From 0068a70e15510d223b0f9b4272e9c69e100b92a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:25:42 +0000 Subject: [PATCH 1/4] v0.5.0: one command, measured performance, 35 new commands Three things this release delivers, against a v0.4 that had the right architecture but the wrong ergonomics. One command. The GUI shell moves from src/Shell into the module, so a PowerShell Gallery install now carries the window as well as the commands - previously the Gallery shipped the module only and the GUI needed a separate archive download. install.ps1 is a bootstrapper pinned to a release tag that prefers the signed Gallery package, falls back to a SHA256-verified release archive with no way to skip the check, and installs a `pctools` command into a directory already on PATH. Invoke-PCTools gives the same actions a scriptable front door with real exit codes. Measured performance. Nothing here had ever been measured, and the hot paths were all the same mistake in different places: independent I/O performed one item at a time. Nine connectivity probes ran in sequence, each waiting out its own timeout; a traceroute walked up to twenty TTLs one at a time; every temp directory was walked twice, once to size and once to delete. They now run concurrently behind a single Invoke-PCParallel helper, and the shipped module is compiled to one file. build/Measure-Performance.ps1 is the arbiter, and docs/PERFORMANCE.md records both the numbers and what was deliberately not done. Thirty-five new commands: storage analysis, startup and service control, health and security reporting, Windows Update and winget, scheduled maintenance, run history, HTML reports and a drop-in extension model. Safety held constant. Every mutating action still supports -WhatIf. The two sharpest new commands are allowlist-driven with no -Force: Set-PCServiceStartup refuses a fixed list of services whose loss breaks a machine, and Remove-PCAppxPackage will only remove preinstalled apps on a curated list that deliberately excludes the Store, App Installer, Photos, Calculator and every framework package. Get-PCSecurityStatus reports posture and has no counterpart that changes it. Bugs found and fixed while building it: - Get-PCResultSummary and Export-PCReport threw on an empty batch, because Measure-Object emits nothing for an empty collection and reading .Sum off nothing is a terminating error under strict mode. A network-only report reaching the HTML writer would have hit this. - Invoke-PCProcess mangled any argument containing a quote. It now applies the escaping rules CommandLineToArgvW actually uses on Windows PowerShell 5.1. - Save-PCHistory overwrote an entry when two runs finished in the same second. - Assigning the result of an `if` unrolls a one-element array to a scalar, so .Count on it threw under strict mode - in four places, each on the common single-item path. - Test-PCMtu used GetNewClosure(), which rebinds a scriptblock to a scope detached from the module's session state; the probe could not see the private helper it needed and every call failed. - Passing a live scriptblock across a runspace boundary corrupts the caller's session state. Invoke-PCParallel marshals the body as text and recompiles it in the target runspace. Tests: 713 assertions across ten suites, four of them new - Parallel, Safety, Extension, Compile and Install. Compile.Tests asserts the compiled module exports exactly what the source does, so the generated artifact cannot drift from the code the tests ran against. The Test-PCMtu search was verified exhaustively against all 1473 possible MTU values. The release pipeline now signs the compiled module before packaging rather than after, so the file users actually execute carries the signature, and publishes that same compiled module to the Gallery. ROADMAP.md is rewritten around v0.6 (trust: a real signing certificate, winget, verifiable builds), v0.7 (fit) and v1.0 (a stable command surface), plus a set of permanent non-goals: no telemetry, no unexplainable registry tweaks, no memory "optimisation", no bulk service-disabling preset, no forced restarts, and no silent changes to security settings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KkdwqfkUYsNSkp9f3AVDqg --- .github/workflows/ci.yml | 43 + .github/workflows/release.yml | 51 +- .gitignore | 4 + CHANGELOG.md | 145 +++- MIGRATION.md | 29 + README.md | 350 ++++++--- ROADMAP.md | 328 +++----- build/Invoke-Build.ps1 | 229 +++++- build/Measure-Performance.ps1 | 297 +++++++ docs/COMMANDS.md | 131 +++ docs/PERFORMANCE.md | 130 +++ install.ps1 | 328 ++++++++ pc-tools.ps1 | 37 +- src/PCTools/PCTools.psd1 | 76 +- src/PCTools/PCTools.psm1 | 72 +- src/PCTools/Private/Clear-PCFolderContent.ps1 | 176 ++++- .../Private/Get-PCCriticalServiceName.ps1 | 63 ++ src/PCTools/Private/Get-PCFunctionSource.ps1 | 59 ++ .../Private/Get-PCRemovableAppxName.ps1 | 84 ++ src/PCTools/Private/Initialize-PCLog.ps1 | 55 ++ src/PCTools/Private/Invoke-PCParallel.ps1 | 200 +++++ src/PCTools/Private/Invoke-PCProcess.ps1 | 104 ++- src/PCTools/Private/Set-PCStartupApproval.ps1 | 68 ++ src/PCTools/Private/Write-PCHtmlReport.ps1 | 182 +++++ src/PCTools/Private/Write-PCLog.ps1 | 2 + .../Public/Automation/Get-PCExtension.ps1 | 85 ++ .../Public/Automation/Get-PCHistory.ps1 | 121 +++ .../Automation/Get-PCScheduledMaintenance.ps1 | 81 ++ .../Automation/Register-PCExtension.ps1 | 167 ++++ .../Register-PCScheduledMaintenance.ps1 | 177 +++++ .../Public/Automation/Save-PCHistory.ps1 | 130 +++ .../Unregister-PCScheduledMaintenance.ps1 | 50 ++ src/PCTools/Public/Export-PCReport.ps1 | 85 +- src/PCTools/Public/Get-PCLogPath.ps1 | 4 + .../Public/Get-PCMaintenanceProfile.ps1 | 41 + src/PCTools/Public/Get-PCResultSummary.ps1 | 98 +++ .../Public/Health/Get-PCBatteryReport.ps1 | 75 ++ .../Public/Health/Get-PCBootPerformance.ps1 | 166 ++++ .../Public/Health/Get-PCDiskHealth.ps1 | 86 ++ .../Public/Health/Get-PCEventSummary.ps1 | 136 ++++ .../Public/Health/Get-PCHealthReport.ps1 | 207 +++++ .../Public/Health/Get-PCSecurityStatus.ps1 | 171 ++++ .../Public/Health/Get-PCSystemInfo.ps1 | 100 +++ src/PCTools/Public/Import-PCConfiguration.ps1 | 9 +- src/PCTools/Public/Invoke-PCMaintenance.ps1 | 25 +- src/PCTools/Public/Invoke-PCTools.ps1 | 193 +++++ .../Public/Network/Get-PCNetworkAdapter.ps1 | 44 +- .../Public/Network/Get-PCNetworkReport.ps1 | 35 +- .../Public/Network/Test-PCConnectivity.ps1 | 132 +++- .../Public/Network/Test-PCDnsServer.ps1 | 180 +++++ src/PCTools/Public/Network/Test-PCMtu.ps1 | 147 +++- src/PCTools/Public/Network/Test-PCRoute.ps1 | 81 +- .../Public/Network/Test-PCThroughput.ps1 | 142 ++++ .../Public/Software/Get-PCAppxPackage.ps1 | 94 +++ .../Public/Software/Get-PCDriverIssue.ps1 | 129 +++ .../Public/Software/Get-PCWindowsUpdate.ps1 | 103 +++ .../Software/Install-PCWindowsUpdate.ps1 | 122 +++ .../Public/Software/Remove-PCAppxPackage.ps1 | 133 ++++ .../Public/Software/Update-PCApplication.ps1 | 148 ++++ src/PCTools/Public/Start-PCTools.ps1 | 86 ++ .../Public/Startup/Disable-PCStartupItem.ps1 | 111 +++ .../Public/Startup/Enable-PCStartupItem.ps1 | 102 +++ src/PCTools/Public/Startup/Get-PCService.ps1 | 91 +++ .../Public/Startup/Get-PCStartupItem.ps1 | 182 +++++ .../Public/Startup/Set-PCServiceStartup.ps1 | 120 +++ .../Public/Storage/Clear-PCCrashDump.ps1 | 105 +++ .../Storage/Clear-PCDeliveryOptimization.ps1 | 74 ++ .../Public/Storage/Clear-PCEventLog.ps1 | 107 +++ .../Public/Storage/Clear-PCThumbnailCache.ps1 | 80 ++ .../Public/Storage/Clear-PCWindowsOld.ps1 | 105 +++ .../Public/Storage/Get-PCDiskSpace.ps1 | 81 ++ .../Public/Storage/Get-PCDiskUsage.ps1 | 230 ++++++ .../Public/Storage/Optimize-PCVolume.ps1 | 146 ++++ .../Shell/Start-PCToolsShell.ps1 | 743 +++++++++++++++++- tests/Compile.Tests.ps1 | 105 +++ tests/Extension.Tests.ps1 | 196 +++++ tests/Install.Tests.ps1 | 133 ++++ tests/PCTools.Unit.Tests.ps1 | 33 +- tests/Parallel.Tests.ps1 | 175 +++++ tests/Safety.Tests.ps1 | 176 +++++ tests/Shell.Tests.ps1 | 16 +- 81 files changed, 9531 insertions(+), 606 deletions(-) create mode 100644 build/Measure-Performance.ps1 create mode 100644 docs/COMMANDS.md create mode 100644 docs/PERFORMANCE.md create mode 100644 install.ps1 create mode 100644 src/PCTools/Private/Get-PCCriticalServiceName.ps1 create mode 100644 src/PCTools/Private/Get-PCFunctionSource.ps1 create mode 100644 src/PCTools/Private/Get-PCRemovableAppxName.ps1 create mode 100644 src/PCTools/Private/Initialize-PCLog.ps1 create mode 100644 src/PCTools/Private/Invoke-PCParallel.ps1 create mode 100644 src/PCTools/Private/Set-PCStartupApproval.ps1 create mode 100644 src/PCTools/Private/Write-PCHtmlReport.ps1 create mode 100644 src/PCTools/Public/Automation/Get-PCExtension.ps1 create mode 100644 src/PCTools/Public/Automation/Get-PCHistory.ps1 create mode 100644 src/PCTools/Public/Automation/Get-PCScheduledMaintenance.ps1 create mode 100644 src/PCTools/Public/Automation/Register-PCExtension.ps1 create mode 100644 src/PCTools/Public/Automation/Register-PCScheduledMaintenance.ps1 create mode 100644 src/PCTools/Public/Automation/Save-PCHistory.ps1 create mode 100644 src/PCTools/Public/Automation/Unregister-PCScheduledMaintenance.ps1 create mode 100644 src/PCTools/Public/Get-PCResultSummary.ps1 create mode 100644 src/PCTools/Public/Health/Get-PCBatteryReport.ps1 create mode 100644 src/PCTools/Public/Health/Get-PCBootPerformance.ps1 create mode 100644 src/PCTools/Public/Health/Get-PCDiskHealth.ps1 create mode 100644 src/PCTools/Public/Health/Get-PCEventSummary.ps1 create mode 100644 src/PCTools/Public/Health/Get-PCHealthReport.ps1 create mode 100644 src/PCTools/Public/Health/Get-PCSecurityStatus.ps1 create mode 100644 src/PCTools/Public/Health/Get-PCSystemInfo.ps1 create mode 100644 src/PCTools/Public/Invoke-PCTools.ps1 create mode 100644 src/PCTools/Public/Network/Test-PCDnsServer.ps1 create mode 100644 src/PCTools/Public/Network/Test-PCThroughput.ps1 create mode 100644 src/PCTools/Public/Software/Get-PCAppxPackage.ps1 create mode 100644 src/PCTools/Public/Software/Get-PCDriverIssue.ps1 create mode 100644 src/PCTools/Public/Software/Get-PCWindowsUpdate.ps1 create mode 100644 src/PCTools/Public/Software/Install-PCWindowsUpdate.ps1 create mode 100644 src/PCTools/Public/Software/Remove-PCAppxPackage.ps1 create mode 100644 src/PCTools/Public/Software/Update-PCApplication.ps1 create mode 100644 src/PCTools/Public/Start-PCTools.ps1 create mode 100644 src/PCTools/Public/Startup/Disable-PCStartupItem.ps1 create mode 100644 src/PCTools/Public/Startup/Enable-PCStartupItem.ps1 create mode 100644 src/PCTools/Public/Startup/Get-PCService.ps1 create mode 100644 src/PCTools/Public/Startup/Get-PCStartupItem.ps1 create mode 100644 src/PCTools/Public/Startup/Set-PCServiceStartup.ps1 create mode 100644 src/PCTools/Public/Storage/Clear-PCCrashDump.ps1 create mode 100644 src/PCTools/Public/Storage/Clear-PCDeliveryOptimization.ps1 create mode 100644 src/PCTools/Public/Storage/Clear-PCEventLog.ps1 create mode 100644 src/PCTools/Public/Storage/Clear-PCThumbnailCache.ps1 create mode 100644 src/PCTools/Public/Storage/Clear-PCWindowsOld.ps1 create mode 100644 src/PCTools/Public/Storage/Get-PCDiskSpace.ps1 create mode 100644 src/PCTools/Public/Storage/Get-PCDiskUsage.ps1 create mode 100644 src/PCTools/Public/Storage/Optimize-PCVolume.ps1 rename src/{ => PCTools}/Shell/Start-PCToolsShell.ps1 (67%) create mode 100644 tests/Compile.Tests.ps1 create mode 100644 tests/Extension.Tests.ps1 create mode 100644 tests/Install.Tests.ps1 create mode 100644 tests/Parallel.Tests.ps1 create mode 100644 tests/Safety.Tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9241a8e..4b454d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,20 @@ jobs: shell: powershell run: ./build/Invoke-Build.ps1 -Task Analyze + # The compiled module is what ships, so a merge that fails to produce an + # importable module must fail here rather than at release time. + - name: Compile the module + shell: powershell + run: ./build/Invoke-Build.ps1 -Task Compile + + # Published as an artifact rather than gated on: runner timing is noisy + # enough that a hard threshold would fail for reasons unrelated to a + # change. The numbers are still worth having on every run. + - name: Benchmark + shell: powershell + continue-on-error: true + run: ./build/Measure-Performance.ps1 -Iterations 3 -OutputPath ./benchmark.json + - name: Upload test results if: always() uses: actions/upload-artifact@v4 @@ -52,6 +66,35 @@ jobs: path: testResults.xml if-no-files-found: ignore + - name: Upload benchmark + if: always() + uses: actions/upload-artifact@v4 + with: + name: benchmark + path: benchmark.json + if-no-files-found: ignore + + docs: + name: Command reference is current + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + # docs/COMMANDS.md is generated from the module's own help. Regenerating + # it here and failing on a diff is what stops it describing a command that + # no longer exists, or missing one that was just added. + - name: Regenerate and compare + shell: powershell + run: | + ./build/Invoke-Build.ps1 -Task Docs + $diff = git diff --name-only -- docs/COMMANDS.md + if ($diff) { + git --no-pager diff -- docs/COMMANDS.md + throw 'docs/COMMANDS.md is out of date. Run ./build/Invoke-Build.ps1 -Task Docs and commit the result.' + } + Write-Host 'Command reference is up to date.' + test-pwsh7: name: Test on PowerShell 7 runs-on: windows-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1cdcddd..97503fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,14 +53,18 @@ jobs: shell: powershell run: ./build/Invoke-Build.ps1 -Task All - - name: Stage artifacts and checksums + # Compiled first so the signing step below can sign the module that + # actually ships, before it is packed into an archive. Signing after + # packaging would leave the file inside every zip unsigned - which is the + # one file users execute. + - name: Compile the module shell: powershell - run: ./build/Invoke-Build.ps1 -Task Release -Version ${{ steps.version.outputs.version }} + run: ./build/Invoke-Build.ps1 -Task Compile # Signing runs only when a certificate is configured. Without it the # release still ships, unsigned, with checksums - which is what the # project has today. - - name: Sign scripts + - name: Sign the module and scripts if: ${{ secrets.SIGNING_CERTIFICATE != '' }} env: SIGNING_CERTIFICATE: ${{ secrets.SIGNING_CERTIFICATE }} @@ -72,15 +76,23 @@ jobs: $password = ConvertTo-SecureString $env:SIGNING_PASSWORD -AsPlainText -Force $cert = Get-PfxCertificate -FilePath $pfxPath -Password $password - Get-ChildItem ./out -Include *.ps1, *.psm1, *.psd1 -Recurse | ForEach-Object { - Set-AuthenticodeSignature -FilePath $_.FullName -Certificate $cert ` + # The compiled module, the GUI shell inside it, the bootstrapper and + # the legacy single-file tools - everything a user can execute. + $targets = @() + $targets += Get-ChildItem ./obj/PCTools -Include *.ps1, *.psm1, *.psd1 -Recurse -File + $targets += Get-ChildItem . -Filter *.ps1 -File + + foreach ($target in $targets) { + Set-AuthenticodeSignature -FilePath $target.FullName -Certificate $cert ` -TimestampServer 'http://timestamp.digicert.com' -HashAlgorithm SHA256 } Remove-Item $pfxPath -Force - # Signing rewrites the files, so the checksums must be regenerated. - ./build/Invoke-Build.ps1 -Task Checksum + # -SkipCompile so the signatures written above survive into the archives. + - name: Stage artifacts and checksums + shell: powershell + run: ./build/Invoke-Build.ps1 -Task Release -SkipCompile -Version ${{ steps.version.outputs.version }} # Publishing to the Gallery is opt-in: without an API key the release # still ships as an archive. Gallery versions are immutable, so this runs @@ -91,7 +103,10 @@ jobs: env: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} run: | - $module = './src/PCTools' + # The compiled, signed module - not the source tree. Publishing the + # sources would ship the sixty-file dot-sourcing loader and, when a + # certificate is configured, discard the signatures. + $module = './obj/PCTools' # Refuse to republish a version that already exists - the Gallery # rejects it anyway, and failing here says why. @@ -110,15 +125,23 @@ jobs: out/* generate_release_notes: true body: | - ## Verifying this release + ## Install + + ```powershell + irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/${{ github.ref_name }}/install.ps1 | iex + ``` + + The installer prefers the signed PowerShell Gallery package and falls + back to the archive below, verifying it against `SHA256SUMS` first. + + ## Verifying this release yourself - Every file below is listed in `SHA256SUMS`. Verify before running: + Every file below is listed in `SHA256SUMS`: ```powershell - $file = 'pc-tools.ps1' - (Get-FileHash $file -Algorithm SHA256).Hash.ToLower() + (Get-FileHash pc-tools-${{ github.ref_name }}.zip -Algorithm SHA256).Hash.ToLower() # compare against the matching line in SHA256SUMS ``` - Install commands are pinned to this tag in the - [README](https://github.com/likeBloodMoon/pc-powershelltools#install). + Full instructions are in the + [README](https://github.com/likeBloodMoon/pc-powershelltools#install-and-run-in-one-command). diff --git a/.gitignore b/.gitignore index a1e41b1..c99b7f3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ desktop.ini *.psproj testResults*.xml obj/ + +# Benchmark output. Machine- and load-specific, so a committed baseline would +# mislead rather than help; CI publishes each run as an artifact instead. +build/benchmark.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c0995..4c45945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,147 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Nothing yet. + +## [0.5.0] - 2026-09-02 + +One command to install and run, hot paths measured rather than assumed, and +roughly double the commands. + +### Added + +#### One command + +- `install.ps1`: a bootstrapper pinned to a release tag. Prefers the signed + PowerShell Gallery package; falls back to the release archive, which it + verifies against the published `SHA256SUMS` and refuses to unpack on a + mismatch. There is no switch to skip that check. Installs a `pctools` command + into `%LOCALAPPDATA%\Microsoft\WindowsApps`, already on PATH on Windows 10 + and 11, so no PATH edit and no elevation. +- `Start-PCTools` (alias `pctools`) opens the window. The shell moved from + `src/Shell` into the module, so `Install-Module PCTools` now delivers the GUI + as well as the commands - previously the Gallery shipped the module only. +- `Invoke-PCTools`: a scriptable front door with exit codes (`0` success, + `1` warnings, `2` failures, `3010` restart required), `-Json`, and + `-Report Html|Json|Text|Csv`. What the `pctools` shim invokes. + +#### Storage and startup + +- `Get-PCDiskSpace`, with a free-space pressure grade rather than a bare number. +- `Get-PCDiskUsage`: largest folders and files, over `System.IO` enumeration. +- `Clear-PCDeliveryOptimization`, `Clear-PCWindowsOld`, `Clear-PCThumbnailCache`, + `Clear-PCCrashDump`, `Clear-PCEventLog`. +- `Optimize-PCVolume`: reads the media type and picks TRIM for an SSD or + defragmentation for an HDD. There is no switch to force the wrong one. +- `Get-PCStartupItem`: Run keys, Startup folders **and** logon scheduled tasks in + one list - Task Manager omits the third, which is where most modern updater + software lives. `Disable-PCStartupItem` / `Enable-PCStartupItem` write the same + `StartupApproved` flag Task Manager writes, so nothing is deleted and either + tool can undo the other. +- `Get-PCService` and `Set-PCServiceStartup`, the latter refusing a fixed list of + services whose loss breaks a machine. No `-Force`. + +#### Health and security + +- `Get-PCSystemInfo`, `Get-PCDiskHealth` (SMART wear, reallocated sectors, + temperature), `Get-PCBatteryReport` (capacity loss against design). +- `Get-PCBootPerformance`: boot time and, from the diagnostics log, the specific + applications, services and drivers that lengthen it and by how many + milliseconds. +- `Get-PCEventSummary`: errors and criticals grouped by source and event ID + rather than listed, with bugchecks pulled out separately. +- `Get-PCSecurityStatus`: Defender, firewall, BitLocker, UAC, SmartScreen and + pending updates. Read-only by design; there is no counterpart that changes + them. +- `Get-PCHealthReport`: a graded score with the findings behind it, each naming + the command that addresses it. The GUI dashboard renders this. + +#### Software + +- `Get-PCWindowsUpdate` and `Install-PCWindowsUpdate` over the + `Microsoft.Update.Session` COM API - no third-party module dependency in a tool + that runs elevated. Driver updates are excluded unless named explicitly. +- `Update-PCApplication`: `winget upgrade` with per-package results. +- `Get-PCDriverIssue`: problem devices with their Configuration Manager code + decoded into what it actually means. +- `Get-PCAppxPackage` and `Remove-PCAppxPackage`, allowlist-only. The Store, + App Installer, Photos, Calculator, Terminal and every framework package are + deliberately absent from that list. + +#### Automation and reporting + +- `Register-PCScheduledMaintenance`, `Get-`, `Unregister-`. Unattended runs are + limited to the profiles that cannot demand a restart. +- `Save-PCHistory` and `Get-PCHistory`, including `-Summary` for the trend across + runs. The GUI records every non-preview run. +- `Register-PCExtension` / `Get-PCExtension`: drop-in `*.PCAction.ps1` actions + usable in a profile like any built-in. An extension cannot replace a built-in + command. +- `Get-PCResultSummary`: the one-line "what just happened", shared by the GUI, + the CLI and the HTML report so they cannot disagree. + +#### Other + +- `Test-PCThroughput` and `Test-PCDnsServer` - the speed test and resolver + benchmark the roadmap has promised since v0.1. +- New profiles: `Storage`, `Health` (read-only) and `Weekly`. +- `Export-PCReport -Format Html`: a single self-contained file with no external + stylesheet, font or script, so it renders identically offline. +- `build/Measure-Performance.ps1`, and `-Task Compile`, `-Task Docs` and + `-Task Bench` in the build script. +- `docs/COMMANDS.md`, generated from the module's own help, and + `docs/PERFORMANCE.md`. +- New test suites: `Parallel`, `Safety`, `Compile`, `Install`. + +### Changed + +- **The hot paths run concurrently.** `Test-PCConnectivity` ran nine probes in + sequence, each waiting out its own timeout; `Test-PCRoute` walked up to twenty + TTLs one at a time; `Test-PCMtu` made about eleven sequential probes; + `Get-PCNetworkReport -Full` ran six external captures one after another. All + now run at once behind a new `Invoke-PCParallel` helper. Output ordering is + unchanged - it is the diagnostic value. +- **`Get-PCNetworkAdapter` makes three CIM queries instead of two per adapter.** + A laptop with Wi-Fi, Ethernet, Bluetooth, Hyper-V and VPN adapters was paying + twenty-odd round-trips to build one table. +- **`Clear-PCFolderContent` walks each tree once instead of twice.** It sized + with `Get-ChildItem -Recurse` and then deleted with `Remove-Item -Recurse`; + it now sizes and deletes in a single `System.IO` pass, with top-level entries + cleared concurrently. +- **A locked file no longer costs its whole directory.** Deleting per file rather + than per top-level entry means one held-open browser cache file leaves that + file behind, not the directory containing it. `BytesFreed` is now counted after + each successful delete, so it reports what was reclaimed rather than what was + hoped for. +- **`Invoke-PCProcess` reads the pipes instead of two temp files.** It created + two files with `GetTempFileName`, wrote output to disk and read it back, on + every external command. It also now escapes arguments by the rules + `CommandLineToArgvW` actually applies on Windows PowerShell 5.1; the previous + quoting mangled any argument containing a quote. +- **The shipped module is compiled to one file.** `-Task Compile` merges the + sources so an import parses one file rather than sixty, stamps the version + instead of re-reading the manifest, and prepares the log file on first write + instead of at import. Measured: about 520 ms to 400 ms. `tests/Compile.Tests.ps1` + asserts the compiled and source modules export exactly the same surface. +- The GUI gained Dashboard, Storage, Health and Software pages, and opens on the + dashboard. `Start-PCTools -Page` chooses another. +- The PSScriptAnalyzer warning budget rose from 146 to 210 for the new code. It + is a ceiling to lower, not a target. + +### Fixed + +- `Get-PCResultSummary` and `Export-PCReport` no longer throw on an empty batch. + `Measure-Object` emits nothing for an empty collection, so reading `.Sum` off + it is a terminating error under `Set-StrictMode -Version Latest` - which a + network-only report reaching the HTML writer would have hit. +- `Save-PCHistory` no longer overwrites an entry when two runs finish in the same + second. +- `Test-PCMtu` no longer uses `GetNewClosure()`. It rebinds a scriptblock to a + scope detached from the module's session state, so the probe could not see the + private helper it needed and every call failed. + +## [0.4.0] - 2026-01-15 + ### Added - `PCTools` module (`src/PCTools`): 29 public functions covering cleanup, repair, network, preferences and software installation. Every action returns a @@ -116,6 +257,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Prefetch cleanup, DISM + SFC + CHKDSK, network stack reset, adapter IP/DNS configuration, Windows preference toggles, winget app installation. -[Unreleased]: https://github.com/likeBloodMoon/pc-powershelltools/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/likeBloodMoon/pc-powershelltools/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/likeBloodMoon/pc-powershelltools/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/likeBloodMoon/pc-powershelltools/compare/v0.2.0...v0.4.0 [0.2.0]: https://github.com/likeBloodMoon/pc-powershelltools/releases/tag/v0.2.0 [0.1.0]: https://github.com/likeBloodMoon/pc-powershelltools/releases/tag/v0.1.0 diff --git a/MIGRATION.md b/MIGRATION.md index ac9a1ca..bb47fc2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -83,6 +83,35 @@ Names: `DarkMode`, `DisableBingSearch`, `ShowHiddenFiles`, `ShowFileExtensions`, - **Subnet masks are converted, not passed through.** `255.0.255.0` is rejected as invalid rather than handed to `netsh`. + +## New in v0.5 + +The commands above all still work exactly as documented. v0.5 added areas the +single-file tools never covered at all: + +| Question | Command | +|---|---| +| How is this machine doing? | `Get-PCHealthReport` | +| Where did my disk space go? | `Get-PCDiskSpace`, `Get-PCDiskUsage` | +| What starts with Windows? | `Get-PCStartupItem`, `Disable-PCStartupItem` | +| Is this disk failing? | `Get-PCDiskHealth` | +| Why is boot so slow? | `Get-PCBootPerformance` | +| What keeps crashing? | `Get-PCEventSummary` | +| Is this machine protected? | `Get-PCSecurityStatus` | +| What needs updating? | `Get-PCWindowsUpdate`, `Update-PCApplication` | +| Can this run on a schedule? | `Register-PCScheduledMaintenance` | +| How much have I reclaimed over time? | `Get-PCHistory -Summary` | + +And the entry points changed shape: + +| Was | Now | +|---|---| +| `.\pc-tools.ps1` | `Start-PCTools`, or `pctools` | +| `.\pc-tools.ps1 -NoGui` | `Import-Module PCTools` | +| (nothing) | `Invoke-PCTools -ProfileName Quick -Exit`, for scripts and scheduled tasks | + +`.\pc-tools.ps1` still works from a clone or an extracted release archive. + ## Running the old tools Nothing was removed: diff --git a/README.md b/README.md index 586e82c..b2e4cea 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # pc-powershelltools -Windows maintenance, repair and network diagnostics: a PowerShell module with a -GUI on top. +Windows maintenance, repair, storage analysis, health reporting and network +diagnostics. One PowerShell module, with a GUI on top and a scriptable CLI +underneath. > **Disclaimer** > These tools are provided **as-is**. I am not liable for any system issues or @@ -9,94 +10,141 @@ GUI on top. --- -## What is here +## Install and run, in one command -| Component | What it is | -|---|---| -| `PCTools` module | Every action, as ordinary PowerShell commands. Supports `-WhatIf`. | -| PC Tools shell | One GUI over the module: Maintenance, Network, Preferences, Log. | -| `pc-cleanuptool.ps1`, `pc-netdiag.ps1` | The original single-file tools. Still work, no longer developed. | -| `quickspeedboost.ps1` | A console script. See [the note below](#about-quickspeedboostps1). | +```powershell +irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.5.0/install.ps1 | iex +``` -The module is the product; the GUI is a face on it. Anything the window can do, -a console session or a scheduled task can do too. +That installs PC Tools, adds a `pctools` command, and opens the window. ---- +**Note the version in that URL.** It is not `main`, on purpose: pinning to a tag +means you run a specific, reviewed release rather than whatever was pushed most +recently. + +What the installer actually does: + +1. Installs `PCTools` from the PowerShell Gallery, where the package is signed + and PowerShellGet verifies it. +2. If the Gallery is unreachable, downloads the release archive **and** its + `SHA256SUMS`, verifies the hash, and refuses to continue on a mismatch. There + is no switch to skip that check. +3. Drops a `pctools` command into `%LOCALAPPDATA%\Microsoft\WindowsApps`, which + is already on `PATH` on Windows 10 and 11 - so no PATH edit and no elevation. +4. Opens the window. -## Install +**The honest limitation:** step 1 runs a script fetched over TLS from a pinned +URL, which you are trusting on the strength of that connection. Everything after +it is signature- or hash-verified. If you would rather verify the first step too, +use the manual path below. -Download the release archive, **verify it**, and run it from disk: +### Without the bootstrapper ```powershell -# 1. Download the current release and its checksums -$version = 'v0.4.0' +Install-Module PCTools -Scope CurrentUser +Start-PCTools +``` + +The Gallery package now carries the GUI as well as the commands, so this is a +complete install. `Update-Module PCTools` keeps it current. + +### Verifying everything yourself + +```powershell +$version = 'v0.5.0' $base = "https://github.com/likeBloodMoon/pc-powershelltools/releases/download/$version" Invoke-WebRequest "$base/pc-tools-$($version.TrimStart('v')).zip" -OutFile pc-tools.zip Invoke-WebRequest "$base/SHA256SUMS" -OutFile SHA256SUMS -# 2. Check the hash against the published list before running anything $actual = (Get-FileHash pc-tools.zip -Algorithm SHA256).Hash.ToLower() $expected = (Select-String -Path SHA256SUMS -Pattern 'pc-tools-.*\.zip').Line.Split(' ')[0] if ($actual -ne $expected) { throw 'Checksum mismatch - do not run this file.' } -# 3. Extract and launch Expand-Archive pc-tools.zip -DestinationPath .\pc-tools .\pc-tools\pc-tools.ps1 ``` -### From the PowerShell Gallery +Or clone the repository and run `.\pc-tools.ps1`. -Once a release is published there, this is the least ceremony: +--- + +## Three ways to use it ```powershell -Install-Module PCTools -Scope CurrentUser -Import-Module PCTools -Invoke-PCMaintenance -ProfileName Quick -WhatIf +pctools # the window +pctools -ProfileName Quick # run and exit, with an exit code +Import-Module PCTools; Get-PCHealthReport # every action as an ordinary command ``` -The Gallery gives you signed-package verification, versioning and -`Update-Module` for free, which is everything the `irm | iex` route does not. -It installs the module only; the GUI comes with the release archive. - -Or clone the repository and run `.\pc-tools.ps1`. - -### A note on `irm | iex` +The module is the product. The GUI and the CLI are two faces on it, so anything +the window can do, a console session or a scheduled task can do too. -Earlier versions of this README told you to pipe a script straight from the -`main` branch into `iex` as Administrator. Those commands still work and are -kept below so existing links do not break, but understand the trade: `main` is -whatever was pushed to it most recently, so you are running unreviewed, -unpinned, unverified code with full administrator rights. +### The command line -If you use them, pin to a release tag rather than `main`: +`Invoke-PCTools` (aliased as `pctools`) is built for scripts and scheduled tasks: ```powershell -# Legacy single-file tools, pinned to a tag -irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.4.0/pc-cleanuptool.ps1 | iex -irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.4.0/pc-netdiag.ps1 | iex +pctools -ProfileName Quick -Json # JSON on stdout +pctools -ProfileName Recommended -Report Html -Quiet +pctools -Diagnose # network verdict ``` -The verified download above is the recommended path. +Exit codes, so it composes: + +| Code | Meaning | +|---|---| +| `0` | every action succeeded | +| `1` | at least one warning, nothing failed | +| `2` | at least one action failed | +| `3010` | succeeded, but a restart is required | + +--- + +## What it does + +| Area | Commands | +|---|---| +| **Clean up** | Temp files, Recycle Bin, browser caches, Windows Update cache, Delivery Optimization, crash dumps, thumbnails, Prefetch, `Windows.old` | +| **Analyse storage** | Free space per drive with a pressure grade, and a fast scan for the largest folders and files | +| **Repair** | DISM component store, SFC system files, CHKDSK, restore points | +| **Diagnose the network** | Layered connectivity verdict, traceroute, path MTU, Wi-Fi signal, throughput, DNS resolver benchmark | +| **Configure the network** | Static IP, DNS presets, DHCP, Winsock/TCP-IP reset, and Home/Work adapter profiles | +| **Report health** | System info, SMART disk health, battery wear, boot time and what slows it, crash and error history | +| **Check security posture** | Defender, firewall, BitLocker, UAC, SmartScreen, pending updates - read-only, always | +| **Control startup** | Every autostart from Run keys, Startup folders and logon tasks, reversibly disabled | +| **Manage services** | Startup types, with a hard refusal list for the ones that break a machine | +| **Update software** | Windows Update over the COM API, `winget upgrade`, problem-device detection | +| **Remove bloatware** | Preinstalled apps, from a curated allowlist only | +| **Automate** | Scheduled maintenance, run history and trends, HTML/JSON/text/CSV reports | +| **Extend** | Drop-in custom actions usable in your own profiles | + +The full reference is in [docs/COMMANDS.md](docs/COMMANDS.md), generated from +the module's own help. --- ## Using the GUI ```powershell -.\pc-tools.ps1 # dark theme -.\pc-tools.ps1 -Theme Light +Start-PCTools # dark theme, dashboard +Start-PCTools -Theme Light -Page Network +Start-PCTools -Elevate # relaunch as administrator ``` +- **Dashboard** - a health score out of 100 with the reasons behind it, disk + space, what the last run reclaimed, and when the next scheduled one is due. - **Maintenance** - pick a profile, press **Preview** to see exactly what would - run without changing anything, then **Run maintenance**. Results show status, - space reclaimed and detail per action. -- **Network** - a verdict banner naming the failing layer ("DNS is not - resolving") rather than a table of pass/fail rows to interpret yourself, plus - the adapter list and the common fixes. + run without changing anything, then **Run maintenance**. +- **Storage** - drives with a free-space pressure grade, the reclaims, and a + scan for the largest folders and files. +- **Health** - read-only reports. Nothing on this page changes the machine. +- **Network** - a verdict naming the failing layer ("DNS is not resolving") + rather than a table of pass/fail rows to interpret yourself. +- **Software** - Windows updates, application updates, device problems, and + one-click scheduled maintenance. - **Preferences** - checkboxes that reflect what Windows currently has set. Clearing one restores the Windows default. -- **Log** - everything the session has done. The full log is on disk; the path - is in the log pane at startup. +- **Log** - everything the session has done. Long-running work happens on a background runspace, so the window stays responsive during a DISM or SFC run. @@ -106,8 +154,7 @@ responsive during a DISM or SFC run. ## Using the module ```powershell -Import-Module .\src\PCTools\PCTools.psd1 - +Import-Module PCTools Get-Command -Module PCTools Get-Help Invoke-PCMaintenance -Full ``` @@ -125,30 +172,69 @@ Invoke-PCMaintenance -ProfileName Quick | Format-Table Action, Status, FreedDisplay, Detail ``` +How is this machine doing: + +```powershell +$health = Get-PCHealthReport +$health.Score # 0-100 +$health.Findings | Format-Table Severity, Area, Message, Command +``` + +Where did the disk space go: + +```powershell +Get-PCDiskSpace | Format-Table Drive, FreeDisplay, PercentFree, Pressure +(Get-PCDiskUsage -Path C:\ -Top 20).LargestFolders | Format-Table SizeDisplay, Path +``` + Diagnose a network problem: ```powershell $report = Get-PCNetworkReport $report.Verdict.Verdict # e.g. 'DNS is not resolving' $report.Verdict.Advice -$report | Export-PCReport +$report | Export-PCReport -Format Html ``` -Save and restore adapter configurations: +What starts with Windows, and switch something off reversibly: ```powershell -Export-PCNetworkProfile -Name Ethernet -ProfileName Office -Get-PCNetworkProfile | Format-Table ProfileName, Adapter, Dhcp, IPv4Address -Import-PCNetworkProfile -ProfileName Office -WhatIf +Get-PCStartupItem | Where-Object Enabled | Format-Table Name, Source, Command +Disable-PCStartupItem -Name 'Spotify' -WhatIf +Enable-PCStartupItem -Name 'Spotify' # back on ``` -Define your own maintenance profiles in JSON: +Keep it maintained without remembering to: + +```powershell +Register-PCScheduledMaintenance -ProfileName Weekly -Frequency Weekly +Get-PCScheduledMaintenance | Format-Table TaskName, NextRunTime, LastResult +Get-PCHistory -Summary +``` + +### Maintenance profiles + +| Profile | What it does | +|---|---| +| `Quick` | Reclaim disk space. No repairs, no restart. | +| `Recommended` | Quick, plus the Windows Update cache and a health scan. Restore point first. | +| `Full` | Adds component-store and system-file repair. Can run for an hour; expect a restart. | +| `Storage` | The deeper reclaims: update and delivery caches, crash dumps, thumbnails. | +| `Health` | Read-only. Reports, changes nothing. | +| `Weekly` | The sensible default for a schedule. No restart. | +| `NetworkRepair` | Diagnose, then apply the standard network fixes. Requires a restart. | + +Prefetch cleanup, the network stack reset, removing `Windows.old`, clearing +event logs and uninstalling apps are deliberately **not** in any profile. Each +gives up something that cannot be got back, so each has to be asked for by name. + +### Your own profiles ```json { "profiles": [ { - "name": "Weekly", + "name": "Friday", "description": "What I actually run on Fridays", "restorePoint": false, "actions": [ @@ -162,39 +248,47 @@ Define your own maintenance profiles in JSON: ```powershell Import-PCConfiguration -Path .\my-profiles.json -Invoke-PCMaintenance -ProfileName Weekly -WhatIf +Invoke-PCMaintenance -ProfileName Friday -WhatIf ``` Action names are validated at import, so a typo fails there rather than partway -through a run. A profile named the same as a built-in overrides it. +through a run. + +### Your own actions -Individual actions: +Write a function that returns a `PCTools.ActionResult`, save it as +`*.PCAction.ps1` under `%LOCALAPPDATA%\PCTools\extensions`, and it can be named +in a profile like any built-in: ```powershell -Clear-PCTempFile -Clear-PCBrowserCache -Browser Chrome, Edge -Set-PCDnsServer -Name Ethernet -Preset Cloudflare -Set-PCPreference -Name DarkMode, ShowFileExtensions -Enabled $true -Set-PCPreference -Name DisableMouseAcceleration -Enabled $false # revert -Repair-PCSystemImage -ScanOnly - -Test-PCRoute -Target 1.1.1.1 # where does connectivity stop -Test-PCMtu # large transfers stalling? -Get-PCWirelessStatus # signal, band and rate +# %LOCALAPPDATA%\PCTools\extensions\backup.PCAction.ps1 +function Invoke-PCMyBackup { + [CmdletBinding()] + param([string]$Destination = 'D:\Backups') + Invoke-PCAction -Name 'Invoke-PCMyBackup' -Body { + # Invoke-PCAction handles the timing, logging and failure shape. + @{ Detail = "copied to $Destination" } + } +} ``` -### Maintenance profiles +```powershell +Register-PCExtension -PassThru +Get-PCExtension | Format-Table Name, Loaded, File -| Profile | What it does | -|---|---| -| `Quick` | Reclaim disk space. No repairs, no restart. | -| `Recommended` | Quick, plus the Windows Update cache and a health scan. Restore point first. | -| `Full` | Adds component-store and system-file repair. Can run for an hour; expect a restart. | -| `NetworkRepair` | Diagnose, then apply the standard network fixes. Requires a restart. | +Invoke-PCMaintenance -Action 'Invoke-PCMyBackup' # or name it in a profile +``` -Prefetch cleanup and the network stack reset are deliberately **not** in any -general profile. Both cost you something, and neither belongs in a preset -someone runs without reading. They remain available as individual commands. +Extension actions run through `Invoke-PCMaintenance` and profiles, not as +top-level commands. They are loaded into the module's scope so they can use +`Invoke-PCAction` and the other internals - which is what makes their results the +same shape as a built-in's - and a module cannot export a function after it has +finished loading. + +An extension is ordinary PowerShell running with your rights. Nothing sandboxes +it. It cannot silently replace a built-in command - that is refused - and a file +that does not parse is skipped rather than taking the others down with it. But +load only extensions you wrote or would be willing to read. --- @@ -204,22 +298,47 @@ someone runs without reading. They remain available as individual commands. same code path that performs the work, so the plan cannot drift from the action. - **Restore point as a gate.** Profiles that repair or reset take a checkpoint - first, and abort if one cannot be created. Override deliberately with - `-SkipRestorePoint`. + first, and abort if one cannot be created. Override with `-SkipRestorePoint`. +- **Allowlists, not blocklists, for the destructive commands.** + `Set-PCServiceStartup` refuses a fixed list of services that break a machine. + `Remove-PCAppxPackage` will only remove preinstalled apps on a curated list. + Neither has a `-Force` to talk it round; `Set-Service` and `Remove-AppxPackage` + are still there if you genuinely mean it. +- **Security settings are reported, never changed.** `Get-PCSecurityStatus` tells + you the firewall is off. It will not turn it on: a maintenance tool that + reconfigures your antivirus is indistinguishable from malware. - **`Reset-PCNetworkStack` is high impact and says so.** Resetting Winsock removes third-party layered service providers, which is what breaks some VPN clients until they are reinstalled. Use `-SkipWinsock` to avoid that. - **Failures are reported, not swallowed.** DISM, SFC, CHKDSK and winget exit codes are interpreted; a failed repair does not look like a successful one. +- **No telemetry, ever.** Nothing here reports home. The only network traffic is + what a diagnostic explicitly probes and what an update command downloads. - **Everything is logged** to `%LOCALAPPDATA%\PCTools\logs`, rotated at 2 MB. --- +## Speed + +v0.5 measured the hot paths for the first time and fixed what the numbers +showed - mostly independent I/O that was being done one item at a time. Module +import is about a third faster, eight concurrent probes take the time of one +rather than eight, and a path-MTU probe converges in at most six rounds instead +of eleven sequential ones. The method and the figures are in +[docs/PERFORMANCE.md](docs/PERFORMANCE.md); reproduce them with: + +```powershell +./build/Measure-Performance.ps1 +./build/Measure-Performance.ps1 -Baseline ./build/benchmark.json +``` + +--- + ## Requirements - Windows 10 or 11 -- Windows PowerShell 5.1 (the GUI uses WinForms). The module itself also loads - under PowerShell 7. +- Windows PowerShell 5.1 (the GUI uses WinForms). The module also loads under + PowerShell 7. - Administrator rights for repair, network and system-cache actions. The shell runs without them and offers a **Restart as admin** button; actions that need elevation fail with a clear message rather than a cascade of access-denied @@ -232,7 +351,10 @@ someone runs without reading. They remain available as individual commands. ```powershell ./build/Invoke-Build.ps1 -Task All # Pester suite, then PSScriptAnalyzer ./build/Invoke-Build.ps1 -Task Test +./build/Invoke-Build.ps1 -Task Compile # merge the module into one .psm1 +./build/Invoke-Build.ps1 -Task Docs # regenerate docs/COMMANDS.md ./build/Invoke-Build.ps1 -Task Release # stage out/ and write SHA256SUMS +./build/Measure-Performance.ps1 # benchmark the hot paths ``` CI runs the suite on both Windows PowerShell 5.1 and PowerShell 7. Releases are @@ -242,38 +364,54 @@ certificate is configured. Layout: ``` -src/PCTools/ the module - Public/ by domain, Private/ for helpers -src/Shell/ the WinForms shell -tests/ Pester: syntax, module contract, unit, shell static analysis -build/ the single Test/Analyze/Release entry point +install.ps1 the one-command bootstrapper +src/PCTools/ the module - Public/ by domain, Private/ for helpers, Shell/ for the GUI +tests/ Pester: syntax, module contract, unit, parallel, safety, compile, shell +build/ Test/Analyze/Compile/Docs/Release, and the benchmark harness +docs/ generated command reference and performance notes ``` +The shipped module is compiled: `-Task Compile` merges the sources into a single +`.psm1` so an import parses one file instead of sixty. `tests/Compile.Tests.ps1` +asserts the compiled module exports exactly what the source does, so the two +cannot drift. + --- -## About `quickspeedboost.ps1` +## The older tools + +`pc-cleanuptool.ps1`, `pc-netdiag.ps1` and `gui-framework.ps1` are the original +single-file tools. They still work and are still shipped, but they are frozen: +bug fixes only. [MIGRATION.md](MIGRATION.md) maps every old function to its +replacement. + +`quickspeedboost.ps1` is kept for reference, but most of what it does is +counterproductive: `EmptyWorkingSet` on every process forces those pages straight +back off disk, and purging the standby list discards the cache Windows built to +make things fast. Killing `dwm.exe` is the sharpest edge in the repository. The +parts worth having - temp cleanup, DNS flush, a safe Explorer restart - are in +the module as `Clear-PCTempFile`, `Clear-PCDnsCache` and `Restart-PCExplorer`. + +### A note on `irm | iex` -It is kept for reference, but most of what it does is counterproductive: -`EmptyWorkingSet` on every process forces those pages straight back off disk, -and purging the standby list discards the cache Windows built to make things -fast. Killing `dwm.exe` is the sharpest edge in the repository. +Earlier versions told you to pipe a script straight from the `main` branch into +`iex` as Administrator. Those commands still work and are kept so existing links +do not break, but `main` is whatever was pushed to it most recently. If you use +them, pin to a tag: -The parts worth having - temp cleanup, DNS flush, a safe Explorer restart - are -in the module as `Clear-PCTempFile`, `Clear-PCDnsCache` and `Restart-PCExplorer`. -`Restart-PCExplorer` waits for the shell to come back rather than sleeping a -fixed two seconds and hoping, which is how the original could leave you with no -taskbar. +```powershell +irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.5.0/pc-cleanuptool.ps1 | iex +irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.5.0/pc-netdiag.ps1 | iex +``` + +The installer at the top of this file is the recommended path: it is pinned, +and it verifies what it downloads. --- ## Roadmap -See [ROADMAP.md](ROADMAP.md). Phases 0-5 are done - the Gallery publish step is -wired up and runs on a tagged release once an API key is configured. - -On the "portable executable" item: `ps2exe` output is unsigned and reliably -trips SmartScreen and antivirus heuristics, which is worse for trust than the -current script. The signed module plus the checksummed archive is the better -answer unless a code-signing certificate is in the budget. +See [ROADMAP.md](ROADMAP.md). Suggestions and contributions are welcome. diff --git a/ROADMAP.md b/ROADMAP.md index 49c271c..829c13c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,227 +1,127 @@ -# pc-powershelltools — Roadmap +# pc-powershelltools - Roadmap -A plan for turning four standalone scripts into one maintained toolkit. +Where this is going, and what it deliberately will not do. --- -## Where the project stands today - -| Script | Lines | Role | State | -|---|---|---|---| -| `pc-cleanuptool.ps1` | 903 | Cleanup / repair / network / prefs GUI | v0.1, oldest architecture | -| `pc-netdiag.ps1` | 1089 | Network diagnostics GUI | v0.2, most polished features | -| `gui-framework.ps1` | 710 | Reusable GUI shell demo | Best architecture, unused | -| `quickspeedboost.ps1` | 228 | Console "refresh" script | Undocumented, riskiest | - -### What is already good - -- `gui-framework.ps1` is the only file with a correct concurrency model: a runspace - pool, `Invoke-UI` marshalling, a `ConcurrentQueue` log pump, a task-completion - timer, theming and notifications. It is a real application shell. -- `pc-netdiag.ps1` has the best operational hygiene: log rotation, a global - `ThreadException` handler, `Invoke-ExternalWithTimeout` (timeout + output - truncation + temp-file cleanup), and structured JSON/TXT report export. -- `pc-cleanuptool.ps1` handles the in-memory (`irm | iex`) case by falling back to - `$env:LOCALAPPDATA` when `$PSCommandPath` is empty. - -### What blocks growth - -1. **Three codebases, one product.** Three separate WinForms UIs, three logging - implementations, three admin checks. Temp cleanup, DNS flush and Prefetch - cleanup are each implemented two or three times, differently. -2. **The UI thread does the work.** In `pc-cleanuptool.ps1` every `Add_Click` - handler runs synchronously — `DISM /RestoreHealth`, `sfc /scannow` and - `chkdsk C: /scan` can each run for tens of minutes with the window frozen and - marked "Not Responding". `pc-netdiag.ps1` is the same: `Start-QuickDiagnostics` - and `Start-FullDiagnosticsWorker` are labelled "in-process" and only guard with - a `$global:Busy` flag. The README's claim that Net Diag runs "asynchronous runs - to keep UI responsive" no longer matches the code. -3. **No repo infrastructure.** No LICENSE, `.gitignore`, CHANGELOG, tags, - releases, tests, or CI. Versions exist only as prose in the README. -4. **Distribution asks for a lot of trust.** The README instructs users to pipe - `main` straight into `iex` as Administrator — unpinned, unsigned, unhashed. - Any push to `main` immediately becomes what every user executes. -5. **Actions are irreversible and unannounced.** "Run all" performs a restore - point (only if ticked), Prefetch deletion, `netsh winsock reset`, - `netsh int ip reset`, DISM, SFC and CHKDSK with no confirmation, no dry run, - and no summary of what changed or how much space was reclaimed. -6. **Non-standard verbs everywhere** — `Flush-`, `Run-`, `Create-`, `Apply-`, - `Load-`, `Require-`, `Cleanup-`, `Append-`, `With-`. Fine in a script, blocking - for a module: `Import-Module` warns on every one. +## Where the project stands ---- +v0.5.0. The module is the product: 74 commands covering cleanup, storage +analysis, repair, network diagnostics and configuration, health and security +reporting, startup and service control, updates, and automation. The GUI ships +inside it. `Invoke-PCTools` gives the same actions a scriptable front door with +a meaningful exit code. -## Phase 0 — Foundation ✅ done - -Nothing here changes behaviour; everything after it gets easier. - -- `LICENSE` (MIT fits the disclaimer already in the README). -- `.gitignore` for `logs/`, `*.log`, `reports/`. -- `CHANGELOG.md`, and retroactive git tags `v0.1` / `v0.2`. -- GitHub Actions on `windows-latest`: - - `PSScriptAnalyzer` (start with errors only, tighten later), - - a Pester test that parses every `.ps1` with - `[System.Management.Automation.Language.Parser]::ParseFile` and asserts zero - syntax errors — cheap, and it catches the class of break that bricks an - `irm | iex` user instantly. -- Correct the README's async claim; add `quickspeedboost.ps1` to the docs or - remove it (see Phase 6). - -## Phase 1 — Extract a `PCTools` module ✅ done - -Move the logic out of the GUIs. This is the change everything else depends on. - -``` -src/PCTools/ - PCTools.psd1 - PCTools.psm1 - Public/ Cleanup/ Repair/ Network/ Preferences/ Software/ - Private/ Write-PCLog.ps1 Test-PCAdmin.ps1 Invoke-PCProcess.ps1 -``` - -Three rules for the extraction: - -- **Every action returns an object, not log text.** - `[pscustomobject]@{ Action; Status; Detail; BytesFreed; Duration; RebootRequired }` - One shape consumed by the GUI, a CLI, and the report writer — which is what - makes "export logs" and "results summary" nearly free later. -- **Every mutating function gets `[CmdletBinding(SupportsShouldProcess)]`.** - This delivers two roadmap items at once: `-WhatIf` is the dry-run mode, and - `-Confirm` with `ConfirmImpact = 'High'` is the safe-confirmation prompt. -- **Approved verbs.** `Flush-DnsCache` → `Clear-PCDnsCache`, - `Run-SystemHealthChecks` → `Repair-PCSystemImage`, - `Create-SystemRestorePoint` → `New-PCRestorePoint`, and so on. Prefix nouns - with `PC` to avoid colliding with built-in `Clear-RecycleBin` et al. - -Promote `Invoke-ExternalWithTimeout` from `pc-netdiag.ps1` into `Private/` and -route *every* external call (`dism`, `sfc`, `chkdsk`, `netsh`, `winget`) through -it. Today only Net Diag's full scan is timeout-protected; the cleanup tool can -hang forever on a stuck DISM. - -Fix while extracting: `Clear-TempFiles` pipes `Get-ChildItem -Recurse` into -`Remove-Item -Recurse` — the double recursion is slow and noisy. Enumerate one -level, delete recursively, and measure bytes freed on the way through. - -## Phase 2 — Promote `gui-framework.ps1` to the application shell ✅ done - -The framework is already the answer to the "UI freezes" problem; it just is not -wired to anything. - -- Rename it to the shell (`src/Shell/`), give the left nav real pages, and port - Net Diag in first (it has the cleanest data flow), then the Cleanup Tool. -- Retire the two bespoke UIs once both are ported. One theme, one log pane, one - status bar, one notification path. -- Replace `pc-cleanuptool.ps1`'s absolute `Point`/`Size` layout with the - framework's `TableLayoutPanel` docking — it is the reason the window is pinned - at 940×600 with `MaximizeBox = $false`. - -Two things in the framework to fix during the port: - -- `Complete-AsyncTask` calls `Invoke-UI { & $Task.OnSuccess $result }`. The - scriptblock resolves `$Task` and `$result` at *execution* time, and when it is - queued with `BeginInvoke` it executes after `Complete-AsyncTask` has returned - and that scope is gone — so the callback fails outright ("the expression after - '&' produced an object that was not valid") rather than receiving stale - values. It is currently latent: the task pump is a WinForms `Timer`, already - on the UI thread, so `InvokeRequired` is false and the block runs - synchronously while the scope still exists. Anything that completes a task off - the UI thread breaks it. Bind the values into the block with - `GetNewClosure()`, which does not depend on how WinForms marshals a delegate's - parameter array. -- `$sync.Controls.Tasks` is a plain `ArrayList` mutated from the timer; make it - `[System.Collections.ArrayList]::Synchronized(...)` to match the rest of `$sync`. - -A third, found during the port: the framework sets `Set-StrictMode -Version -Latest`, under which reading a not-yet-assigned hashtable key is a *terminating -error*. A guard such as `if ($sync.Controls.StatusLabel)` therefore throws -instead of returning false. Declare every control key up front. - -Also add DPI awareness at startup — at 150 % scaling the fixed-pixel layouts blur -and clip today. - -## Phase 3 — Safety and trust ✅ done - -This is what decides whether anyone but you runs these tools. - -- **Pin the install command.** Point the README at a release tag, never `main`: - `irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.3/pc-cleanuptool.ps1 | iex`, - and publish a `SHA256SUMS` file with a verify-then-run snippet next to the - one-liner. -- **Authenticode-sign** release scripts in the CI pipeline. -- **Restore point becomes a gate, not a checkbox.** Anything in the Repair or - Network-reset group takes a restore point first, or explains why it cannot and - makes the user opt in. -- **Preflight screen.** Before "Run all": what will run, what it touches, what is - irreversible, and an estimated reclaim. `-WhatIf` from Phase 1 supplies it. -- **Result summary.** "Freed 4.2 GB · 6 actions OK · 1 warning · reboot - recommended", with per-action detail — the Cleanup Tool currently tells the user - nothing quantitative. - -## Phase 4 — The features already on the README roadmap ✅ done - -Each becomes small once Phases 1–2 land: - -- **JSON config + presets** (Quick cleanup / Full maintenance / Custom) — a preset - is a list of action names plus parameters. -- **Network profiles** (Home / Work) — capture a full adapter config to JSON and - reapply it; the `Set-StaticIP` / `Set-DnsServers` / `Set-DhcpMode` trio already - covers the write path. -- **Export and copy logs** — lift `Save-Report` out of Net Diag and give every - tool JSON + TXT export. -- **More Windows preferences** — taskbar/Start tweaks, background apps. Ship each - toggle with a `Get-` (read current state, so the UI reflects reality instead of - always-unchecked) and a revert. -- **More diagnostics** — traceroute with per-hop latency, MTU/path-MTU probe, - Wi-Fi signal sampling over time, throughput test. - -## Phase 5 — Distribution ✅ done - -- **PowerShell Gallery**: `Install-Module PCTools` — the credible alternative to - `irm | iex`, and it gets you versioning and updates for free. -- **GitHub Releases** as the source of truth for the pinned URLs and checksums. -- **winget manifest** once releases are signed. -- On the README's "portable executable": `ps2exe` output is unsigned and reliably - trips SmartScreen and AV heuristics — worse for trust than the current script. - Prefer a signed module plus a tiny launcher `.cmd`, and only revisit an `.exe` - if a code-signing certificate is in the budget. - -## Phase 6 — Decide about `quickspeedboost.ps1` - -It is not in the README, and most of what it does is counterproductive: -`EmptyWorkingSet` on every process forces those pages straight back off disk, and -purging the standby list throws away the cache Windows built to make things fast. -Killing `dwm.exe` is the sharpest edge in the repo. - -Recommendation: keep the temp cleanup, DNS flush and Explorer restart, fold them -into the module as `Invoke-PCQuickRefresh`, and drop the memory manipulation — or -keep it behind an `-Advanced` switch with an honest note that it usually costs -performance rather than adding it. +Installation is one command, pinned to a tag and verified. ---- +### History -## Status +| Version | What landed | +|---|---| +| v0.1 - v0.2 | Four standalone scripts: a cleanup GUI, a network diagnostics GUI, an unused GUI framework, and a console "speed boost". | +| v0.3 | **Phases 0-3.** Repo foundation, CI, and the `PCTools` module extraction - every action became a function returning a structured result and supporting `-WhatIf`. The GUI framework was promoted to the application shell and both bespoke UIs retired. Restore-point gating, preflight preview and a result summary. Releases pinned and checksummed. | +| v0.4 | **Phases 4-5.** Network profiles, JSON config, traceroute, MTU probe, Wi-Fi status, nine reversible preference toggles. PowerShell Gallery publishing wired into the release workflow. | +| v0.5 | **One command, measured performance, and roughly double the commands.** See below. | -Phases 0-3 are implemented. What actually shipped, against what was planned: +### What v0.5 changed -| Phase | State | Notes | -|---|---|---| -| 0 Foundation | Done | Also caught a live encoding bug: both GUIs stored non-ASCII characters with no BOM, so Windows PowerShell 5.1 decoded them as ANSI. | -| 1 `PCTools` module | Done | 29 public functions. Several behavioural bugs fixed in passing — see the CHANGELOG. | -| 2 Application shell | Done | The async callback bug turned out to be latent rather than live; see the corrected description above. | -| 3 Safety and trust | Done | `-WhatIf` preflight, restore-point gating, result summary, pinned + checksummed releases, signing wired to a certificate secret. | -| 4 Features | Done | Network profiles, JSON config, traceroute, MTU probe, Wi-Fi status. Nine preference toggles, up from six, all reversible. | -| 5 Distribution | Done | Gallery publish wired into the release workflow, gated on an API key. winget deferred until releases are actually signed. | -| 6 `quickspeedboost.ps1` | Partly | Its worthwhile parts are in the module; the script itself is still present and now documented honestly in the README. | +**One command.** The shell moved inside the module, so a Gallery install carries +the GUI; `install.ps1` prefers the signed Gallery package, falls back to a +hash-verified release archive, and installs a `pctools` command onto a PATH +directory that already exists. -## Suggested order +**Measured performance.** Nothing here had ever been measured. The hot paths +were all the same shape - independent I/O done one item at a time - and are now +concurrent behind a single `Invoke-PCParallel` helper. The shipped module is +compiled to one file. Numbers and method: [docs/PERFORMANCE.md](docs/PERFORMANCE.md). -1. Phase 0 — a weekend, and it stops silent regressions reaching `iex` users. -2. Pin + hash the install command (from Phase 3) — the highest-risk item, and it - is a README change plus a release. -3. Phase 1 — the module extraction. The largest single piece of work and the - unlock for everything else. -4. Phase 2 — port Net Diag onto the framework; retire its bespoke UI. -5. Phase 2 — port the Cleanup Tool; `-WhatIf` preflight and the result summary - arrive with it. +**Thirty-five new commands** across storage analysis, startup and service +control, health and security reporting, updates and drivers, bloatware removal, +scheduling, history and extensions. + +--- + +## v0.6 - Trust + +The theme is making the install defensible to somebody who is not already +inclined to trust it. + +- **A real code-signing certificate.** The release workflow already signs when + one is configured; nothing is configured. Signed scripts mean the Gallery + package verifies, SmartScreen calms down, and the `irm | iex` bootstrapper + stops being the weakest link in the chain. +- **A winget manifest**, once releases are signed. `winget install PCTools` + is the install command people already know, and it verifies the hash itself. +- **Verifiable builds.** The compiled module is already byte-reproducible for a + given commit - the merge is ordered and embeds no timestamp - so what remains + is publishing that fact: a documented command a third party can run to confirm + the released artifact matches the source it claims to come from. +- **A tray agent**, optional and off by default: run the Weekly profile when the + machine is idle and say so afterwards, rather than needing a scheduled task + somebody has to know about. + +## v0.7 - Fit + +Making it pleasant for the second and third use, not just the first. + +- **A command palette** in the shell (Ctrl+K), because seven pages of buttons is + already the point where searching beats hunting. +- **Per-machine policy.** A JSON file an administrator can drop next to the + module that removes commands from the UI, forces `-WhatIf`, or pins the + profiles on offer. The extension model already proves the loading path. +- **Localisation.** Every user-facing string currently lives inline. Moving them + to a resource table is unglamorous and gets steadily harder the longer it is + left. +- **Better disk visualisation.** `Get-PCDiskUsage` returns the right data; the + GUI renders it as a list. A treemap would make the answer obvious rather than + merely available. +- **Scheduled health reporting.** The pieces exist - `Get-PCHealthReport`, + `Export-PCReport -Format Html`, `Register-PCScheduledMaintenance`. Wiring a + weekly HTML report into a folder is small and useful for anybody looking after + a relative's PC. + +## v1.0 - Stability + +- **A stable command surface.** After 1.0, a breaking change to an exported + command's parameters or output shape means a major version. That promise is + worth making only once the surface has settled, which is why it is not being + made now. +- **Complete help.** Every command has a synopsis and examples; not every one has + `.NOTES` explaining when *not* to use it, which for a tool that runs elevated + is the part that matters. +- **A test machine matrix.** The suite runs on Windows Server images in CI. + Windows 10, Windows 11 and a Home edition - which has no BitLocker cmdlets and + a different Appx set - are where the users are, and where the untested code + paths are. + +--- -Everything after that is additive. +## Permanent non-goals + +These are not "not yet". They are decisions. + +- **No telemetry.** Not anonymised, not opt-in, not "just crash reports". + Nothing here reports home, and no future version will. The only network + traffic is what a diagnostic explicitly probes and what an update command + downloads. +- **No registry "tweaks" whose benefit cannot be explained.** Every preference + in `Set-PCPreference` says what it changes and can be reverted. Anything that + amounts to folklore does not get added, however popular. +- **No `ps2exe` bundle.** Its output is unsigned and reliably trips SmartScreen + and antivirus heuristics, which is worse for trust than a script. A signed + module plus a checksummed archive is the better answer. +- **No memory "optimisation".** `EmptyWorkingSet` on every process forces those + pages straight back off disk, and purging the standby list throws away the + cache Windows built to make things fast. `quickspeedboost.ps1` remains in the + repository as a record of what not to do, documented as such. +- **No "disable these 40 services" list.** `Set-PCServiceStartup` refuses the + services that break a machine, and this project will not ship a preset that + disables things wholesale. Which services a machine needs depends on what that + machine does. +- **No forced restarts.** Commands report that a restart is required. Deciding + when is the user's. +- **No silent changes to security settings.** `Get-PCSecurityStatus` reports + that the firewall is off. It will never turn it on: a maintenance tool that + reconfigures antivirus or firewall settings without being asked is + indistinguishable from malware, and one that turns protection on unasked + overrides somebody's deliberate configuration. diff --git a/build/Invoke-Build.ps1 b/build/Invoke-Build.ps1 index 75ba2be..22ad6bf 100644 --- a/build/Invoke-Build.ps1 +++ b/build/Invoke-Build.ps1 @@ -9,7 +9,10 @@ .PARAMETER Task Test - run the Pester suite (syntax, manifest, unit). Analyze - run PSScriptAnalyzer against every script and module file. + Compile - merge the module sources into one .psm1 under obj/. + Docs - regenerate docs/COMMANDS.md from the module's own help. Release - stage release artifacts into out/ and write SHA256SUMS. + Bench - measure the hot paths and write build/benchmark.json. All - Test, then Analyze. .EXAMPLE @@ -17,18 +20,24 @@ #> [CmdletBinding()] param( - [ValidateSet('Test', 'Analyze', 'Release', 'Checksum', 'All')] + [ValidateSet('Test', 'Analyze', 'Compile', 'Docs', 'Release', 'Checksum', 'Bench', 'All')] [string]$Task = 'All', [string]$Version, # Warnings above this fail the build, so the count can only go down. - # 146 is what the repository carries today: mostly - # PSUseShouldProcessForStateChangingFunctions firing on the shell's + # Mostly PSUseShouldProcessForStateChangingFunctions firing on the shell's # internal UI helpers, where the rule does not apply but is worth keeping - # enabled for the module's actual cmdlets. Lower it as findings are fixed; + # enabled for the module's actual cmdlets, and PSAvoidUsingEmptyCatchBlock + # on the deliberate best-effort cleanups. Lower it as findings are fixed; # raising it should be a deliberate, reviewed decision. - [int]$MaxWarning = 146 + [int]$MaxWarning = 210, + + # Release only. Stage from the obj/PCTools that is already there instead of + # recompiling, so a release pipeline can compile, Authenticode-sign the + # result, and then package the signed files. Recompiling at package time + # would discard those signatures. + [switch]$SkipCompile ) $ErrorActionPreference = 'Stop' @@ -138,27 +147,42 @@ function Invoke-ReleaseTask { Get-ChildItem -Path $RepoRoot -Filter '*.ps1' -File | Copy-Item -Destination $outDir - # The current toolkit. The GUI needs the module beside it, so this ships as - # a layout rather than as loose scripts. + # The compiled module is what ships: one file to parse instead of sixty. + $compiled = if ($SkipCompile) { + $existing = Join-Path $RepoRoot 'obj/PCTools' + if (-not (Test-Path (Join-Path $existing 'PCTools.psm1'))) { + throw "-SkipCompile was given but there is no compiled module at $existing. Run -Task Compile first." + } + Write-Host "Using the already-compiled module at $existing" -ForegroundColor Yellow + $existing + } + else { + Invoke-CompileTask + } + + # The current toolkit. pc-tools.ps1 expects src/PCTools beside it, so the + # compiled module is staged at that path rather than as loose sources. $stage = Join-Path $RepoRoot 'obj/pc-tools' if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } - New-Item -ItemType Directory -Path $stage -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $stage 'src') -Force | Out-Null Copy-Item (Join-Path $RepoRoot 'pc-tools.ps1') -Destination $stage - Copy-Item (Join-Path $RepoRoot 'src') -Destination $stage -Recurse + Copy-Item (Join-Path $RepoRoot 'install.ps1') -Destination $stage -ErrorAction SilentlyContinue + Copy-Item $compiled -Destination (Join-Path $stage 'src') -Recurse + foreach ($doc in 'README.md', 'CHANGELOG.md', 'LICENSE') { $path = Join-Path $RepoRoot $doc if (Test-Path $path) { Copy-Item $path -Destination $stage } } + $docsDir = Join-Path $RepoRoot 'docs' + if (Test-Path $docsDir) { Copy-Item $docsDir -Destination $stage -Recurse } Compress-Archive -Path (Join-Path $stage '*') ` -DestinationPath (Join-Path $outDir "pc-tools-$Version.zip") -Force - # The module on its own, for people who only want the cmdlets. - $moduleSrc = Join-Path $RepoRoot 'src/PCTools' - if (Test-Path $moduleSrc) { - Compress-Archive -Path $moduleSrc -DestinationPath (Join-Path $outDir "PCTools-$Version.zip") -Force - } + # The module on its own, for people who only want the commands, and the + # archive install.ps1 verifies and unpacks. + Compress-Archive -Path $compiled -DestinationPath (Join-Path $outDir "PCTools-$Version.zip") -Force Remove-Item $stage -Recurse -Force @@ -193,11 +217,188 @@ function Invoke-ChecksumTask { Write-Host '' } +function Invoke-CompileTask { + <# + Merges Private/ then Public/ into a single .psm1. + + The source module dot-sources 60-odd files at import, which is right for + development - edit one file, re-import, done - and wrong for a shipped + module, where it means 60 file opens and 60 parses on every import, + including every time the GUI starts. + + The merged module is what ships. The source tree keeps dot-sourcing, and + tests/Compile.Tests.ps1 asserts the two export exactly the same surface + so they cannot drift apart. + #> + param([string]$OutputRoot) + + Write-Step 'Compiling the module into a single file' + + $source = Join-Path $RepoRoot 'src/PCTools' + if (-not $OutputRoot) { $OutputRoot = Join-Path $RepoRoot 'obj/PCTools' } + + if (Test-Path $OutputRoot) { Remove-Item $OutputRoot -Recurse -Force } + New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null + + $manifestPath = Join-Path $source 'PCTools.psd1' + $version = (Import-PowerShellDataFile $manifestPath).ModuleVersion + + $loader = Get-Content -LiteralPath (Join-Path $source 'PCTools.psm1') -Raw + + # Everything from the dot-sourcing loop to the end is replaced by the merged + # function bodies; the prologue above it (strict mode, paths, module state) + # is kept exactly as written. + $marker = '# Dot-source Private first: Public functions depend on those helpers.' + $index = $loader.IndexOf($marker) + if ($index -lt 0) { throw "The module loader has changed shape: '$marker' was not found in PCTools.psm1." } + + $prologue = $loader.Substring(0, $index) + + # Stamp the version so the shipped module does not re-read its own manifest + # on every import. + $prologue = $prologue.Replace("'{{MODULE_VERSION}}'", "'$version'") + if ($prologue -match '\{\{MODULE_VERSION\}\}') { + throw 'The version placeholder was not replaced; check the loader prologue.' + } + + $builder = [System.Text.StringBuilder]::new() + [void]$builder.AppendLine($prologue.TrimEnd()) + [void]$builder.AppendLine() + [void]$builder.AppendLine('# ---------------------------------------------------------------------') + [void]$builder.AppendLine('# Generated by build/Invoke-Build.ps1 -Task Compile. Do not edit:') + [void]$builder.AppendLine('# change the files under src/PCTools/Private and src/PCTools/Public.') + [void]$builder.AppendLine('# ---------------------------------------------------------------------') + [void]$builder.AppendLine() + + $publicNames = [System.Collections.Generic.List[string]]::new() + + foreach ($folder in 'Private', 'Public') { + $folderPath = Join-Path $source $folder + if (-not (Test-Path $folderPath)) { continue } + + foreach ($file in (Get-ChildItem -LiteralPath $folderPath -Recurse -Filter '*.ps1' -File | Sort-Object FullName)) { + [void]$builder.AppendLine("#region $folder/$($file.BaseName)") + [void]$builder.AppendLine((Get-Content -LiteralPath $file.FullName -Raw).TrimEnd()) + [void]$builder.AppendLine('#endregion') + [void]$builder.AppendLine() + + if ($folder -eq 'Public') { $publicNames.Add($file.BaseName) } + } + } + + [void]$builder.AppendLine('$script:PublicFunctionName = @(') + foreach ($name in $publicNames) { [void]$builder.AppendLine(" '$name'") } + [void]$builder.AppendLine(')') + [void]$builder.AppendLine() + [void]$builder.AppendLine('$script:PublicAliasName = [System.Collections.Generic.List[string]]::new()') + [void]$builder.AppendLine('foreach ($name in $script:PublicFunctionName) {') + [void]$builder.AppendLine(' $command = Get-Command -Name $name -CommandType Function -ErrorAction SilentlyContinue') + [void]$builder.AppendLine(' if (-not $command) { continue }') + [void]$builder.AppendLine(' foreach ($attribute in $command.ScriptBlock.Attributes) {') + [void]$builder.AppendLine(' if ($attribute -is [System.Management.Automation.AliasAttribute]) {') + [void]$builder.AppendLine(' foreach ($alias in $attribute.AliasNames) { $script:PublicAliasName.Add($alias) }') + [void]$builder.AppendLine(' }') + [void]$builder.AppendLine(' }') + [void]$builder.AppendLine('}') + [void]$builder.AppendLine() + [void]$builder.AppendLine('Export-ModuleMember -Function $script:PublicFunctionName -Alias $script:PublicAliasName') + + $merged = $builder.ToString() + + # A merged file that does not parse would ship a module nobody can import. + $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseInput($merged, [ref]$null, [ref]$parseErrors) + if ($parseErrors) { + $detail = ($parseErrors | Select-Object -First 5 | ForEach-Object { + 'Line {0}: {1}' -f $_.Extent.StartLineNumber, $_.Message + }) -join [Environment]::NewLine + throw "The compiled module does not parse:$([Environment]::NewLine)$detail" + } + + # UTF-8 with a BOM: Windows PowerShell 5.1 reads a BOM-less file as ANSI. + $encoding = [System.Text.UTF8Encoding]::new($true) + [System.IO.File]::WriteAllText((Join-Path $OutputRoot 'PCTools.psm1'), $merged, $encoding) + + Copy-Item $manifestPath -Destination $OutputRoot + + # The GUI ships inside the module, so it comes along. + $shell = Join-Path $source 'Shell' + if (Test-Path $shell) { Copy-Item $shell -Destination $OutputRoot -Recurse } + + Write-Host ("Compiled {0} function(s) into {1}" -f $publicNames.Count, (Join-Path $OutputRoot 'PCTools.psm1')) -ForegroundColor Green + $OutputRoot +} + +function Invoke-DocsTask { + <# + Regenerates docs/COMMANDS.md from the module's own help, so the command + reference cannot describe a command that no longer exists or miss one + that was just added. + #> + Write-Step 'Generating the command reference' + + Import-Module (Join-Path $RepoRoot 'src/PCTools/PCTools.psd1') -Force + + $module = Get-Module PCTools + $commands = $module.ExportedFunctions.Keys | Sort-Object + + $source = Join-Path $RepoRoot 'src/PCTools/Public' + $folderOf = @{} + foreach ($file in (Get-ChildItem -LiteralPath $source -Recurse -Filter '*.ps1' -File)) { + $folder = Split-Path -Leaf $file.DirectoryName + $folderOf[$file.BaseName] = $(if ($folder -eq 'Public') { 'Entry points and orchestration' } else { $folder }) + } + + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('# PCTools command reference') + $lines.Add('') + $lines.Add("Generated from the module's own help by ``./build/Invoke-Build.ps1 -Task Docs``.") + $lines.Add("Do not edit by hand - edit the comment-based help on the command instead.") + $lines.Add('') + $lines.Add("PCTools $($module.Version) exports $($commands.Count) commands.") + $lines.Add('') + + foreach ($group in ($commands | Group-Object { $folderOf[$_] } | Sort-Object Name)) { + $lines.Add("## $($group.Name)") + $lines.Add('') + $lines.Add('| Command | What it does | Supports -WhatIf |') + $lines.Add('|---|---|---|') + + foreach ($name in ($group.Group | Sort-Object)) { + $help = Get-Help $name -ErrorAction SilentlyContinue + $synopsis = if ($help -and $help.Synopsis) { ($help.Synopsis -replace '\s+', ' ').Trim() } else { '' } + $synopsis = $synopsis -replace '\|', '\|' + + $command = Get-Command $name + $supportsWhatIf = if ($command.Parameters.ContainsKey('WhatIf')) { 'yes' } else { '' } + + $lines.Add("| ``$name`` | $synopsis | $supportsWhatIf |") + } + $lines.Add('') + } + + $docsDir = Join-Path $RepoRoot 'docs' + if (-not (Test-Path $docsDir)) { New-Item -ItemType Directory -Path $docsDir -Force | Out-Null } + + $path = Join-Path $docsDir 'COMMANDS.md' + $lines | Set-Content -LiteralPath $path -Encoding UTF8 + + Write-Host "Wrote $path ($($commands.Count) commands)" -ForegroundColor Green +} + +function Invoke-BenchTask { + Write-Step 'Measuring' + & (Join-Path $PSScriptRoot 'Measure-Performance.ps1') -OutputPath (Join-Path $PSScriptRoot 'benchmark.json') +} + switch ($Task) { 'Test' { Invoke-TestTask } 'Analyze' { Invoke-AnalyzeTask } + 'Compile' { $null = Invoke-CompileTask } + 'Docs' { Invoke-DocsTask } 'Release' { Invoke-ReleaseTask } 'Checksum' { Invoke-ChecksumTask } + 'Bench' { Invoke-BenchTask } 'All' { Invoke-TestTask; Invoke-AnalyzeTask } } diff --git a/build/Measure-Performance.ps1 b/build/Measure-Performance.ps1 new file mode 100644 index 0000000..cfeed23 --- /dev/null +++ b/build/Measure-Performance.ps1 @@ -0,0 +1,297 @@ +<# +.SYNOPSIS + Measures the module's hot paths so performance claims have numbers behind + them. + +.DESCRIPTION + Nothing in this project was ever measured before v0.5, which is how it + ended up with a traceroute that waited out twenty timeouts in a row. This + is the arbiter: a change that does not move a number here does not get + described as an optimization. + + Each scenario runs -Iterations times and the median is reported, not the + mean. One outlier from a background virus scan should not decide the + result. + + Scenarios that need Windows are skipped elsewhere with a reason rather than + failing, so the harness still runs on Linux CI for the parts that are + platform-independent. + +.PARAMETER Iterations + How many times to run each scenario. + +.PARAMETER OutputPath + Where to write the JSON result. + +.PARAMETER Baseline + A previous result file to compare against. The comparison is printed as a + percentage change per scenario. + +.PARAMETER Scenario + Run only the named scenarios. + +.EXAMPLE + ./build/Measure-Performance.ps1 + +.EXAMPLE + ./build/Measure-Performance.ps1 -Baseline ./build/benchmark.json +#> +[CmdletBinding()] +param( + [ValidateRange(1, 50)] + [int]$Iterations = 5, + + [string]$OutputPath, + + [string]$Baseline, + + [string[]]$Scenario +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$ModulePath = Join-Path $RepoRoot 'src/PCTools/PCTools.psd1' + +$isWindowsHost = if ($null -eq (Get-Variable -Name IsWindows -ErrorAction SilentlyContinue)) { $true } else { $IsWindows } + +function Get-Median { + param([double[]]$Value) + if ($Value.Count -eq 0) { return $null } + $sorted = @($Value | Sort-Object) + if ($sorted.Count % 2 -eq 1) { return $sorted[[int][math]::Floor($sorted.Count / 2)] } + ($sorted[($sorted.Count / 2) - 1] + $sorted[$sorted.Count / 2]) / 2 +} + +function New-TempFixture { + <# + A deterministic tree to clean, so the filesystem scenario measures the + code rather than whatever happened to be in the user's TEMP folder. + #> + param([int]$Directories = 12, [int]$FilesEach = 40, [int]$Bytes = 8192) + + $root = Join-Path ([System.IO.Path]::GetTempPath()) ("pcbench-{0}" -f [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $root -Force | Out-Null + + $payload = [byte[]]::new($Bytes) + for ($d = 0; $d -lt $Directories; $d++) { + $dir = Join-Path $root "dir$d\nested" + New-Item -ItemType Directory -Path $dir -Force | Out-Null + for ($f = 0; $f -lt $FilesEach; $f++) { + [System.IO.File]::WriteAllBytes((Join-Path $dir "file$f.bin"), $payload) + } + } + $root +} + +$scenarios = @( + @{ + Name = 'Import-Module' + Windows = $false + Note = 'Paid on every GUI launch and every console session.' + # Measured in a child process: a module already loaded in this one + # would report the cost of a no-op re-import. + Measure = { + $command = "`$s=[Diagnostics.Stopwatch]::StartNew(); Import-Module '$ModulePath'; `$s.Elapsed.TotalMilliseconds" + $host_ = if ($isWindowsHost) { 'powershell.exe' } else { 'pwsh' } + [double](& $host_ -NoProfile -Command $command) + } + } + @{ + Name = 'Clear-PCFolderContent' + Windows = $false + Note = '12 directories, 480 files. Was a size walk plus a delete walk.' + Measure = { + $fixture = New-TempFixture + try { + $watch = [System.Diagnostics.Stopwatch]::StartNew() + & (Get-Module PCTools) { param($p) Clear-PCFolderContent -Path $p } $fixture | Out-Null + $watch.Stop() + $watch.Elapsed.TotalMilliseconds + } + finally { + Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue + } + } + } + @{ + Name = 'Invoke-PCParallel' + Windows = $false + Note = 'Eight 200 ms sleeps. Serially this is 1600 ms.' + Measure = { + $watch = [System.Diagnostics.Stopwatch]::StartNew() + & (Get-Module PCTools) { + Invoke-PCParallel -InputObject (1..8) -ScriptBlock { + param($n) Start-Sleep -Milliseconds 200; $n + } -ThrottleLimit 8 + } | Out-Null + $watch.Stop() + $watch.Elapsed.TotalMilliseconds + } + } + @{ + Name = 'Get-PCNetworkAdapter' + Windows = $true + Note = 'Was two CIM round-trips per adapter; now three in total.' + Measure = { + $watch = [System.Diagnostics.Stopwatch]::StartNew() + Get-PCNetworkAdapter | Out-Null + $watch.Stop() + $watch.Elapsed.TotalMilliseconds + } + } + @{ + Name = 'Test-PCConnectivity' + Windows = $true + Note = 'Nine probes. Was strictly sequential.' + Measure = { + $watch = [System.Diagnostics.Stopwatch]::StartNew() + Test-PCConnectivity | Out-Null + $watch.Stop() + $watch.Elapsed.TotalMilliseconds + } + } + @{ + Name = 'Get-PCNetworkReport-Full' + Windows = $true + Note = 'Six external captures. Were run one after another.' + Measure = { + $watch = [System.Diagnostics.Stopwatch]::StartNew() + Get-PCNetworkReport -Full | Out-Null + $watch.Stop() + $watch.Elapsed.TotalMilliseconds + } + } + @{ + Name = 'Test-PCRoute' + Windows = $true + Note = 'Was one TTL at a time, up to MaxHops timeouts.' + Measure = { + $watch = [System.Diagnostics.Stopwatch]::StartNew() + Test-PCRoute -Target '1.1.1.1' -MaxHops 12 -TimeoutSeconds 2 | Out-Null + $watch.Stop() + $watch.Elapsed.TotalMilliseconds + } + } + @{ + Name = 'Test-PCMtu' + Windows = $true + Note = 'Was eleven sequential probes; now a bracket plus a search.' + Measure = { + $watch = [System.Diagnostics.Stopwatch]::StartNew() + Test-PCMtu -Target '1.1.1.1' -TimeoutSeconds 2 | Out-Null + $watch.Stop() + $watch.Elapsed.TotalMilliseconds + } + } + @{ + Name = 'Get-PCDiskSpace' + Windows = $true + Note = 'One CIM query.' + Measure = { $w = [System.Diagnostics.Stopwatch]::StartNew(); Get-PCDiskSpace | Out-Null; $w.Stop(); $w.Elapsed.TotalMilliseconds } + } +) + +Import-Module $ModulePath -Force + +Write-Host '' +Write-Host "PCTools $((Get-Module PCTools).Version) benchmark" -ForegroundColor Cyan +Write-Host " host : $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)" +Write-Host " iterations : $Iterations" +Write-Host '' + +$results = [System.Collections.Generic.List[object]]::new() + +foreach ($entry in $scenarios) { + if ($Scenario -and $Scenario -notcontains $entry.Name) { continue } + + if ($entry.Windows -and -not $isWindowsHost) { + Write-Host (' {0,-26} skipped (needs Windows)' -f $entry.Name) -ForegroundColor DarkGray + $results.Add([pscustomobject]@{ + Scenario = $entry.Name; Skipped = $true; Reason = 'Needs Windows' + MedianMs = $null; MinMs = $null; MaxMs = $null; Note = $entry.Note + }) + continue + } + + $samples = [System.Collections.Generic.List[double]]::new() + $failure = '' + + for ($i = 0; $i -lt $Iterations; $i++) { + try { + $samples.Add([double](& $entry.Measure)) + } + catch { + $failure = $_.Exception.Message + break + } + } + + if ($failure -or $samples.Count -eq 0) { + Write-Host (' {0,-26} failed: {1}' -f $entry.Name, $failure) -ForegroundColor Yellow + $results.Add([pscustomobject]@{ + Scenario = $entry.Name; Skipped = $true; Reason = $failure + MedianMs = $null; MinMs = $null; MaxMs = $null; Note = $entry.Note + }) + continue + } + + $median = [math]::Round((Get-Median -Value $samples.ToArray()), 1) + $min = [math]::Round(($samples | Measure-Object -Minimum).Minimum, 1) + $max = [math]::Round(($samples | Measure-Object -Maximum).Maximum, 1) + + Write-Host (' {0,-26} {1,9:n1} ms (min {2:n1}, max {3:n1})' -f $entry.Name, $median, $min, $max) + + $results.Add([pscustomobject]@{ + Scenario = $entry.Name; Skipped = $false; Reason = '' + MedianMs = $median; MinMs = $min; MaxMs = $max; Note = $entry.Note + }) +} + +$report = [pscustomobject]@{ + Tool = 'PCTools' + Version = (Get-Module PCTools).Version.ToString() + Generated = Get-Date + Host = "$($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)" + Platform = if ($isWindowsHost) { 'Windows' } else { 'Non-Windows' } + Iterations = $Iterations + Results = @($results) +} + +if ($Baseline) { + if (-not (Test-Path -LiteralPath $Baseline)) { + Write-Warning "No baseline at $Baseline; nothing to compare." + } + else { + $previous = Get-Content -LiteralPath $Baseline -Raw | ConvertFrom-Json + $previousByName = @{} + foreach ($item in $previous.Results) { $previousByName[$item.Scenario] = $item } + + Write-Host '' + Write-Host "Against $Baseline ($($previous.Version), $($previous.Generated)):" -ForegroundColor Cyan + + foreach ($current in $results) { + if ($current.Skipped) { continue } + if (-not $previousByName.ContainsKey($current.Scenario)) { continue } + + $was = $previousByName[$current.Scenario] + if ($was.Skipped -or -not $was.MedianMs) { continue } + + $change = (($current.MedianMs - $was.MedianMs) / $was.MedianMs) * 100 + $colour = if ($change -le -5) { 'Green' } elseif ($change -ge 5) { 'Red' } else { 'Gray' } + + Write-Host (' {0,-26} {1,9:n1} -> {2,9:n1} ms {3,+7:n1}%' -f + $current.Scenario, $was.MedianMs, $current.MedianMs, $change) -ForegroundColor $colour + } + } +} + +if ($OutputPath) { + $report | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 + Write-Host '' + Write-Host "Written to $OutputPath" -ForegroundColor Green +} + +Write-Host '' +$report diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..8556691 --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,131 @@ +# PCTools command reference + +Generated from the module's own help by `./build/Invoke-Build.ps1 -Task Docs`. +Do not edit by hand - edit the comment-based help on the command instead. + +PCTools 0.5.0 exports 74 commands. + +## Automation + +| Command | What it does | Supports -WhatIf | +|---|---|---| +| `Get-PCExtension` | Lists extension actions, loaded and available. | | +| `Get-PCHistory` | Reads past maintenance runs, and the trend across them. | | +| `Get-PCScheduledMaintenance` | Shows the scheduled maintenance tasks this module has registered. | | +| `Register-PCExtension` | Loads custom actions from disk so they can be used like built-in ones. | yes | +| `Register-PCScheduledMaintenance` | Schedules a maintenance profile to run on its own. | yes | +| `Save-PCHistory` | Records a maintenance run so it can be compared with later ones. | yes | +| `Unregister-PCScheduledMaintenance` | Removes a scheduled maintenance task. | yes | + +## Cleanup + +| Command | What it does | Supports -WhatIf | +|---|---|---| +| `Clear-PCBrowserCache` | Clears the HTTP cache of installed Chromium-based browsers and Firefox. | yes | +| `Clear-PCPrefetchCache` | Clears the Windows Prefetch folder. | yes | +| `Clear-PCRecycleBin` | Empties the Recycle Bin for all drives. | yes | +| `Clear-PCTempFile` | Removes temporary files for the current user and the machine. | yes | +| `Clear-PCWindowsUpdateCache` | Clears the Windows Update download cache. | yes | + +## Entry points and orchestration + +| Command | What it does | Supports -WhatIf | +|---|---|---| +| `Export-PCReport` | Writes action results or a network report to disk as HTML, JSON, text or CSV. | yes | +| `Format-PCByteSize` | Formats a byte count for display. | | +| `Get-PCLogPath` | Returns the path of the current PCTools log file. | | +| `Get-PCMaintenanceProfile` | Lists the built-in maintenance presets. | | +| `Get-PCResultSummary` | Reduces a set of action results to the one line a person actually reads. | | +| `Import-PCConfiguration` | Loads user-defined maintenance profiles from a JSON file. | | +| `Invoke-PCMaintenance` | Runs a maintenance profile and returns one result per action. | yes | +| `Invoke-PCTools` | The command-line front door: run a profile, diagnose the network, or open the window. | yes | +| `Register-PCLogSink` | Routes PCTools log lines to a caller-supplied scriptblock. | | +| `Start-PCTools` | Opens the PC Tools window. | | +| `Test-PCAdmin` | Returns $true when the current session is elevated. | | +| `Unregister-PCLogSink` | Removes a log sink registered with Register-PCLogSink. | | + +## Health + +| Command | What it does | Supports -WhatIf | +|---|---|---| +| `Get-PCBatteryReport` | Reports battery wear: how much capacity the battery has actually lost. | | +| `Get-PCBootPerformance` | How long this machine takes to start, and what is slowing it down. | | +| `Get-PCDiskHealth` | Reads SMART-derived health and wear for every physical disk. | | +| `Get-PCEventSummary` | Summarises what has been going wrong, grouped rather than listed. | | +| `Get-PCHealthReport` | One graded answer to "how is this machine doing". | | +| `Get-PCSecurityStatus` | Reports this machine's security posture. Reads only; changes nothing. | | +| `Get-PCSystemInfo` | What this machine is. | | + +## Network + +| Command | What it does | Supports -WhatIf | +|---|---|---| +| `Clear-PCDnsCache` | Flushes the DNS resolver cache. | yes | +| `Export-PCNetworkProfile` | Saves an adapter's full IP configuration as a named profile. | yes | +| `Get-PCNetworkAdapter` | Lists network adapters with their IP configuration. | | +| `Get-PCNetworkProfile` | Lists saved network profiles. | | +| `Get-PCNetworkReport` | Builds a full network diagnostic report. | | +| `Get-PCWirelessStatus` | Reports the current Wi-Fi association: SSID, signal, band and rate. | | +| `Import-PCNetworkProfile` | Applies a saved network profile to an adapter. | yes | +| `Reset-PCNetworkStack` | Resets the Windows TCP/IP and Winsock stack. | yes | +| `Set-PCDhcp` | Returns an adapter to DHCP for both address and DNS. | yes | +| `Set-PCDnsServer` | Sets the DNS servers for an adapter. | yes | +| `Set-PCNetworkAddress` | Assigns a static IPv4 address, prefix and gateway to an adapter. | yes | +| `Test-PCConnectivity` | Runs layered connectivity tests: gateway, internet, DNS and TCP. | | +| `Test-PCDnsServer` | Benchmarks DNS resolvers and says which one to use. | | +| `Test-PCMtu` | Finds the largest packet that reaches a host without fragmenting. | | +| `Test-PCRoute` | Traces the path to a host, reporting per-hop latency. | | +| `Test-PCThroughput` | Measures download throughput against an HTTP endpoint. | | + +## Preferences + +| Command | What it does | Supports -WhatIf | +|---|---|---| +| `Get-PCPreference` | Reads the current state of the Windows preferences the module manages. | | +| `Restart-PCExplorer` | Restarts Windows Explorer. | yes | +| `Set-PCPreference` | Enables or disables a Windows preference. | yes | + +## Repair + +| Command | What it does | Supports -WhatIf | +|---|---|---| +| `New-PCRestorePoint` | Creates a System Restore checkpoint. | yes | +| `Repair-PCSystemFile` | Verifies and repairs protected system files with SFC. | yes | +| `Repair-PCSystemImage` | Repairs the Windows component store with DISM. | yes | +| `Test-PCDisk` | Runs an online CHKDSK scan of a volume. | yes | + +## Software + +| Command | What it does | Supports -WhatIf | +|---|---|---| +| `Get-PCAppxPackage` | Lists installed Store-style apps and grades what is safe to remove. | | +| `Get-PCDriverIssue` | Finds devices Windows is reporting a problem with. | | +| `Get-PCWindowsUpdate` | Lists the Windows updates that apply to this machine but are not installed. | | +| `Install-PCApplication` | Installs applications with winget. | yes | +| `Install-PCWindowsUpdate` | Downloads and installs pending Windows updates. | yes | +| `Remove-PCAppxPackage` | Removes preinstalled apps from the curated removable list. Nothing else. | yes | +| `Update-PCApplication` | Upgrades installed applications through winget. | yes | + +## Startup + +| Command | What it does | Supports -WhatIf | +|---|---|---| +| `Disable-PCStartupItem` | Switches off a startup item, reversibly. | yes | +| `Enable-PCStartupItem` | Switches a startup item back on. | yes | +| `Get-PCService` | Lists services with their startup type and what they belong to. | | +| `Get-PCStartupItem` | Lists everything that starts with Windows, from every place it can hide. | | +| `Set-PCServiceStartup` | Changes a service's startup type, refusing the ones that matter. | yes | + +## Storage + +| Command | What it does | Supports -WhatIf | +|---|---|---| +| `Clear-PCCrashDump` | Removes crash dumps and Windows Error Reporting queues. | yes | +| `Clear-PCDeliveryOptimization` | Removes the Delivery Optimization download cache. | yes | +| `Clear-PCEventLog` | Clears Windows event logs. | yes | +| `Clear-PCThumbnailCache` | Clears the Explorer thumbnail and icon caches. | yes | +| `Clear-PCWindowsOld` | Removes the previous Windows installation left behind by a feature update. | yes | +| `Get-PCDiskSpace` | Reports free and used space per fixed drive. | | +| `Get-PCDiskUsage` | Finds what is actually using the space. | | +| `Optimize-PCVolume` | Optimizes a drive the way that drive should be optimized. | yes | + diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..02c686e --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,130 @@ +# Performance + +Nothing in this project was measured before v0.5. That is how it ended up with a +traceroute that waited out twenty timeouts one after another, and a temp cleanup +that walked every directory tree twice. + +This document is the method and the findings. The rule for the work was: measure, +change, measure. A change that does not move a number here is not described as an +optimization. + +## Running the benchmark + +```powershell +./build/Measure-Performance.ps1 +./build/Measure-Performance.ps1 -Iterations 10 -OutputPath before.json +./build/Measure-Performance.ps1 -Baseline before.json +``` + +Each scenario runs several times and the **median** is reported. Median rather +than mean because one outlier - a virus scan waking up mid-run - would otherwise +decide the result. Scenarios needing Windows are skipped elsewhere with a +reason, so the harness still runs on Linux CI for the platform-independent +parts. + +CI runs it on every push and uploads the JSON as an artifact. Only module import +time is a hard gate, with a deliberately generous budget: a flaky performance +test is worse than none. + +## What was slow, and why + +Every one of these was the same mistake in a different place - independent I/O +performed one item at a time. + +| Where | Before | After | +|---|---|---| +| `Get-PCNetworkAdapter` | `Get-NetIPConfiguration` **and** `Get-NetIPInterface` per adapter | Three bulk CIM queries, joined in memory on `ifIndex` | +| `Test-PCConnectivity` | Nine probes strictly in sequence, each waiting out its own timeout | All nine concurrent, sorted back into layer order | +| `Test-PCRoute` | TTL 1..20, one `Ping.Send` at a time | Every TTL at once, sorted by hop, truncated at the destination | +| `Test-PCMtu` | ~11 sequential probes, binary search | A parallel bracket, then a 3-probe-per-round search | +| `Clear-PCFolderContent` | `Get-ChildItem -Recurse` to size, then `Remove-Item -Recurse` to delete | One `System.IO` walk that sizes and deletes together, top-level entries concurrent | +| `Get-PCNetworkReport -Full` | Six external captures one after another | Six concurrent | +| `Invoke-PCProcess` | Two `GetTempFileName()` files per call, written and read back | Both pipes read asynchronously in memory | +| Module import | 51 files dot-sourced, plus parsing its own manifest, plus log setup | One compiled file, version stamped at build, log prepared on first write | + +### The measured effect + +Two figures worth stating precisely, because they were measured rather than +reasoned about. Both are medians of seven runs on PowerShell 7 on a Linux CI +container - the absolute numbers move with machine load, the ratio much less so: + +- **`Invoke-PCParallel`**: eight 200 ms operations complete in ~265 ms. Serially + that is 1600 ms, so roughly 6x, which is about what eight concurrent waits on + a throttle of eight should give. +- **Module import**: 335 ms from the source tree, 224 ms from the compiled + module - about a third faster. Smaller than "60 files became 1" suggests, + because opening and parsing files is only part of the cost of an import. + +Measure your own rather than trusting these: `./build/Measure-Performance.ps1`. + +The Windows-only network figures depend entirely on the network the machine is +attached to, which is exactly why they are not quoted here as a fixed number. +The structural claim is the honest one: `Test-PCRoute` was bounded by +`MaxHops x TimeoutSeconds` and is now bounded by roughly one timeout. On a +healthy network both are fast; the difference shows up precisely when something +is broken, which is when somebody is running a traceroute. + +`Test-PCMtu` is the one that can be stated exactly, because the search is +arithmetic rather than network-dependent: it converges on the correct payload +size for every value from 0 to 1472 in at most 6 rounds, against about 11 +sequential probes before. Each round is one wall-clock timeout instead of one +probe, so the worst case roughly halves and the common case - a standard 1500 +byte path - is a single round. + +## The parallel helper + +`Invoke-PCParallel` (private) is one runspace pool implementation used on both +Windows PowerShell 5.1 and PowerShell 7. + +The plan was to use `ForEach-Object -Parallel` on 7. That does not work for a +general-purpose helper: PowerShell 7 refuses to pass a scriptblock into a +parallel block - *"a ForEach-Object -Parallel using variable cannot be a script +block ... can result in undefined behavior"* - which is exactly what a helper +taking a `-ScriptBlock` parameter must do. One implementation that behaves +identically everywhere beats two where one of them is documented as undefined. + +Two constraints fell out of building it, both learned the hard way: + +**The body crosses into the pool as text and is recompiled there.** A live +scriptblock carries the session state of the runspace that created it, so +invoking one from a pool thread runs it against the caller's session state from +another thread. During development this corrupted that state: `Get-Date` stopped +resolving on the main thread mid-run. + +**A parallel body must be self-contained.** A fresh runspace has no modules +loaded, and importing PCTools into each one would cost more than the parallelism +saves. Bodies use .NET types and built-in cmdlets only. Where a module helper is +genuinely needed - `Test-PCPing`, `Test-PCTcpPort`, `Invoke-PCProcess` - +`Get-PCFunctionSource` ships that function's own source into the runspace, so +there is still exactly one implementation of a ping rather than a sequential one +and a parallel one that can drift apart. + +## Reproducibility + +The compiled module is byte-identical between builds of the same source: the +merge walks the files in sorted order and embeds no timestamp. Confirm it with: + +```powershell +./build/Invoke-Build.ps1 -Task Compile +(Get-FileHash ./obj/PCTools/PCTools.psm1).Hash +./build/Invoke-Build.ps1 -Task Compile +(Get-FileHash ./obj/PCTools/PCTools.psm1).Hash # the same +``` + +## What was not done, and why + +**Deleting files in parallel *within* a directory.** Top-level entries are +cleared concurrently; the files inside one entry are not. Per-file metadata +operations do overlap well on an SSD, but the added complexity sits in the one +code path that permanently destroys data, and the measured gain did not justify +it. + +**A faster `Get-PCDiskUsage` for the whole of `C:\`.** It is bounded by reading +the MFT through ordinary filesystem APIs. Reading the MFT directly would be +dramatically faster and requires elevation, raw volume access, and NTFS-specific +parsing that breaks on ReFS - a large amount of fragile code for a command +people run occasionally. + +**Caching CIM results between calls.** Tempting, and wrong for a diagnostic +tool: the entire point of re-running `Get-PCNetworkAdapter` after changing an +adapter is to see the change. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..f692c52 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,328 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Installs PC Tools and opens it. + +.DESCRIPTION + The one-command install: + + irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.5.0/install.ps1 | iex + + Note the tag in that URL. It is not 'main' on purpose: pinning to a tag + means the code you run is a specific reviewed release rather than whatever + was pushed most recently. + + What this actually does, in order: + + 1. Checks that this is Windows on PowerShell 5.1 or later. + 2. Installs PCTools from the PowerShell Gallery, which is the good path - + the package is signed, PowerShellGet verifies it, and Update-Module + works afterwards. + 3. If the Gallery is unreachable or PowerShellGet is missing, downloads + the release archive from GitHub AND its SHA256SUMS, verifies the hash, + and refuses to go any further if it does not match. That verification + is not optional and there is no switch to skip it. + 4. Installs a `pctools` command into %LOCALAPPDATA%\Microsoft\WindowsApps, + which is already on PATH on Windows 10 and 11, so no PATH edit and no + elevation are needed. + 5. Opens the window. + + The honest limitation: step 1 runs code fetched over TLS from a pinned URL, + which you are trusting on the strength of that TLS connection. Everything + after it is signature- or hash-verified. If you would rather verify the + first step too, the README documents the fully manual path - download, + check the hash yourself, then run. + +.PARAMETER Version + The release to install, e.g. '0.5.0'. Defaults to the newest published. + +.PARAMETER Scope + CurrentUser (default) or AllUsers. AllUsers needs an elevated session. + +.PARAMETER NoLaunch + Install without opening the window. + +.PARAMETER CliOnly + Skip the `pctools` shim; install the module only. + +.PARAMETER Force + Reinstall even when the requested version is already present. + +.EXAMPLE + irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.5.0/install.ps1 | iex + +.EXAMPLE + & ([scriptblock]::Create((irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.5.0/install.ps1))) -NoLaunch + + Passing arguments through a piped-to-iex script needs this form; `iex` + itself has nowhere to put them. + +.LINK + https://github.com/likeBloodMoon/pc-powershelltools +#> +[CmdletBinding()] +param( + [string]$Version, + + [ValidateSet('CurrentUser', 'AllUsers')] + [string]$Scope = 'CurrentUser', + + [switch]$NoLaunch, + + [switch]$CliOnly, + + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +$script:Repository = 'likeBloodMoon/pc-powershelltools' +$script:ModuleName = 'PCTools' + +function Write-Step { + param([string]$Message) + Write-Host " $Message" -ForegroundColor Cyan +} + +function Write-Done { + param([string]$Message) + Write-Host " $Message" -ForegroundColor Green +} + +function Test-Windows { + # $IsWindows only exists on PowerShell 6+. On 5.1 the host is Windows. + if ($null -eq (Get-Variable -Name IsWindows -ErrorAction SilentlyContinue)) { return $true } + return $IsWindows +} + +function Install-FromGallery { + <# + The preferred path. Returns $true when the module is installed and + importable, $false when the Gallery could not be used at all - a + missing PowerShellGet, no network, a blocked feed. A genuine install + failure is not swallowed: it throws. + #> + param([string]$RequestedVersion, [string]$InstallScope, [switch]$Reinstall) + + if (-not (Get-Command Install-Module -ErrorAction SilentlyContinue)) { + Write-Step 'PowerShellGet is not available; using the release archive instead.' + return $false + } + + try { + # TLS 1.2 is not the default on Windows PowerShell 5.1 and the Gallery + # has required it for years. Without this the call fails with an + # unhelpful "underlying connection was closed". + [System.Net.ServicePointManager]::SecurityProtocol = + [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 + } + catch { } + + $findParams = @{ Name = $script:ModuleName; ErrorAction = 'Stop' } + if ($RequestedVersion) { $findParams['RequiredVersion'] = $RequestedVersion } + + try { + $available = Find-Module @findParams + } + catch { + Write-Step "The PowerShell Gallery is not reachable ($($_.Exception.Message.Trim()))." + return $false + } + + $installed = Get-Module -ListAvailable -Name $script:ModuleName | + Sort-Object Version -Descending | + Select-Object -First 1 + + if ($installed -and $installed.Version -ge $available.Version -and -not $Reinstall) { + Write-Done "$($script:ModuleName) $($installed.Version) is already installed." + return $true + } + + Write-Step "Installing $($script:ModuleName) $($available.Version) from the PowerShell Gallery..." + + $installParams = @{ + Name = $script:ModuleName + Scope = $InstallScope + Force = $true + AllowClobber = $true + ErrorAction = 'Stop' + } + if ($RequestedVersion) { $installParams['RequiredVersion'] = $RequestedVersion } + + Install-Module @installParams + Write-Done "Installed $($script:ModuleName) $($available.Version)." + return $true +} + +function Install-FromRelease { + <# + The fallback. Downloads the release archive and SHA256SUMS from the + GitHub release, verifies the archive against the published hash, and + only then extracts it. A mismatch is fatal - that is the entire point + of this function. + #> + param([string]$RequestedVersion) + + $tag = if ($RequestedVersion) { "v$($RequestedVersion.TrimStart('v'))" } else { 'latest' } + $base = if ($tag -eq 'latest') { + "https://github.com/$script:Repository/releases/latest/download" + } + else { + "https://github.com/$script:Repository/releases/download/$tag" + } + + $work = Join-Path ([System.IO.Path]::GetTempPath()) ("pctools-install-{0}" -f [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $work -Force | Out-Null + + try { + Write-Step "Downloading the release archive from $base ..." + + $sumsPath = Join-Path $work 'SHA256SUMS' + Invoke-WebRequest -Uri "$base/SHA256SUMS" -OutFile $sumsPath -UseBasicParsing -ErrorAction Stop + + # The archive name carries the version, which for 'latest' we do not + # know until we read the checksum list. + $line = Get-Content -LiteralPath $sumsPath | + Where-Object { $_ -match '\s(PCTools-[\d\.]+\.zip)\s*$' } | + Select-Object -First 1 + + if (-not $line) { + throw "SHA256SUMS does not list a PCTools module archive. Nothing here can be verified, so nothing will be installed." + } + + $expected = ($line -split '\s+')[0].ToLowerInvariant() + $archiveName = ($line -split '\s+')[-1] + + $archivePath = Join-Path $work $archiveName + Invoke-WebRequest -Uri "$base/$archiveName" -OutFile $archivePath -UseBasicParsing -ErrorAction Stop + + $actual = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + + if ($actual -ne $expected) { + throw @" +Checksum mismatch. Nothing was installed. + + file : $archiveName + expected : $expected + actual : $actual + +Do not run this download. Report it at https://github.com/$script:Repository/issues +"@ + } + + Write-Done "Verified $archiveName against the published SHA256." + + $moduleRoot = if ($Scope -eq 'AllUsers') { + Join-Path $env:ProgramFiles 'WindowsPowerShell\Modules' + } + else { + Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'WindowsPowerShell\Modules' + } + + $extract = Join-Path $work 'extract' + Expand-Archive -LiteralPath $archivePath -DestinationPath $extract -Force + + # The archive holds a PCTools folder; find the manifest rather than + # assuming a layout that a future release might change. + $manifest = Get-ChildItem -Path $extract -Recurse -Filter 'PCTools.psd1' -File | + Select-Object -First 1 + if (-not $manifest) { throw 'The archive does not contain PCTools.psd1.' } + + $version = (Import-PowerShellDataFile -Path $manifest.FullName).ModuleVersion + $destination = Join-Path (Join-Path $moduleRoot $script:ModuleName) $version + + if (Test-Path -LiteralPath $destination) { + Remove-Item -LiteralPath $destination -Recurse -Force + } + New-Item -ItemType Directory -Path $destination -Force | Out-Null + + Copy-Item -Path (Join-Path $manifest.DirectoryName '*') -Destination $destination -Recurse -Force + + Write-Done "Installed $script:ModuleName $version to $destination." + return $true + } + finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Install-Shim { + <# + %LOCALAPPDATA%\Microsoft\WindowsApps is on PATH by default on Windows 10 + and 11 for the current user. Dropping a .cmd there gives `pctools` in + any new terminal without touching PATH and without elevation. + #> + $shimDir = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps' + + if (-not (Test-Path -LiteralPath $shimDir)) { + # Not a Windows 10/11 layout. Fall back to the module's own folder and + # say so, rather than silently editing PATH. + $shimDir = Join-Path $env:LOCALAPPDATA 'PCTools\bin' + New-Item -ItemType Directory -Path $shimDir -Force | Out-Null + Write-Step "Installed the shim to $shimDir - add it to PATH to use 'pctools' directly." + } + + $shimPath = Join-Path $shimDir 'pctools.cmd' + + # -STA because the GUI is WinForms. %* forwards every argument to + # Invoke-PCTools, and -Exit makes the process exit code the run's result. + $shim = @' +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -Command "Import-Module PCTools -ErrorAction Stop; Invoke-PCTools -Exit %*" +'@ + + Set-Content -LiteralPath $shimPath -Value $shim -Encoding ASCII + Write-Done "The 'pctools' command is available in a new terminal." +} + +Write-Host '' +Write-Host ' PC Tools installer' -ForegroundColor White +Write-Host ' https://github.com/likeBloodMoon/pc-powershelltools' +Write-Host '' + +if (-not (Test-Windows)) { + throw 'PC Tools maintains Windows. This installer only runs on Windows.' +} + +if ($PSVersionTable.PSVersion -lt [version]'5.1') { + throw "PowerShell 5.1 or later is required; this session is $($PSVersionTable.PSVersion)." +} + +if ($Scope -eq 'AllUsers') { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw '-Scope AllUsers needs an elevated session. Re-run as Administrator, or use the default -Scope CurrentUser.' + } +} + +$installed = Install-FromGallery -RequestedVersion $Version -InstallScope $Scope -Reinstall:$Force + +if (-not $installed) { + $installed = Install-FromRelease -RequestedVersion $Version +} + +if (-not $installed) { + throw 'PC Tools could not be installed from either the PowerShell Gallery or a GitHub release.' +} + +Import-Module $script:ModuleName -Force -ErrorAction Stop +$module = Get-Module $script:ModuleName + +if (-not $CliOnly) { + Install-Shim +} + +Write-Host '' +Write-Host " PC Tools $($module.Version) is installed." -ForegroundColor Green +Write-Host '' +Write-Host ' pctools open the window' +Write-Host ' pctools -ProfileName Quick reclaim disk space and exit' +Write-Host ' pctools -Diagnose diagnose the network' +Write-Host ' Get-Command -Module PCTools every command' +Write-Host ' Invoke-PCMaintenance -WhatIf preview without changing anything' +Write-Host '' + +if (-not $NoLaunch) { + Start-PCTools +} diff --git a/pc-tools.ps1 b/pc-tools.ps1 index 31a407d..008cb0d 100644 --- a/pc-tools.ps1 +++ b/pc-tools.ps1 @@ -1,19 +1,25 @@ #requires -Version 5.1 <# .SYNOPSIS - Launches PC Tools. + Launches PC Tools from a cloned repository or an extracted release archive. .DESCRIPTION - The entry point for the current toolkit. Finds the PCTools module and the - GUI shell next to this script, then starts the shell. + A thin launcher, kept so existing links and the release archive layout keep + working. It imports the PCTools module sitting next to it and calls + Start-PCTools. - The older single-file scripts (pc-cleanuptool.ps1, pc-netdiag.ps1) are still - present and still work, but they are frozen: new work goes into the module - and this shell. See MIGRATION.md. + If PCTools is installed - Install-Module PCTools, or install.ps1 - you do + not need this file at all: + + pctools + Start-PCTools .PARAMETER Theme Dark or Light. Defaults to Dark. +.PARAMETER Page + The page to open on. Defaults to the dashboard. + .PARAMETER NoGui Import the PCTools module into the current session and exit, instead of opening the window. Use this to drive the actions from the console. @@ -33,6 +39,9 @@ param( [ValidateSet('Dark', 'Light')] [string]$Theme = 'Dark', + [ValidateSet('Dashboard', 'Maintenance', 'Storage', 'Health', 'Network', 'Software', 'Preferences', 'Log')] + [string]$Page = 'Dashboard', + [switch]$NoGui ) @@ -45,11 +54,11 @@ if (-not $root) { throw @' PC Tools cannot locate its own folder. -This usually means the script was piped into iex from the network. The GUI -needs the PCTools module alongside it, so download the release archive and run -pc-tools.ps1 from disk instead: +This usually means the script was piped into iex from the network. Use the +installer instead - it handles that case, verifies what it downloads, and gives +you a `pctools` command: - https://github.com/likeBloodMoon/pc-powershelltools/releases/latest + irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.5.0/install.ps1 | iex '@ } @@ -67,6 +76,7 @@ if ($NoGui) { Write-Host ' Get-Command -Module PCTools list every action' Write-Host ' Get-PCMaintenanceProfile show the built-in profiles' Write-Host ' Invoke-PCMaintenance -WhatIf preview without changing anything' + Write-Host ' Get-PCHealthReport how is this machine doing' Write-Host ' Get-PCNetworkReport | Format-List diagnose the network' Write-Host '' if (-not (Test-PCAdmin)) { @@ -75,9 +85,4 @@ if ($NoGui) { return } -$shellPath = Join-Path $root 'src\Shell\Start-PCToolsShell.ps1' -if (-not (Test-Path -LiteralPath $shellPath)) { - throw "The PC Tools shell is missing. Expected it at: $shellPath" -} - -& $shellPath -Theme $Theme +Start-PCTools -Theme $Theme -Page $Page diff --git a/src/PCTools/PCTools.psd1 b/src/PCTools/PCTools.psd1 index 43bf317..92c7876 100644 --- a/src/PCTools/PCTools.psd1 +++ b/src/PCTools/PCTools.psd1 @@ -1,12 +1,12 @@ @{ RootModule = 'PCTools.psm1' - ModuleVersion = '0.4.0' + ModuleVersion = '0.5.0' GUID = '6f3c0a58-1d2e-4a7b-9c34-8e5f2b7d41a9' Author = 'likeBloodMoon' CompanyName = 'likeBloodMoon' Copyright = '(c) 2025 likeBloodMoon. Released under the MIT License.' - Description = 'Windows maintenance, repair and network diagnostic actions. The shared engine behind the pc-powershelltools GUIs: every action supports -WhatIf and returns a structured result rather than writing to a log box.' + Description = 'Windows maintenance, repair, storage analysis, health reporting and network diagnostics - as commands and as a GUI, in one module. Every action supports -WhatIf and returns a structured result. Run Start-PCTools for the window, or Invoke-PCTools for a scriptable run with an exit code.' PowerShellVersion = '5.1' @@ -18,12 +18,38 @@ 'Clear-PCTempFile' 'Clear-PCWindowsUpdateCache' + # Storage and disk analysis + 'Clear-PCCrashDump' + 'Clear-PCDeliveryOptimization' + 'Clear-PCEventLog' + 'Clear-PCThumbnailCache' + 'Clear-PCWindowsOld' + 'Get-PCDiskSpace' + 'Get-PCDiskUsage' + 'Optimize-PCVolume' + + # Startup and services + 'Disable-PCStartupItem' + 'Enable-PCStartupItem' + 'Get-PCService' + 'Get-PCStartupItem' + 'Set-PCServiceStartup' + # Repair 'New-PCRestorePoint' 'Repair-PCSystemFile' 'Repair-PCSystemImage' 'Test-PCDisk' + # Health and security reporting + 'Get-PCBatteryReport' + 'Get-PCBootPerformance' + 'Get-PCDiskHealth' + 'Get-PCEventSummary' + 'Get-PCHealthReport' + 'Get-PCSecurityStatus' + 'Get-PCSystemInfo' + # Network 'Clear-PCDnsCache' 'Export-PCNetworkProfile' @@ -37,34 +63,52 @@ 'Set-PCDnsServer' 'Set-PCNetworkAddress' 'Test-PCConnectivity' + 'Test-PCDnsServer' 'Test-PCMtu' 'Test-PCRoute' + 'Test-PCThroughput' # Preferences 'Get-PCPreference' 'Restart-PCExplorer' 'Set-PCPreference' - # Software + # Software, updates and drivers + 'Get-PCAppxPackage' + 'Get-PCDriverIssue' + 'Get-PCWindowsUpdate' 'Install-PCApplication' + 'Install-PCWindowsUpdate' + 'Remove-PCAppxPackage' + 'Update-PCApplication' - # Orchestration and reporting + # Automation, history and extensions + 'Get-PCExtension' + 'Get-PCHistory' + 'Get-PCScheduledMaintenance' + 'Register-PCExtension' + 'Register-PCScheduledMaintenance' + 'Save-PCHistory' + 'Unregister-PCScheduledMaintenance' + + # Entry points, orchestration and reporting 'Export-PCReport' + 'Format-PCByteSize' + 'Get-PCLogPath' 'Get-PCMaintenanceProfile' + 'Get-PCResultSummary' 'Import-PCConfiguration' 'Invoke-PCMaintenance' - - # Host integration - 'Format-PCByteSize' - 'Get-PCLogPath' + 'Invoke-PCTools' 'Register-PCLogSink' + 'Start-PCTools' 'Test-PCAdmin' 'Unregister-PCLogSink' ) CmdletsToExport = @() VariablesToExport = @() - AliasesToExport = @() + AliasesToExport = @('pctools') # Windows-only: the GUI needs WinForms, and the actions call Windows-only # cmdlets. Declaring it keeps the module out of Linux and macOS search @@ -75,13 +119,23 @@ PSData = @{ Tags = @( 'Windows', 'Maintenance', 'Cleanup', 'Network', 'Diagnostics', - 'Optimization', 'Repair', 'DISM', 'SFC', 'GUI', + 'Optimization', 'Repair', 'DISM', 'SFC', 'GUI', 'Health', + 'Storage', 'Startup', 'Security', 'WindowsUpdate', 'winget', 'PSEdition_Desktop', 'PSEdition_Core' ) LicenseUri = 'https://github.com/likeBloodMoon/pc-powershelltools/blob/main/LICENSE' ProjectUri = 'https://github.com/likeBloodMoon/pc-powershelltools' ReleaseNotes = @' -Every action supports -WhatIf and returns a structured result. +0.5.0 - one command, faster, and a great deal more of it. + +- The GUI now ships inside the module: Install-Module PCTools; Start-PCTools. +- Invoke-PCTools gives a scriptable run with a meaningful exit code. +- 35 new commands: storage analysis, startup and service control, health and + security reporting, Windows Update and winget, scheduled maintenance, run + history, HTML reports and a drop-in extension model. +- The network and cleanup hot paths run concurrently instead of serially. + +Every action still supports -WhatIf and returns a structured result. See the full changelog: https://github.com/likeBloodMoon/pc-powershelltools/blob/main/CHANGELOG.md diff --git a/src/PCTools/PCTools.psm1 b/src/PCTools/PCTools.psm1 index a22ff73..d140b20 100644 --- a/src/PCTools/PCTools.psm1 +++ b/src/PCTools/PCTools.psm1 @@ -9,7 +9,21 @@ Set-StrictMode -Version Latest $script:ModuleName = 'PCTools' -$script:ModuleVersion = (Import-PowerShellDataFile -Path (Join-Path $PSScriptRoot 'PCTools.psd1')).ModuleVersion + +# The build stamps the version in when it compiles the shipping module, so a +# release does not re-read and re-parse its own manifest on every import. From +# a source checkout there is nothing to stamp, so fall back to the manifest. +$script:ModuleVersion = '{{MODULE_VERSION}}' + +if ($script:ModuleVersion -like '{{*') { + # Not stamped, so this is a source checkout rather than a built module. + try { + $script:ModuleVersion = (Import-PowerShellDataFile -Path (Join-Path $PSScriptRoot 'PCTools.psd1')).ModuleVersion + } + catch { + $script:ModuleVersion = '0.0.0' + } +} # $IsWindows exists in PowerShell 6+ only. On Windows PowerShell 5.1 the host # is Windows by definition. This lets the module import on Linux CI so the @@ -30,40 +44,26 @@ else { } $script:PCLogDirectory = Join-Path $script:PCDataRoot 'logs' +$script:PCHistoryDirectory = Join-Path $script:PCDataRoot 'history' +$script:PCExtensionDirectory = Join-Path $script:PCDataRoot 'extensions' $script:PCLogPath = $null $script:PCLogSinkTable = @{} $script:PCLogSinks = @() +# Set once the log file has been prepared. Preparing it costs a directory +# probe, a file stat and possibly a rotation, and importing the module is not +# the moment to spend that - a session that only calls Get-PCPreference never +# writes a log line at all. Initialize-PCLog does the work on first write. +$script:PCLogReady = $false + # Maintenance profiles loaded from a user's JSON config, if any. $script:PCImportedProfile = @() -try { - if (-not (Test-Path -LiteralPath $script:PCLogDirectory)) { - New-Item -ItemType Directory -Path $script:PCLogDirectory -Force | Out-Null - } - - $script:PCLogPath = Join-Path $script:PCLogDirectory 'pctools.log' +# Set by Invoke-PCTools so a caller can read the last computed exit code. +$script:PCLastExitCode = 0 - # Rotate at 2 MB, keeping the five most recent archives, so a machine where - # these tools run weekly does not accumulate an unbounded log. - if (Test-Path -LiteralPath $script:PCLogPath) { - $existing = Get-Item -LiteralPath $script:PCLogPath - if ($existing.Length -gt 2MB) { - $archive = Join-Path $script:PCLogDirectory ('pctools-{0:yyyyMMdd-HHmmss}.log' -f (Get-Date)) - Move-Item -LiteralPath $script:PCLogPath -Destination $archive -Force - - Get-ChildItem -LiteralPath $script:PCLogDirectory -Filter 'pctools-*.log' | - Sort-Object LastWriteTime -Descending | - Select-Object -Skip 5 | - Remove-Item -Force -ErrorAction SilentlyContinue - } - } -} -catch { - # Logging to a file is a convenience, not a prerequisite for running. - $script:PCLogPath = $null - Write-Warning "PCTools: file logging disabled ($($_.Exception.Message))." -} +# Extension actions loaded from disk by Register-PCExtension, keyed by name. +$script:PCExtension = @{} # Dot-source Private first: Public functions depend on those helpers. $script:PublicFunctionName = [System.Collections.Generic.List[string]]::new() @@ -91,4 +91,20 @@ foreach ($folder in 'Private', 'Public') { } } -Export-ModuleMember -Function $script:PublicFunctionName +# Aliases declared with [Alias()] on a public function are exported too, so the +# manifest's AliasesToExport has something to find. Read from the functions +# actually loaded rather than a hand-kept list, which would drift. +$script:PublicAliasName = [System.Collections.Generic.List[string]]::new() + +foreach ($name in $script:PublicFunctionName) { + $command = Get-Command -Name $name -CommandType Function -ErrorAction SilentlyContinue + if (-not $command) { continue } + + foreach ($attribute in $command.ScriptBlock.Attributes) { + if ($attribute -is [System.Management.Automation.AliasAttribute]) { + foreach ($alias in $attribute.AliasNames) { $script:PublicAliasName.Add($alias) } + } + } +} + +Export-ModuleMember -Function $script:PublicFunctionName -Alias $script:PublicAliasName diff --git a/src/PCTools/Private/Clear-PCFolderContent.ps1 b/src/PCTools/Private/Clear-PCFolderContent.ps1 index d1c8f10..e6fbdb6 100644 --- a/src/PCTools/Private/Clear-PCFolderContent.ps1 +++ b/src/PCTools/Private/Clear-PCFolderContent.ps1 @@ -9,24 +9,43 @@ function Clear-PCFolderContent { all mattered: - The Cleanup Tool piped Get-ChildItem -Recurse into Remove-Item -Recurse, - walking every tree twice for no benefit. This enumerates one level and - lets Remove-Item recurse. + walking every tree twice for no benefit. - Neither measured anything, so the user was never told what they gained. - This sizes each entry before deleting it. - Neither used -LiteralPath, so a path containing [ or ] was silently skipped as an unmatched wildcard. - Files locked by a running process are expected and are counted as + This version walks each tree exactly once, through System.IO rather than + the PowerShell provider, summing sizes and deleting as it goes. On a + temp folder holding tens of thousands of small files the provider + overhead per item was most of the cost, and the second walk was pure + waste. + + Two behaviours changed with the rewrite, both for the better: + + - A locked file no longer costs its whole directory. Deleting per file + instead of per top-level entry means one held-open Chrome cache file + leaves that one file behind, not the 400 MB directory containing it. + - Bytes are counted after each successful delete rather than sized up + front, so the reported figure is what was actually reclaimed rather + than what was hoped for. + + Files locked by a running process remain expected and are counted as skipped, not as failures. .PARAMETER Path The folder whose contents are removed. The folder itself is kept. .PARAMETER ExcludeName - Entry names to leave alone. + Top-level entry names to leave alone. + + .PARAMETER ThrottleLimit + How many top-level entries to clear at once. Deleting many small files + is dominated by per-file metadata operations, which overlap well. The + ShouldProcess decision is always made on the calling thread first; only + the deletion of already-approved entries is concurrent. .OUTPUTS - pscustomobject with BytesFreed, ItemsRemoved and ItemsLocked. + pscustomobject with BytesFreed, ItemsRemoved, ItemsLocked and Missing. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] @@ -34,56 +53,155 @@ function Clear-PCFolderContent { [Parameter(Mandatory)] [string]$Path, - [string[]]$ExcludeName = @() - ) + [string[]]$ExcludeName = @(), - $bytesFreed = 0L - $removed = 0 - $locked = 0 + [ValidateRange(1, 16)] + [int]$ThrottleLimit = 4 + ) if (-not (Test-Path -LiteralPath $Path)) { Write-PCLog -Level WARN -Message "Path does not exist, skipping: $Path" - return [pscustomobject]@{ BytesFreed = 0L; ItemsRemoved = 0; ItemsLocked = 0; Missing = $true } + return [pscustomobject]@{ + BytesFreed = 0L; ItemsRemoved = 0; ItemsLocked = 0 + FilesRemoved = 0; FilesLocked = 0; Missing = $true + } } - $entries = @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue) + $entries = @() + try { + $entries = @([System.IO.Directory]::EnumerateFileSystemEntries($Path)) + } + catch { + Write-PCLog -Level WARN -Message "Cannot enumerate ${Path}: $($_.Exception.Message)" + return [pscustomobject]@{ + BytesFreed = 0L; ItemsRemoved = 0; ItemsLocked = 0 + FilesRemoved = 0; FilesLocked = 0; Missing = $false + } + } + + # ShouldProcess must run on this thread - $PSCmdlet does not exist in a + # background runspace - so approval happens first, in full, and only the + # approved list is handed to the workers. Under -WhatIf nothing is approved + # and nothing is deleted. + $approved = [System.Collections.Generic.List[string]]::new() foreach ($entry in $entries) { - if ($ExcludeName -contains $entry.Name) { continue } + $name = [System.IO.Path]::GetFileName($entry) + if ($ExcludeName -contains $name) { continue } + if (-not $PSCmdlet.ShouldProcess($entry, 'Remove')) { continue } + $approved.Add($entry) + } + + if ($approved.Count -eq 0) { + return [pscustomobject]@{ + BytesFreed = 0L; ItemsRemoved = 0; ItemsLocked = 0 + FilesRemoved = 0; FilesLocked = 0; Missing = $false + } + } + + # Self-contained by construction: System.IO only, no module functions, no + # captured variables. See Invoke-PCParallel. + $clearBody = { + param($Entry) + + $bytes = 0L + $filesRemoved = 0 + $filesLocked = 0 - $size = 0L try { - if ($entry.PSIsContainer) { - $size = (Get-ChildItem -LiteralPath $entry.FullName -Recurse -Force -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum - if (-not $size) { $size = 0L } + if ([System.IO.Directory]::Exists($Entry)) { + # One walk: size and delete each file as it is reached. + $files = @() + try { + $files = [System.IO.Directory]::EnumerateFiles( + $Entry, '*', [System.IO.SearchOption]::AllDirectories) + } + catch { + # A reparse point or a permission wall part-way down. Take + # what the top level gives rather than abandoning the entry. + try { $files = [System.IO.Directory]::EnumerateFiles($Entry) } catch { $files = @() } + } + + foreach ($file in $files) { + try { + $length = ([System.IO.FileInfo]$file).Length + + # Read-only files are common in browser caches and are + # not a reason to leave the space occupied. + $info = [System.IO.FileInfo]$file + if ($info.IsReadOnly) { $info.IsReadOnly = $false } + + [System.IO.File]::Delete($file) + $bytes += $length + $filesRemoved++ + } + catch { + $filesLocked++ + } + } + + # Now empty, unless something was locked. Recursive because the + # walk above left the directory skeleton behind. + try { [System.IO.Directory]::Delete($Entry, $true) } catch { } } else { - $size = $entry.Length + $info = [System.IO.FileInfo]$Entry + $length = $info.Length + if ($info.IsReadOnly) { $info.IsReadOnly = $false } + [System.IO.File]::Delete($Entry) + $bytes += $length + $filesRemoved++ } } catch { - $size = 0L + $filesLocked++ } - if (-not $PSCmdlet.ShouldProcess($entry.FullName, 'Remove')) { continue } + $gone = -not ([System.IO.Directory]::Exists($Entry) -or [System.IO.File]::Exists($Entry)) - try { - Remove-Item -LiteralPath $entry.FullName -Recurse -Force -ErrorAction Stop - $bytesFreed += $size - $removed++ + @{ + BytesFreed = $bytes + FilesRemoved = $filesRemoved + FilesLocked = $filesLocked + Removed = $gone } - catch { - # In-use files are the normal case in TEMP, not an error worth raising. + } + + $completed = @(Invoke-PCParallel -InputObject $approved.ToArray() -ScriptBlock $clearBody ` + -ThrottleLimit $ThrottleLimit -TimeoutSeconds 900) + + $bytesFreed = 0L + $removed = 0 + $locked = 0 + $filesRemoved = 0 + $filesLocked = 0 + + foreach ($item in $completed) { + $outcome = $item.Output + if (-not $outcome) { $locked++ - Write-PCLog -Level DEBUG -Message "Locked or protected, left in place: $($entry.FullName)" + Write-PCLog -Level DEBUG -Message "Could not clear: $($item.Input)" + continue } + + $bytesFreed += [long]$outcome.BytesFreed + $filesRemoved += [int]$outcome.FilesRemoved + $filesLocked += [int]$outcome.FilesLocked + + if ($outcome.Removed) { $removed++ } else { $locked++ } + } + + if ($filesLocked -gt 0) { + # In-use files are the normal case in TEMP, not an error worth raising. + Write-PCLog -Level DEBUG -Message "$filesLocked file(s) under $Path were in use and left in place." } [pscustomobject]@{ BytesFreed = $bytesFreed ItemsRemoved = $removed ItemsLocked = $locked + FilesRemoved = $filesRemoved + FilesLocked = $filesLocked Missing = $false } } diff --git a/src/PCTools/Private/Get-PCCriticalServiceName.ps1 b/src/PCTools/Private/Get-PCCriticalServiceName.ps1 new file mode 100644 index 0000000..461ee91 --- /dev/null +++ b/src/PCTools/Private/Get-PCCriticalServiceName.ps1 @@ -0,0 +1,63 @@ +function Get-PCCriticalServiceName { + <# + .SYNOPSIS + The services this module refuses to reconfigure. + + .DESCRIPTION + The internet is full of "services you can safely disable" lists, and + following one is a reliable way to produce a machine that will not boot, + will not reach the network, or cannot install updates. This list is the + opposite: the services no amount of user insistence will get + Set-PCServiceStartup to touch. + + It is deliberately short. It is not a list of services that are useful - + it is the list where getting it wrong means an unbootable machine or a + support call, and where the space or CPU saved would be negligible + anyway. + + Kept as a function rather than a variable so it has somewhere to explain + itself and so the tests can assert against it. + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + + @( + # Boot and session + 'BFE' # Base Filtering Engine - the firewall stops working + 'CoreMessagingRegistrar' + 'DcomLaunch' # taking this out does not let the machine boot + 'LSM' + 'PlugPlay' + 'Power' + 'ProfSvc' # user profiles do not load + 'RpcSs' + 'RpcEptMapper' + 'SamSs' + 'Schedule' # every scheduled task, including this module's own + 'SystemEventsBroker' + 'UserManager' + 'Winmgmt' # WMI: half of this module stops working + + # Storage and filesystem + 'PartMgr' + 'Volsnap' # no shadow copies means no restore points + + # Network + 'Dhcp' + 'Dnscache' + 'NlaSvc' + 'nsi' + 'Netman' + 'WinHttpAutoProxySvc' + + # Security and servicing + 'CryptSvc' # signature verification, and therefore updates + 'EventLog' + 'MpsSvc' # Windows Firewall + 'TrustedInstaller' # DISM and SFC repair + 'wuauserv' # Windows Update + 'BITS' # how updates actually download + 'gpsvc' # group policy; disabling this can lock you out + ) +} diff --git a/src/PCTools/Private/Get-PCFunctionSource.ps1 b/src/PCTools/Private/Get-PCFunctionSource.ps1 new file mode 100644 index 0000000..ab26c90 --- /dev/null +++ b/src/PCTools/Private/Get-PCFunctionSource.ps1 @@ -0,0 +1,59 @@ +function Get-PCFunctionSource { + <# + .SYNOPSIS + Returns the source of named module functions as a scriptblock that + redefines them. + + .DESCRIPTION + A runspace started by Invoke-PCParallel has no modules loaded, so a + parallel body cannot call a PCTools helper. The obvious workaround - + reimplementing the helper inline in the body - creates two copies of the + same logic that drift apart, and the probe helpers here (Test-PCPing, + Test-PCTcpPort) are exactly the code that must not quietly differ + between the sequential and parallel paths. + + So the definition itself is shipped into the runspace. There is one + implementation of a ping; the parallel path runs that one. + + Only helpers that are already self-contained can travel this way. Ones + that log get a no-op Write-PCLog alongside them, since a background + runspace has no log file handle and its Write-Verbose output is + discarded anyway. + + .PARAMETER Name + The functions to include, in dependency order. + + .PARAMETER IncludeLogStub + Also define a no-op Write-PCLog, for helpers that call it. + + .OUTPUTS + scriptblock + #> + [CmdletBinding()] + [OutputType([scriptblock])] + param( + [Parameter(Mandatory, Position = 0)] + [string[]]$Name, + + [switch]$IncludeLogStub + ) + + $builder = [System.Text.StringBuilder]::new() + + if ($IncludeLogStub) { + [void]$builder.AppendLine('function Write-PCLog { param([string]$Message, [string]$Level = ''INFO'') }') + } + + foreach ($functionName in $Name) { + $command = Get-Command -Name $functionName -CommandType Function -ErrorAction SilentlyContinue + if (-not $command) { + throw "Get-PCFunctionSource: no such function '$functionName'." + } + + [void]$builder.AppendLine("function $functionName {") + [void]$builder.AppendLine($command.Definition) + [void]$builder.AppendLine('}') + } + + [scriptblock]::Create($builder.ToString()) +} diff --git a/src/PCTools/Private/Get-PCRemovableAppxName.ps1 b/src/PCTools/Private/Get-PCRemovableAppxName.ps1 new file mode 100644 index 0000000..8476cdd --- /dev/null +++ b/src/PCTools/Private/Get-PCRemovableAppxName.ps1 @@ -0,0 +1,84 @@ +function Get-PCRemovableAppxName { + <# + .SYNOPSIS + The curated list of preinstalled apps this module will remove. + + .DESCRIPTION + An allowlist, not a blocklist, and the distinction is the entire safety + model. "Remove everything except a deny list" is how debloating scripts + break the Store, Calculator, the photo viewer, or - reliably, on at + least one popular script - the ability to open a JPEG. + + Every entry here is a preinstalled consumer application that nothing + else depends on and that can be reinstalled from the Store afterwards. + Deliberately absent, whatever the debloat lists say: + + - Microsoft.WindowsStore removing it means nothing can be + reinstalled, including itself + - Microsoft.WindowsCalculator people use it, and it costs nothing + - Microsoft.Windows.Photos leaves no default image viewer + - Microsoft.WindowsTerminal the shell this module runs in + - Microsoft.DesktopAppInstaller winget lives in it, and two commands + here depend on winget + - Microsoft.SecHealthUI the Windows Security interface + - anything *Framework* or VCLibs, which are shared runtimes + + Kept as a function so it can explain itself and so the tests can assert + that these exclusions stay excluded. + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + + @( + # Entertainment and lifestyle preinstalls + 'Microsoft.BingNews' + 'Microsoft.BingWeather' + 'Microsoft.BingFinance' + 'Microsoft.BingSports' + 'Microsoft.BingTranslator' + 'Microsoft.ZuneMusic' + 'Microsoft.ZuneVideo' + 'Microsoft.MicrosoftSolitaireCollection' + 'Microsoft.MinecraftUWP' + 'Microsoft.MixedReality.Portal' + 'Microsoft.Microsoft3DViewer' + 'Microsoft.Print3D' + 'Microsoft.3DBuilder' + 'Microsoft.MSPaint' # Paint 3D, not classic Paint + 'Microsoft.SkypeApp' + 'Microsoft.YourPhone' + 'Microsoft.People' + 'Microsoft.WindowsFeedbackHub' + 'Microsoft.GetHelp' + 'Microsoft.Getstarted' + 'Microsoft.WindowsMaps' + 'Microsoft.WindowsSoundRecorder' + 'Microsoft.Wallet' + 'Microsoft.OneConnect' + 'Microsoft.Office.OneNote' + 'Microsoft.MicrosoftOfficeHub' + 'Microsoft.MicrosoftStickyNotes' + 'Microsoft.Todos' + 'Microsoft.PowerAutomateDesktop' + 'MicrosoftTeams' + 'MSTeams' + 'Clipchamp.Clipchamp' + 'Microsoft.GamingApp' + 'Microsoft.Xbox.TCUI' + 'Microsoft.XboxGameOverlay' + 'Microsoft.XboxGamingOverlay' + 'Microsoft.XboxSpeechToTextOverlay' + 'Microsoft.XboxApp' + 'Microsoft.549981C3F5F10' # Cortana + + # Common OEM preinstalls + 'king.com.CandyCrushSaga' + 'king.com.CandyCrushSodaSaga' + 'Facebook.Facebook' + 'SpotifyAB.SpotifyMusic' + 'Disney.37853FC22B2CE' + 'AmazonVideo.PrimeVideo' + 'BytedancePte.Ltd.TikTok' + ) +} diff --git a/src/PCTools/Private/Initialize-PCLog.ps1 b/src/PCTools/Private/Initialize-PCLog.ps1 new file mode 100644 index 0000000..a53c8d6 --- /dev/null +++ b/src/PCTools/Private/Initialize-PCLog.ps1 @@ -0,0 +1,55 @@ +function Initialize-PCLog { + <# + .SYNOPSIS + Prepares the log file, once, on first use. + + .DESCRIPTION + This used to run at import: create the log directory, stat the log file, + rotate it if it had grown past 2 MB, and prune the old archives. Every + import paid for it, including the ones that never logged anything - + Get-PCPreference, Format-PCByteSize, a tab-completion probe. + + Doing it on the first Write-PCLog instead costs the same for a session + that logs and nothing for a session that does not. + + Failure here is not fatal. Logging to a file is a convenience; the + module still works with it disabled, and the sinks a host registers are + unaffected. + #> + [CmdletBinding()] + [OutputType([void])] + param() + + # Set first: a failure below must not make every subsequent log line retry + # the same broken path. + $script:PCLogReady = $true + + try { + if (-not (Test-Path -LiteralPath $script:PCLogDirectory)) { + New-Item -ItemType Directory -Path $script:PCLogDirectory -Force | Out-Null + } + + $path = Join-Path $script:PCLogDirectory 'pctools.log' + + # Rotate at 2 MB, keeping the five most recent archives, so a machine + # where these tools run weekly does not accumulate an unbounded log. + if (Test-Path -LiteralPath $path) { + $existing = Get-Item -LiteralPath $path + if ($existing.Length -gt 2MB) { + $archive = Join-Path $script:PCLogDirectory ('pctools-{0:yyyyMMdd-HHmmss}.log' -f (Get-Date)) + Move-Item -LiteralPath $path -Destination $archive -Force + + Get-ChildItem -LiteralPath $script:PCLogDirectory -Filter 'pctools-*.log' | + Sort-Object LastWriteTime -Descending | + Select-Object -Skip 5 | + Remove-Item -Force -ErrorAction SilentlyContinue + } + } + + $script:PCLogPath = $path + } + catch { + $script:PCLogPath = $null + Write-Warning "PCTools: file logging disabled ($($_.Exception.Message))." + } +} diff --git a/src/PCTools/Private/Invoke-PCParallel.ps1 b/src/PCTools/Private/Invoke-PCParallel.ps1 new file mode 100644 index 0000000..d7dca67 --- /dev/null +++ b/src/PCTools/Private/Invoke-PCParallel.ps1 @@ -0,0 +1,200 @@ +function Invoke-PCParallel { + <# + .SYNOPSIS + Runs a scriptblock against many inputs concurrently. + + .DESCRIPTION + The module's hot paths are all the same shape: a handful of independent + I/O-bound probes run one after another, so the wall-clock cost is their + sum when it could be their maximum. A traceroute waits out twenty + timeouts in sequence; a connectivity check waits out nine. This runs + them at once. + + A runspace pool is used on both Windows PowerShell 5.1 and PowerShell 7 + rather than switching to ForEach-Object -Parallel on 7. That was the + intent, but PowerShell 7 refuses to pass a scriptblock into a parallel + block ("a ForEach-Object -Parallel using variable cannot be a script + block ... can result in undefined behavior"), which is exactly what a + general-purpose helper like this one has to do. Two code paths where one + of them is documented as undefined is worse than one that behaves + identically everywhere. + + The body crosses into the pool as *text* and is rebuilt there with + [scriptblock]::Create. That is not ceremony. A live scriptblock carries + the session state of the runspace that created it, so invoking one from + a pool thread runs it against the caller's session state from another + thread - which corrupts it: during development this made Get-Date stop + resolving on the main thread mid-run. Marshalling the text and + recompiling gives each runspace its own block, which is also precisely + why the next paragraph holds. + + IMPORTANT - what a body may contain. A fresh runspace has no modules + loaded, and importing PCTools into each one would cost far more than the + parallelism saves. A body passed here must be self-contained: .NET types + (Ping, TcpClient, Dns, Directory, FileInfo) and built-in cmdlets only, + never another PCTools function, and never a variable captured from the + caller - pass those through -ArgumentList. Every caller in this module + satisfies that; keep it that way. + + .PARAMETER InputObject + The items to process. Each is passed to the body both as the first + argument and as $_, so a body can be written either way. + + .PARAMETER ScriptBlock + The work. Must be self-contained - see above. + + .PARAMETER ThrottleLimit + Maximum concurrent runspaces. The default suits I/O-bound probes, where + the threads spend their time waiting rather than computing. + + .PARAMETER TimeoutSeconds + An upper bound on the whole batch. A body that hangs cannot hang the + caller; incomplete work is abandoned and what finished is returned. + + .PARAMETER ArgumentList + Extra arguments appended after the item, for values every body needs. + + .PARAMETER InitScript + Runs once in each runspace before the body. This is how a body reaches a + module helper without an Import-Module: pass the helper's own source, + via Get-PCFunctionSource, so the parallel copy and the sequential + original cannot drift apart. + + .OUTPUTS + One pscustomobject per input, in input order, with the body's output in + Output and any terminating error in Error. Order is preserved even + though execution is not, because every caller here reports results in a + meaningful order. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory, Position = 0)] + [AllowEmptyCollection()] + [object[]]$InputObject, + + [Parameter(Mandatory, Position = 1)] + [scriptblock]$ScriptBlock, + + [ValidateRange(1, 64)] + [int]$ThrottleLimit = 8, + + [ValidateRange(1, 3600)] + [int]$TimeoutSeconds = 120, + + [object[]]$ArgumentList = @(), + + [scriptblock]$InitScript + ) + + $items = @($InputObject) + if ($items.Count -eq 0) { return } + + # One item is not worth a pool. Run it here and keep the output shape. + if ($items.Count -eq 1) { + $output = $null + $failure = $null + try { + $current = $items[0] + $output = & { + if ($InitScript) { . $InitScript } + $_ = $current + & $ScriptBlock $current @ArgumentList + } + } + catch { + $failure = $_ + } + + return [pscustomobject]@{ + Index = 0 + Input = $items[0] + Output = $output + Error = $failure + } + } + + $throttle = [math]::Min($ThrottleLimit, $items.Count) + + $pool = [runspacefactory]::CreateRunspacePool(1, $throttle) + $pool.ThreadOptions = 'ReuseThread' + $pool.Open() + + # The body arrives as text and is recompiled inside the runspace - see the + # description. $_ is set around the call so a body may use either $_ or a + # param block. + $bodyText = $ScriptBlock.ToString() + $initText = if ($InitScript) { $InitScript.ToString() } else { '' } + + $wrapper = { + param($Item, $BodyText, $Extra, $InitText) + if ($InitText) { . ([scriptblock]::Create($InitText)) } + $body = [scriptblock]::Create($BodyText) + $_ = $Item + & $body $Item @Extra + } + + $running = [System.Collections.Generic.List[object]]::new() + + try { + for ($i = 0; $i -lt $items.Count; $i++) { + $shell = [powershell]::Create() + $shell.RunspacePool = $pool + $null = $shell.AddScript($wrapper). + AddArgument($items[$i]). + AddArgument($bodyText). + AddArgument($ArgumentList). + AddArgument($initText) + + $running.Add([pscustomobject]@{ + Index = $i + Input = $items[$i] + Shell = $shell + Handle = $shell.BeginInvoke() + }) + } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $results = [System.Collections.Generic.List[object]]::new() + + foreach ($task in $running) { + $output = $null + $failure = $null + + $remaining = [int]([math]::Max(0, ($deadline - (Get-Date)).TotalMilliseconds)) + + if ($remaining -le 0 -or -not $task.Handle.AsyncWaitHandle.WaitOne($remaining)) { + try { $task.Shell.Stop() } catch { } + $failure = "Timed out after $TimeoutSeconds seconds." + } + else { + try { + $output = $task.Shell.EndInvoke($task.Handle) + + $streamErrors = @($task.Shell.Streams.Error) + if ($streamErrors.Count -gt 0 -and $null -eq $output) { + $failure = $streamErrors[0] + } + } + catch { + $failure = $_ + } + } + + $results.Add([pscustomobject]@{ + Index = $task.Index + Input = $task.Input + Output = $output + Error = $failure + }) + } + + $results + } + finally { + foreach ($task in $running) { + try { $task.Shell.Dispose() } catch { } + } + try { $pool.Close(); $pool.Dispose() } catch { } + } +} diff --git a/src/PCTools/Private/Invoke-PCProcess.ps1 b/src/PCTools/Private/Invoke-PCProcess.ps1 index de577e2..dcd0acf 100644 --- a/src/PCTools/Private/Invoke-PCProcess.ps1 +++ b/src/PCTools/Private/Invoke-PCProcess.ps1 @@ -10,7 +10,12 @@ function Invoke-PCProcess { through this, so a hung tool surfaces as a timeout instead of freezing the caller forever. - stdout and stderr are captured to temp files and always cleaned up. + stdout and stderr are read straight from the pipes rather than being + redirected through two temp files. The old version created two files per + call with GetTempFileName, wrote the output to disk, read it back and + deleted them - a disk round-trip per external command, which a full + network report paid six times over for output it only ever formats into + a string. .PARAMETER FilePath The executable to run. @@ -27,6 +32,9 @@ function Invoke-PCProcess { Output beyond this is truncated, so a chatty tool cannot exhaust memory or make a report unreadable. + .PARAMETER WorkingDirectory + Directory to start the process in. Defaults to the current location. + .OUTPUTS pscustomobject with ExitCode, Output, TimedOut and Duration. #> @@ -40,27 +48,78 @@ function Invoke-PCProcess { [int]$TimeoutSeconds = 60, - [int]$MaxOutputChars = 100000 + [int]$MaxOutputChars = 100000, + + [string]$WorkingDirectory ) - $stdoutFile = [System.IO.Path]::GetTempFileName() - $stderrFile = [System.IO.Path]::GetTempFileName() $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $timedOut = $false $exitCode = -1 + $stdout = '' + $stderr = '' + + $process = [System.Diagnostics.Process]::new() + $startInfo = $process.StartInfo + $startInfo.FileName = $FilePath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + + if ($ArgumentList.Count -gt 0) { + if ($startInfo.PSObject.Properties.Name -contains 'ArgumentList') { + # PowerShell 6+ / .NET Core: the runtime does the quoting, correctly. + foreach ($argument in $ArgumentList) { $startInfo.ArgumentList.Add([string]$argument) } + } + else { + # Windows PowerShell 5.1 has only the single Arguments string, so the + # quoting is ours to get right. Wrapping anything containing a space + # in quotes is the obvious approach and it is wrong: an argument that + # already contains a quote comes out mangled. These are the escaping + # rules CommandLineToArgvW actually applies - double the run of + # backslashes before a quote, escape the quote, and double a trailing + # run before the closing quote. + $startInfo.Arguments = ($ArgumentList | ForEach-Object { + $argument = [string]$_ + + if ($argument -eq '') { + '""' + } + elseif ($argument -notmatch '[\s"]') { + $argument + } + else { + $escaped = [regex]::Replace($argument, '(\\*)"', '$1$1\"') + $escaped = [regex]::Replace($escaped, '(\\+)$', '$1$1') + '"' + $escaped + '"' + } + }) -join ' ' + } + } + + if ($WorkingDirectory) { $startInfo.WorkingDirectory = $WorkingDirectory } try { - $startParams = @{ - FilePath = $FilePath - PassThru = $true - WindowStyle = 'Hidden' - RedirectStandardOutput = $stdoutFile - RedirectStandardError = $stderrFile - ErrorAction = 'Stop' + try { + [void]$process.Start() + } + catch { + $stopwatch.Stop() + return [pscustomobject]@{ + FilePath = $FilePath + ExitCode = -1 + Output = "Could not start ${FilePath}: $($_.Exception.Message)" + TimedOut = $false + Duration = $stopwatch.Elapsed + } } - if ($ArgumentList.Count -gt 0) { $startParams['ArgumentList'] = $ArgumentList } - $process = Start-Process @startParams + # Both streams are drained concurrently by the framework. Reading one to + # the end before the other is the classic redirect deadlock: a tool that + # fills the stderr pipe blocks forever while we wait on stdout. + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { $timedOut = $true @@ -68,13 +127,20 @@ function Invoke-PCProcess { Write-PCLog -Level WARN -Message "$FilePath timed out after $TimeoutSeconds seconds and was terminated." } else { + # The parameterless overload waits for the redirected streams to + # finish flushing; the timed one does not, and without it the tail + # of a chatty tool's output is lost. + try { $process.WaitForExit() } catch { } $exitCode = $process.ExitCode } - $stdout = '' - $stderr = '' - try { $stdout = [System.IO.File]::ReadAllText($stdoutFile) } catch { } - try { $stderr = [System.IO.File]::ReadAllText($stderrFile) } catch { } + # Killing the process closes the pipes, so these complete either way. + # The bound is belt and braces against a grandchild process inheriting + # the handles and holding them open. + try { [void][System.Threading.Tasks.Task]::WaitAll(@($stdoutTask, $stderrTask), 5000) } catch { } + + if ($stdoutTask.IsCompleted -and -not $stdoutTask.IsFaulted) { $stdout = [string]$stdoutTask.Result } + if ($stderrTask.IsCompleted -and -not $stderrTask.IsFaulted) { $stderr = [string]$stderrTask.Result } $output = ($stdout, $stderr | Where-Object { $_ } | ForEach-Object { $_.Trim() }) -join [Environment]::NewLine $output = $output.Trim() @@ -97,8 +163,6 @@ function Invoke-PCProcess { } finally { $stopwatch.Stop() - foreach ($file in @($stdoutFile, $stderrFile)) { - try { Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue } catch { } - } + try { $process.Dispose() } catch { } } } diff --git a/src/PCTools/Private/Set-PCStartupApproval.ps1 b/src/PCTools/Private/Set-PCStartupApproval.ps1 new file mode 100644 index 0000000..0fa490f --- /dev/null +++ b/src/PCTools/Private/Set-PCStartupApproval.ps1 @@ -0,0 +1,68 @@ +function Set-PCStartupApproval { + <# + .SYNOPSIS + Writes the StartupApproved flag that enables or disables a startup item. + + .DESCRIPTION + Task Manager does not delete a Run key entry when you switch it off. It + writes a 12-byte value under StartupApproved whose first byte carries + the flag - even for enabled, odd for disabled - and leaves the entry + alone. Following that convention rather than inventing one means a user + can re-enable in Task Manager something disabled here, and the reverse, + with neither surprising the other. + + The remaining bytes are a timestamp Windows writes and does not require + from us; zeroes are accepted. + + .PARAMETER Name + The Run key value name, or the Startup folder file name. + + .PARAMETER Source + Registry or Folder - they use different StartupApproved subkeys. + + .PARAMETER Scope + Which hive the item came from. Anything starting with 'AllUsers' means + HKLM, which needs elevation. + + .PARAMETER Enabled + The state to write. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([void])] + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [ValidateSet('Registry', 'Folder')] + [string]$Source, + + [Parameter(Mandatory)] + [string]$Scope, + + [Parameter(Mandatory)] + [bool]$Enabled + ) + + $hive = if ($Scope -like 'AllUsers*') { 'HKLM:' } else { 'HKCU:' } + $leaf = if ($Source -eq 'Folder') { 'StartupFolder' } else { 'Run' } + $key = "$hive\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\$leaf" + + if ($hive -eq 'HKLM:' -and -not (Test-PCAdmin)) { + throw "Changing an all-users startup item requires an elevated session." + } + + if (-not (Test-Path -LiteralPath $key)) { + if (-not $PSCmdlet.ShouldProcess($key, 'Create')) { return } + New-Item -Path $key -Force | Out-Null + } + + # 02 = enabled, 03 = disabled. Windows writes 02/03 followed by a FILETIME; + # it reads back only the first byte, so a zeroed tail is accepted. + $flag = if ($Enabled) { [byte]2 } else { [byte]3 } + $value = [byte[]]@($flag, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + + if (-not $PSCmdlet.ShouldProcess("$key\$Name", $(if ($Enabled) { 'Enable' } else { 'Disable' }))) { return } + + Set-ItemProperty -LiteralPath $key -Name $Name -Value $value -Type Binary -Force +} diff --git a/src/PCTools/Private/Write-PCHtmlReport.ps1 b/src/PCTools/Private/Write-PCHtmlReport.ps1 new file mode 100644 index 0000000..863f91d --- /dev/null +++ b/src/PCTools/Private/Write-PCHtmlReport.ps1 @@ -0,0 +1,182 @@ +function Write-PCHtmlReport { + <# + .SYNOPSIS + Renders a report as a single self-contained HTML file. + + .DESCRIPTION + The format to send somebody. Everything is inline - no stylesheet, no + webfont, no script - so the file renders identically on a machine with + no network, survives being emailed, and cannot phone anywhere. + + ConvertTo-Html is deliberately not used. It produces a table per object + type with no styling and no summary, which is a worse artifact than the + plain text export it would be replacing. + + Every value is HTML-escaped on the way in. Action detail strings carry + file paths and error messages from the system, and one containing a + stray angle bracket must not be able to break the document. + + .PARAMETER Payload + The report payload assembled by Export-PCReport. + + .PARAMETER ActionResult + The action results within it, already filtered. + + .PARAMETER Path + Where to write the file. + #> + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory)] + $Payload, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$ActionResult, + + [Parameter(Mandatory)] + [string]$Path + ) + + $escape = { + param($Value) + if ($null -eq $Value) { return '' } + [System.Net.WebUtility]::HtmlEncode([string]$Value) + } + + $summary = Get-PCResultSummary -Result $ActionResult + + $html = [System.Text.StringBuilder]::new() + + [void]$html.AppendLine('') + [void]$html.AppendLine('') + [void]$html.AppendLine('') + [void]$html.AppendLine("PC Tools report - $(& $escape $Payload.ComputerName)") + + # Colours are defined for both schemes so the file reads correctly whether + # the recipient's browser is in light or dark mode. + [void]$html.AppendLine(@' +
+'@) + + [void]$html.AppendLine('

PC Tools report

') + [void]$html.AppendLine("

$(& $escape $Payload.ComputerName) · $(& $escape $Payload.Generated)

") + + if ($ActionResult.Count -gt 0) { + [void]$html.AppendLine("
$(& $escape $summary.Text)
") + } + + [void]$html.AppendLine('

Machine

') + foreach ($pair in @( + @{ Label = 'Computer'; Value = $Payload.ComputerName } + @{ Label = 'User'; Value = $Payload.User } + @{ Label = 'Elevated'; Value = $Payload.IsAdmin } + @{ Label = 'Generated'; Value = $Payload.Generated } + @{ Label = 'PC Tools'; Value = $Payload.Version } + )) { + [void]$html.AppendLine("
$(& $escape $pair.Label)$(& $escape $pair.Value)
") + } + [void]$html.AppendLine('
') + + if ($ActionResult.Count -gt 0) { + [void]$html.AppendLine('

Actions

') + [void]$html.AppendLine('') + + foreach ($result in $ActionResult) { + $status = & $escape $result.Status + $freed = if ($result.BytesFreed -gt 0) { & $escape $result.FreedDisplay } else { '' } + $took = if ($result.Duration) { '{0:n1}s' -f $result.Duration.TotalSeconds } else { '' } + + # The whole -f expression needs its own parentheses. Inside a method + # call, commas separate method arguments, so without them the format + # operator receives only its first value and everything after it is + # passed to AppendLine as extra arguments. + $row = ( + ('' + + '') -f + $status, (& $escape $result.Action), $freed, (& $escape $took), (& $escape $result.Detail) + ) + [void]$html.AppendLine($row) + } + + [void]$html.AppendLine('
StatusActionReclaimedTookDetail
{0}{1}{2}{3}{4}
') + } + + foreach ($report in @($Payload.Items | Where-Object { $_.PSObject.TypeNames -contains 'PCTools.NetworkReport' })) { + [void]$html.AppendLine('

Network

') + [void]$html.AppendLine("
$(& $escape $report.Verdict.Verdict)
") + [void]$html.AppendLine("

$(& $escape $report.Verdict.Advice)

") + + [void]$html.AppendLine('
') + [void]$html.AppendLine('') + + foreach ($test in @($report.Tests)) { + $state = if ($test.Success) { 'Success' } else { 'Failed' } + $label = if ($test.Success) { 'OK' } else { 'Fail' } + $latency = if ($null -ne $test.LatencyMs) { '{0} ms' -f $test.LatencyMs } else { '' } + + $row = ( + ('' + + '') -f + (& $escape $test.Layer), (& $escape $test.Target), $state, $label, + (& $escape $latency), (& $escape $test.Detail) + ) + [void]$html.AppendLine($row) + } + + [void]$html.AppendLine('
LayerTargetResultLatencyDetail
{0}{1}{3}{4}{5}
') + + if ($report.Full) { + foreach ($property in $report.Full.PSObject.Properties) { + [void]$html.AppendLine("

$(& $escape $property.Name)

") + [void]$html.AppendLine("
$(& $escape $property.Value)
") + } + } + } + + [void]$html.AppendLine('
Generated by PC Tools. No data left this machine to produce this file.
') + [void]$html.AppendLine('
') + + Set-Content -LiteralPath $Path -Value $html.ToString() -Encoding UTF8 +} diff --git a/src/PCTools/Private/Write-PCLog.ps1 b/src/PCTools/Private/Write-PCLog.ps1 index d6afea6..24069d7 100644 --- a/src/PCTools/Private/Write-PCLog.ps1 +++ b/src/PCTools/Private/Write-PCLog.ps1 @@ -27,6 +27,8 @@ function Write-PCLog { [string]$Level = 'INFO' ) + if (-not $script:PCLogReady) { Initialize-PCLog } + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $line = '[{0}] [{1}] {2}' -f $timestamp, $Level, $Message diff --git a/src/PCTools/Public/Automation/Get-PCExtension.ps1 b/src/PCTools/Public/Automation/Get-PCExtension.ps1 new file mode 100644 index 0000000..00ba4c2 --- /dev/null +++ b/src/PCTools/Public/Automation/Get-PCExtension.ps1 @@ -0,0 +1,85 @@ +function Get-PCExtension { + <# + .SYNOPSIS + Lists extension actions, loaded and available. + + .DESCRIPTION + Shows what Register-PCExtension has loaded into this session, and what + is sitting in the extensions folder waiting to be loaded. The + distinction matters: a file being present does not mean its actions are + callable, and an action that a profile references but that was never + loaded is the likeliest reason a custom profile fails. + + .PARAMETER IncludeAvailable + Also list files present on disk that have not been loaded. On by + default. + + .EXAMPLE + Get-PCExtension | Format-Table Name, Loaded, File + + .OUTPUTS + PCTools.Extension + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [bool]$IncludeAvailable = $true + ) + + foreach ($key in @($script:PCExtension.Keys | Sort-Object)) { + $entry = $script:PCExtension[$key] + [pscustomobject]@{ + PSTypeName = 'PCTools.Extension' + Name = $entry.Name + Loaded = $true + File = $entry.File + LoadedAt = $entry.LoadedAt + } + } + + if (-not $IncludeAvailable) { return } + + $root = $script:PCExtensionDirectory + if (-not (Test-Path -LiteralPath $root)) { return } + + foreach ($file in @(Get-ChildItem -LiteralPath $root -Filter '*.PCAction.ps1' -File -Recurse)) { + $alreadyLoaded = @($script:PCExtension.Values | Where-Object { $_.File -eq $file.FullName }) + if ($alreadyLoaded.Count -gt 0) { continue } + + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$null, [ref]$parseErrors) + + # @() around the whole expression: an `if` that yields a one-element + # array assigns a scalar, and .Count on it below then throws under + # strict mode. One action per file is the normal case. + $functions = @(if ($parseErrors) { + @() + } + else { + @($ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $false) | ForEach-Object { $_.Name } | Where-Object { $_ -match '^\w+-PC\w+$' }) + }) + + foreach ($name in $functions) { + [pscustomobject]@{ + PSTypeName = 'PCTools.Extension' + Name = $name + Loaded = $false + File = $file.FullName + LoadedAt = $null + } + } + + if ($functions.Count -eq 0) { + [pscustomobject]@{ + PSTypeName = 'PCTools.Extension' + Name = "($($file.Name): $(if ($parseErrors) { 'parse errors' } else { 'no PC actions' }))" + Loaded = $false + File = $file.FullName + LoadedAt = $null + } + } + } +} diff --git a/src/PCTools/Public/Automation/Get-PCHistory.ps1 b/src/PCTools/Public/Automation/Get-PCHistory.ps1 new file mode 100644 index 0000000..cf6e8d8 --- /dev/null +++ b/src/PCTools/Public/Automation/Get-PCHistory.ps1 @@ -0,0 +1,121 @@ +function Get-PCHistory { + <# + .SYNOPSIS + Reads past maintenance runs, and the trend across them. + + .DESCRIPTION + Reads what Save-PCHistory recorded. With -Summary it reduces the runs to + a trend instead of listing them: how much has been reclaimed in total, + how much a typical run reclaims, and whether anything has been failing. + + A corrupt or partly written entry is skipped rather than throwing. The + history is a convenience and one bad file should not cost the rest of + it. + + .PARAMETER Last + How many runs to return, newest first. + + .PARAMETER ProfileName + Only runs of this profile. + + .PARAMETER Since + Only runs after this time. + + .PARAMETER Summary + Return the trend rather than the individual runs. + + .EXAMPLE + Get-PCHistory -Last 10 | Format-Table Timestamp, ProfileName, Succeeded, Failed, BytesFreed + + .EXAMPLE + Get-PCHistory -Summary + + .OUTPUTS + PCTools.HistoryEntry, or PCTools.HistorySummary with -Summary. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [ValidateRange(1, 1000)] + [int]$Last = 25, + + [string]$ProfileName, + + [datetime]$Since, + + [switch]$Summary + ) + + $entries = [System.Collections.Generic.List[object]]::new() + + if (Test-Path -LiteralPath $script:PCHistoryDirectory) { + $files = Get-ChildItem -LiteralPath $script:PCHistoryDirectory -Filter 'run-*.json' -File | + Sort-Object Name -Descending + + foreach ($file in $files) { + if (-not $Summary -and $entries.Count -ge $Last) { break } + + try { + $entry = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + } + catch { + Write-PCLog -Level DEBUG -Message "Skipping unreadable history entry $($file.Name): $($_.Exception.Message)" + continue + } + + if ($ProfileName -and $entry.ProfileName -ne $ProfileName) { continue } + if ($PSBoundParameters.ContainsKey('Since') -and [datetime]$entry.Timestamp -lt $Since) { continue } + + $entries.Add($entry) + } + } + + if (-not $Summary) { + return @($entries | Select-Object -First $Last) + } + + $all = @($entries) + + if ($all.Count -eq 0) { + return [pscustomobject]@{ + PSTypeName = 'PCTools.HistorySummary' + Runs = 0 + FirstRun = $null + LastRun = $null + TotalFreed = 0L + TotalDisplay = Format-PCByteSize -Bytes 0 + AverageFreed = 0L + AverageDisplay = Format-PCByteSize -Bytes 0 + FailedRuns = 0 + Text = 'No maintenance runs have been recorded yet.' + } + } + + $totalFreed = 0L + foreach ($entry in $all) { $totalFreed += [long]$entry.BytesFreed } + + $average = [long]($totalFreed / $all.Count) + $failedRuns = @($all | Where-Object { [int]$_.Failed -gt 0 }).Count + + $first = ($all | Sort-Object { [datetime]$_.Timestamp } | Select-Object -First 1) + $lastRun = ($all | Sort-Object { [datetime]$_.Timestamp } -Descending | Select-Object -First 1) + + $text = '{0} run(s) recorded, {1} reclaimed in total, {2} on a typical run.' -f + $all.Count, (Format-PCByteSize -Bytes $totalFreed), (Format-PCByteSize -Bytes $average) + if ($failedRuns -gt 0) { + $text += ' {0} run(s) had a failed action.' -f $failedRuns + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.HistorySummary' + Runs = $all.Count + FirstRun = [datetime]$first.Timestamp + LastRun = [datetime]$lastRun.Timestamp + TotalFreed = $totalFreed + TotalDisplay = Format-PCByteSize -Bytes $totalFreed + AverageFreed = $average + AverageDisplay = Format-PCByteSize -Bytes $average + FailedRuns = $failedRuns + Text = $text + } +} diff --git a/src/PCTools/Public/Automation/Get-PCScheduledMaintenance.ps1 b/src/PCTools/Public/Automation/Get-PCScheduledMaintenance.ps1 new file mode 100644 index 0000000..a0722c5 --- /dev/null +++ b/src/PCTools/Public/Automation/Get-PCScheduledMaintenance.ps1 @@ -0,0 +1,81 @@ +function Get-PCScheduledMaintenance { + <# + .SYNOPSIS + Shows the scheduled maintenance tasks this module has registered. + + .DESCRIPTION + Answers the question the dashboard asks on startup: is anything + scheduled, when does it next run, and did the last run work. + + Only tasks registered by Register-PCScheduledMaintenance are reported - + matched on the action actually invoking Invoke-PCTools, so a task + someone renamed is still found and an unrelated task that happens to + share a name is not. + + .PARAMETER TaskName + Limit to a specific task name. + + .EXAMPLE + Get-PCScheduledMaintenance | Format-Table TaskName, ProfileName, NextRunTime, LastResult + + .OUTPUTS + PCTools.ScheduledMaintenance + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string]$TaskName + ) + + $tasks = @() + try { + $tasks = @(Get-ScheduledTask -ErrorAction Stop) + } + catch { + Write-PCLog -Level DEBUG -Message "Could not enumerate scheduled tasks: $($_.Exception.Message)" + return + } + + foreach ($task in $tasks) { + if ($TaskName -and $task.TaskName -ne $TaskName) { continue } + + $arguments = '' + try { + $arguments = (@($task.Actions | ForEach-Object { $_.Arguments }) -join ' ') + } + catch { continue } + + if ($arguments -notmatch 'Invoke-PCTools') { continue } + + $profileName = '' + if ($arguments -match "-ProfileName\s+'([^']+)'") { $profileName = $Matches[1] } + + $info = $null + try { $info = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction Stop } catch { } + + # 267011 is "task has not yet run", which is not a failure. + $lastResult = if ($info) { [int]$info.LastTaskResult } else { $null } + $resultText = switch ($lastResult) { + $null { 'Unknown' } + 0 { 'Succeeded' } + 1 { 'Completed with warnings' } + 2 { 'An action failed' } + 3010 { 'Succeeded; restart required' } + 267011 { 'Has not run yet' } + default { "Exit code $lastResult" } + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.ScheduledMaintenance' + TaskName = $task.TaskName + TaskPath = $task.TaskPath + ProfileName = $profileName + State = [string]$task.State + Description = $task.Description + NextRunTime = if ($info) { $info.NextRunTime } else { $null } + LastRunTime = if ($info) { $info.LastRunTime } else { $null } + LastResult = $resultText + LastExitCode = $lastResult + } + } +} diff --git a/src/PCTools/Public/Automation/Register-PCExtension.ps1 b/src/PCTools/Public/Automation/Register-PCExtension.ps1 new file mode 100644 index 0000000..867e89d --- /dev/null +++ b/src/PCTools/Public/Automation/Register-PCExtension.ps1 @@ -0,0 +1,167 @@ +function Register-PCExtension { + <# + .SYNOPSIS + Loads custom actions from disk so they can be used like built-in ones. + + .DESCRIPTION + Everything in this module is a function returning a PCTools.ActionResult, + and Invoke-PCMaintenance resolves actions by name. That makes a plugin + model almost free: define a function of the same shape, drop it in + %LOCALAPPDATA%\PCTools\extensions as a *.PCAction.ps1 file, and it can + be named in a profile alongside Clear-PCTempFile. + + Understand what this is. An extension is a PowerShell script that runs + in your session with whatever rights that session has - it is code you + are choosing to run, exactly like any other script. This does not + sandbox it and cannot. The checks it does make are about catching + mistakes, not about containing hostile code: + + - the file must be under the extensions folder + - it must parse + - it must define at least one Verb-PCNoun function + - a name that collides with a built-in is refused, so an extension + cannot silently replace Clear-PCTempFile with its own version + + Only load extensions you wrote or would be willing to read. + + Where an extension action can be used: name it in a maintenance profile, + or pass it to Invoke-PCMaintenance -Action. It is deliberately not + exported as a top-level command - it is loaded into the module's scope so + it can call Invoke-PCAction and the other private helpers, which is what + makes its results the same shape as a built-in's, and a module cannot + export a function after it has finished loading. + + .PARAMETER Path + A specific extension file to load. Defaults to every *.PCAction.ps1 in + the extensions folder. + + .PARAMETER PassThru + Return the loaded actions. + + .EXAMPLE + Register-PCExtension -PassThru + + .EXAMPLE + Register-PCExtension + Invoke-PCMaintenance -Action 'Invoke-PCMyBackup' + + Running one extension action directly. + + .EXAMPLE + Get-PCExtension | Format-Table Name, File + + .OUTPUTS + PCTools.Extension when -PassThru is given. + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + [OutputType([pscustomobject])] + param( + [string[]]$Path, + + [switch]$PassThru + ) + + $root = $script:PCExtensionDirectory + + # Wrapped in @(): assigning the result of an `if` unrolls a one-element + # array to a scalar, and then .Count below is a strict-mode error rather + # than 1. A single extension file is the common case, so this was not a + # corner to worry about but the normal path. + $files = @(if ($Path) { + @($Path | ForEach-Object { + if (-not (Test-Path -LiteralPath $_)) { throw "No such extension file: $_" } + (Resolve-Path -LiteralPath $_).ProviderPath + }) + } + elseif (Test-Path -LiteralPath $root) { + @(Get-ChildItem -LiteralPath $root -Filter '*.PCAction.ps1' -File -Recurse | + ForEach-Object { $_.FullName }) + } + else { + @() + }) + + if ($files.Count -eq 0) { + Write-PCLog -Level DEBUG -Message "No extensions found in $root" + return + } + + $builtIn = @((Get-Module $script:ModuleName).ExportedFunctions.Keys) + $loaded = [System.Collections.Generic.List[object]]::new() + + foreach ($file in $files) { + # Refuse anything outside the extensions folder unless it was named + # explicitly, so a stray path in a config file cannot pull in a script + # from somewhere unexpected. + if (-not $Path) { + $resolvedRoot = (Resolve-Path -LiteralPath $root).ProviderPath + if (-not $file.StartsWith($resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-PCLog -Level WARN -Message "Skipping extension outside the extensions folder: $file" + continue + } + } + + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$null, [ref]$parseErrors) + + if ($parseErrors) { + Write-PCLog -Level WARN -Message "Extension $file has $($parseErrors.Count) parse error(s) and was not loaded." + continue + } + + $definitions = @($ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] + }, $false) | Where-Object { $_.Name -match '^\w+-PC\w+$' }) + + $functions = @($definitions | ForEach-Object { $_.Name }) + + if ($functions.Count -eq 0) { + Write-PCLog -Level WARN -Message "Extension $file defines no Verb-PCNoun function and was not loaded." + continue + } + + $collision = @($functions | Where-Object { $builtIn -contains $_ }) + if ($collision.Count -gt 0) { + Write-PCLog -Level WARN -Message "Extension $file would replace built-in command(s): $($collision -join ', '). Not loaded." + continue + } + + if (-not $PSCmdlet.ShouldProcess($file, "Load extension defining $($functions -join ', ')")) { continue } + + foreach ($definition in $definitions) { + # The function's own body, as a scriptblock built here. Two reasons + # rather than dot-sourcing the file: + # + # - Dot-sourcing from inside a function defines the function in that + # function's scope, which is gone the moment this returns. The + # extension appeared to load and was then uncallable. + # - A scriptblock created here belongs to the module's session + # state, so an extension action can call Invoke-PCAction and the + # other private helpers. That is what makes its result the same + # shape as a built-in's. + $command = $null + try { + $command = $definition.Body.GetScriptBlock() + } + catch { + Write-PCLog -Level WARN -Message "Extension action $($definition.Name) could not be prepared: $($_.Exception.Message)" + continue + } + + $entry = [pscustomobject]@{ + PSTypeName = 'PCTools.Extension' + Name = $definition.Name + File = $file + LoadedAt = Get-Date + Command = $command + } + + $script:PCExtension[$definition.Name] = $entry + $loaded.Add($entry) + Write-PCLog -Level INFO -Message "Extension action registered: $($definition.Name) (from $(Split-Path -Leaf $file))" + } + } + + if ($PassThru) { $loaded } +} diff --git a/src/PCTools/Public/Automation/Register-PCScheduledMaintenance.ps1 b/src/PCTools/Public/Automation/Register-PCScheduledMaintenance.ps1 new file mode 100644 index 0000000..81b48d9 --- /dev/null +++ b/src/PCTools/Public/Automation/Register-PCScheduledMaintenance.ps1 @@ -0,0 +1,177 @@ +function Register-PCScheduledMaintenance { + <# + .SYNOPSIS + Schedules a maintenance profile to run on its own. + + .DESCRIPTION + Maintenance that depends on somebody remembering to run it is + maintenance that stops happening. This registers a Windows scheduled + task that calls Invoke-PCTools with a profile, writes a JSON report and + records the run in the history that Get-PCHistory reads. + + The task runs exactly the code an interactive run does. There is no + separate unattended path to drift out of step, which also means -WhatIf + here previews the real thing. + + Destructive profiles are refused. A scheduled task runs with nobody + watching, and Full - which repairs the component store and can demand a + restart - is not something to start unattended at 3am. Quick, + Recommended, Health and Storage are allowed; anything else has to be + run deliberately. + + .PARAMETER ProfileName + The maintenance profile to run. Must be one of the profiles allowed for + unattended use. + + .PARAMETER Frequency + Daily, Weekly or Monthly. + + .PARAMETER At + Time of day to run. Defaults to 03:00. + + .PARAMETER DayOfWeek + For a weekly schedule. Defaults to Sunday. + + .PARAMETER TaskName + The scheduled task name. Defaults to 'PCTools Maintenance'. + + .PARAMETER ReportPath + Where reports are written. Defaults to %LOCALAPPDATA%\PCTools\reports. + + .PARAMETER RunWhenIdleOnly + Only run when the machine is idle, and stop if it stops being idle. + + .EXAMPLE + Register-PCScheduledMaintenance -ProfileName Quick -Frequency Weekly -WhatIf + + .EXAMPLE + Register-PCScheduledMaintenance -ProfileName Recommended -Frequency Monthly -At 02:30 + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [OutputType([pscustomobject])] + param( + [Parameter(Position = 0)] + [string]$ProfileName = 'Quick', + + [ValidateSet('Daily', 'Weekly', 'Monthly')] + [string]$Frequency = 'Weekly', + + [datetime]$At = ([datetime]'03:00'), + + [ValidateSet('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday')] + [string]$DayOfWeek = 'Sunday', + + [string]$TaskName = 'PCTools Maintenance', + + [string]$ReportPath, + + [switch]$RunWhenIdleOnly + ) + + Invoke-PCAction -Name 'Register-PCScheduledMaintenance' -Body { + Assert-PCAdmin -Action 'Registering a scheduled task' + + # Unattended means nobody is there to answer a prompt or to notice a + # restart request, so the destructive profiles are not on offer. + $allowed = @('Quick', 'Recommended', 'Health', 'Storage') + if ($allowed -notcontains $ProfileName) { + return @{ + Status = 'Failed' + Detail = ("'{0}' is not allowed on a schedule. Unattended runs are limited to: {1}. " + + "Profiles that repair the component store or reset the network need somebody present.") -f + $ProfileName, ($allowed -join ', ') + } + } + + # Fail here rather than at 3am on a Sunday. + $null = Get-PCMaintenanceProfile -Name $ProfileName + + if (-not $ReportPath) { $ReportPath = Join-Path $script:PCDataRoot 'reports' } + + $moduleBase = Split-Path -Parent $PSScriptRoot + $moduleBase = Split-Path -Parent $moduleBase + $manifest = Join-Path $moduleBase 'PCTools.psd1' + + # Import by path rather than by name: the task runs as SYSTEM or as a + # service account whose module path may not include a per-user install. + $importExpression = if (Test-Path -LiteralPath $manifest) { + "Import-Module '$manifest'" + } + else { + 'Import-Module PCTools' + } + + $command = "$importExpression; Invoke-PCTools -ProfileName '$ProfileName' -Report Json -ReportPath '$ReportPath' -Quiet -Exit" + + $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command `"$command`"" + + $trigger = switch ($Frequency) { + 'Daily' { New-ScheduledTaskTrigger -Daily -At $At } + 'Weekly' { New-ScheduledTaskTrigger -Weekly -At $At -DaysOfWeek $DayOfWeek } + 'Monthly' { + # New-ScheduledTaskTrigger has no monthly option, so a weekly + # trigger is built and its interval widened through the CIM + # object it produces. + $weekly = New-ScheduledTaskTrigger -Weekly -At $At -DaysOfWeek $DayOfWeek -WeeksInterval 4 + $weekly + } + } + + $settingsParams = @{ + StartWhenAvailable = $true + DontStopOnIdleEnd = $true + ExecutionTimeLimit = ([timespan]::FromHours(3)) + MultipleInstances = 'IgnoreNew' + AllowStartIfOnBatteries = $false + DontStopIfGoingOnBatteries = $false + } + if ($RunWhenIdleOnly) { + $settingsParams['RunOnlyIfIdle'] = $true + $settingsParams['IdleDuration'] = ([timespan]::FromMinutes(10)) + $settingsParams['IdleWaitTimeout'] = ([timespan]::FromHours(2)) + } + + $settings = New-ScheduledTaskSettingsSet @settingsParams + + # SYSTEM so the repair and cache actions have the rights they need + # without storing anybody's password. + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + + $description = "PC Tools '$ProfileName' maintenance, $($Frequency.ToLower()) at $($At.ToString('HH:mm'))." + + if (-not $PSCmdlet.ShouldProcess($TaskName, "Register a $Frequency scheduled task running the $ProfileName profile")) { + return @{ + Status = 'Skipped' + Detail = "Would register '$TaskName': $description" + Data = @{ Command = $command } + } + } + + $existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($existing) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + } + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` + -Settings $settings -Principal $principal -Description $description -Force | Out-Null + + $next = $null + try { $next = (Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction Stop).NextRunTime } catch { } + + @{ + Detail = "'$TaskName' registered: $description$(if ($next) { " Next run $next." })" + Data = @{ + TaskName = $TaskName + ProfileName = $ProfileName + Frequency = $Frequency + NextRunTime = $next + ReportPath = $ReportPath + Replaced = [bool]$existing + } + } + } +} diff --git a/src/PCTools/Public/Automation/Save-PCHistory.ps1 b/src/PCTools/Public/Automation/Save-PCHistory.ps1 new file mode 100644 index 0000000..0762816 --- /dev/null +++ b/src/PCTools/Public/Automation/Save-PCHistory.ps1 @@ -0,0 +1,130 @@ +function Save-PCHistory { + <# + .SYNOPSIS + Records a maintenance run so it can be compared with later ones. + + .DESCRIPTION + A single run tells you it reclaimed 2 GB. Ten runs tell you this machine + generates 2 GB of rubbish a week, which is a different and more useful + fact - and the one that makes a schedule worth setting up. + + One JSON file per run under %LOCALAPPDATA%\PCTools\history. Files, not a + database, because they can be read with anything, deleted individually, + and never need a migration. + + Only the summary is kept, not the full results: the point is trends, and + an unbounded store of every detail of every run is a liability rather + than a feature. Old entries beyond -Keep are pruned on each write. + + .PARAMETER Result + The action results to record. + + .PARAMETER ProfileName + The profile that produced them. + + .PARAMETER Keep + How many runs to retain. + + .EXAMPLE + Invoke-PCMaintenance -ProfileName Quick | Save-PCHistory -ProfileName Quick + + .OUTPUTS + pscustomobject describing the saved entry. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [AllowEmptyCollection()] + [object[]]$Result, + + [string]$ProfileName = 'Custom', + + [ValidateRange(1, 1000)] + [int]$Keep = 100 + ) + + begin { + $collected = [System.Collections.Generic.List[object]]::new() + } + + process { + foreach ($item in $Result) { if ($null -ne $item) { $collected.Add($item) } } + } + + end { + $summary = Get-PCResultSummary -Result @($collected) + if ($summary.Total -eq 0) { return } + + try { + if (-not (Test-Path -LiteralPath $script:PCHistoryDirectory)) { + New-Item -ItemType Directory -Path $script:PCHistoryDirectory -Force | Out-Null + } + } + catch { + Write-PCLog -Level WARN -Message "Could not create the history folder: $($_.Exception.Message)" + return + } + + $stamp = Get-Date + $path = Join-Path $script:PCHistoryDirectory ('run-{0:yyyyMMdd-HHmmss}.json' -f $stamp) + + # Two runs finishing in the same second would otherwise share a name and + # the second would silently replace the first. A short suffix keeps both, + # and keeps the name sortable, which is what the pruning relies on. + if (Test-Path -LiteralPath $path) { + for ($suffix = 1; $suffix -le 99; $suffix++) { + $candidate = Join-Path $script:PCHistoryDirectory ('run-{0:yyyyMMdd-HHmmss}-{1:d2}.json' -f $stamp, $suffix) + if (-not (Test-Path -LiteralPath $candidate)) { $path = $candidate; break } + } + } + + if (-not $PSCmdlet.ShouldProcess($path, 'Record this run in the history')) { return } + + $entry = [pscustomobject]@{ + PSTypeName = 'PCTools.HistoryEntry' + Timestamp = $stamp + ProfileName = $ProfileName + ComputerName = $env:COMPUTERNAME + User = $env:USERNAME + Version = $script:ModuleVersion + Total = $summary.Total + Succeeded = $summary.Succeeded + Warnings = $summary.Warnings + Failed = $summary.Failed + BytesFreed = $summary.BytesFreed + RebootRequired = $summary.RebootRequired + DurationSeconds = [math]::Round($summary.Duration.TotalSeconds, 1) + Actions = @($collected | ForEach-Object { + [pscustomobject]@{ + Action = $_.Action + Status = $_.Status + BytesFreed = $_.BytesFreed + Detail = $_.Detail + } + }) + } + + try { + $entry | ConvertTo-Json -Depth 5 -WarningAction SilentlyContinue | + Set-Content -LiteralPath $path -Encoding UTF8 + } + catch { + Write-PCLog -Level WARN -Message "Could not write history: $($_.Exception.Message)" + return + } + + # Prune oldest-first so the store stays bounded without a separate + # maintenance step of its own. + try { + Get-ChildItem -LiteralPath $script:PCHistoryDirectory -Filter 'run-*.json' -File | + Sort-Object Name -Descending | + Select-Object -Skip $Keep | + Remove-Item -Force -ErrorAction SilentlyContinue + } + catch { } + + Write-PCLog -Level INFO -Message "Run recorded: $path" + $entry + } +} diff --git a/src/PCTools/Public/Automation/Unregister-PCScheduledMaintenance.ps1 b/src/PCTools/Public/Automation/Unregister-PCScheduledMaintenance.ps1 new file mode 100644 index 0000000..3579e99 --- /dev/null +++ b/src/PCTools/Public/Automation/Unregister-PCScheduledMaintenance.ps1 @@ -0,0 +1,50 @@ +function Unregister-PCScheduledMaintenance { + <# + .SYNOPSIS + Removes a scheduled maintenance task. + + .DESCRIPTION + Refuses to remove a task this module did not register. Passing the wrong + name to Unregister-ScheduledTask deletes whatever is there; this checks + the task actually invokes Invoke-PCTools first. + + .PARAMETER TaskName + The task to remove. Defaults to 'PCTools Maintenance'. + + .EXAMPLE + Unregister-PCScheduledMaintenance -WhatIf + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [OutputType([pscustomobject])] + param( + [Parameter(Position = 0)] + [string]$TaskName = 'PCTools Maintenance' + ) + + Invoke-PCAction -Name 'Unregister-PCScheduledMaintenance' -Body { + Assert-PCAdmin -Action 'Removing a scheduled task' + + $task = @(Get-PCScheduledMaintenance -TaskName $TaskName) + + if ($task.Count -eq 0) { + return @{ + Status = 'Skipped' + Detail = "No PC Tools scheduled task named '$TaskName'. Get-PCScheduledMaintenance lists the ones there are." + } + } + + if (-not $PSCmdlet.ShouldProcess($TaskName, 'Unregister the scheduled task')) { + return @{ Status = 'Skipped'; Detail = "Would remove '$TaskName'." } + } + + Unregister-ScheduledTask -TaskName $TaskName -TaskPath $task[0].TaskPath -Confirm:$false -ErrorAction Stop + + @{ + Detail = "'$TaskName' removed; the $($task[0].ProfileName) profile will no longer run on a schedule." + Data = @{ TaskName = $TaskName; ProfileName = $task[0].ProfileName } + } + } +} diff --git a/src/PCTools/Public/Export-PCReport.ps1 b/src/PCTools/Public/Export-PCReport.ps1 index c6a77bf..8491b8c 100644 --- a/src/PCTools/Public/Export-PCReport.ps1 +++ b/src/PCTools/Public/Export-PCReport.ps1 @@ -1,15 +1,22 @@ function Export-PCReport { <# .SYNOPSIS - Writes action results or a network report to disk as JSON and text. + Writes action results or a network report to disk as HTML, JSON, text or + CSV. .DESCRIPTION Generalises Net Diag's Save-Report so every tool in the project can export, not just the network one - the roadmap's "export logs to file" item. - JSON is for machines and for attaching to a support ticket. The text - rendering is for a human reading it in Notepad. + Four formats, because the audiences differ. HTML is the one to send + somebody - a single self-contained file with no external stylesheet, no + fonts and no scripts, so it renders the same on a machine with no + network as on one with. JSON is for machines and for attaching to a + ticket. Text is for reading in Notepad. CSV is for a spreadsheet. + + By default JSON and text are written, which is what this command has + always done; -Format selects otherwise. .PARAMETER InputObject PCTools.ActionResult objects, or a PCTools.NetworkReport. @@ -20,14 +27,21 @@ function Export-PCReport { .PARAMETER BaseName The file name stem. A timestamp is always appended. + .PARAMETER Format + Which formats to write: Html, Json, Text, Csv. Defaults to Json and + Text. + .EXAMPLE Invoke-PCMaintenance -ProfileName Quick | Export-PCReport .EXAMPLE Get-PCNetworkReport -Full | Export-PCReport -Path C:\Temp + .EXAMPLE + Invoke-PCMaintenance -ProfileName Full | Export-PCReport -Format Html + .OUTPUTS - pscustomobject with JsonPath and TextPath. + pscustomobject naming each file written. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] @@ -37,7 +51,10 @@ function Export-PCReport { [string]$Path, - [string]$BaseName = 'pctools-report' + [string]$BaseName = 'pctools-report', + + [ValidateSet('Html', 'Json', 'Text', 'Csv')] + [string[]]$Format = @('Json', 'Text') ) begin { @@ -65,8 +82,11 @@ function Export-PCReport { $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $jsonPath = Join-Path $Path "$BaseName-$stamp.json" $textPath = Join-Path $Path "$BaseName-$stamp.txt" + $htmlPath = Join-Path $Path "$BaseName-$stamp.html" + $csvPath = Join-Path $Path "$BaseName-$stamp.csv" - if (-not $PSCmdlet.ShouldProcess($Path, "Write report as $BaseName-$stamp.[json|txt]")) { + $extensions = ($Format | ForEach-Object { $_.ToLowerInvariant() }) -join '|' + if (-not $PSCmdlet.ShouldProcess($Path, "Write report as $BaseName-$stamp.[$extensions]")) { return } @@ -80,9 +100,11 @@ function Export-PCReport { Items = @($collected) } - # ErrorRecord does not serialise usefully and can be enormous. - $payload | ConvertTo-Json -Depth 8 -WarningAction SilentlyContinue | - Set-Content -LiteralPath $jsonPath -Encoding UTF8 + if ($Format -contains 'Json') { + # ErrorRecord does not serialise usefully and can be enormous. + $payload | ConvertTo-Json -Depth 8 -WarningAction SilentlyContinue | + Set-Content -LiteralPath $jsonPath -Encoding UTF8 + } $lines = [System.Collections.Generic.List[string]]::new() $lines.Add("$($script:ModuleName) report") @@ -101,7 +123,12 @@ function Export-PCReport { $lines.Add(('{0,-9} {1,-28} {2}' -f $result.Status, $result.Action, $result.Detail)) } - $totalFreed = ($actionResults | Measure-Object -Property BytesFreed -Sum).Sum + # Guarded for the same reason as Get-PCResultSummary: Measure-Object + # returns nothing for an empty set, and .Sum on nothing throws under + # strict mode. + $totalFreed = 0L + $measuredTotal = $actionResults | Measure-Object -Property BytesFreed -Sum + if ($measuredTotal -and $null -ne $measuredTotal.Sum) { $totalFreed = [long]$measuredTotal.Sum } $lines.Add('') $lines.Add(('Summary: {0} action(s), {1} succeeded, {2} failed, {3} reclaimed' -f $actionResults.Count, @@ -139,12 +166,40 @@ function Export-PCReport { } } - $lines | Set-Content -LiteralPath $textPath -Encoding UTF8 - Write-PCLog -Level INFO -Message "Report written to $textPath" + if ($Format -contains 'Text') { + $lines | Set-Content -LiteralPath $textPath -Encoding UTF8 + } - [pscustomobject]@{ - JsonPath = $jsonPath - TextPath = $textPath + if ($Format -contains 'Csv') { + # One flat row per action. A network report has no natural tabular + # shape, so CSV covers the action results only and says nothing + # rather than inventing columns. + if ($actionResults.Count -gt 0) { + $actionResults | + Select-Object Action, Status, Detail, BytesFreed, FreedDisplay, + @{ Name = 'DurationSeconds'; Expression = { [math]::Round($_.Duration.TotalSeconds, 2) } }, + RebootRequired, Timestamp | + Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8 + } + else { + Write-PCLog -Level WARN -Message 'CSV export skipped: there are no action results to tabulate.' + } + } + + if ($Format -contains 'Html') { + Write-PCHtmlReport -Payload $payload -ActionResult $actionResults -Path $htmlPath } + + $written = [ordered]@{} + if ($Format -contains 'Json') { $written['JsonPath'] = $jsonPath } + if ($Format -contains 'Text') { $written['TextPath'] = $textPath } + if ($Format -contains 'Html') { $written['HtmlPath'] = $htmlPath } + if ($Format -contains 'Csv' -and $actionResults.Count -gt 0) { $written['CsvPath'] = $csvPath } + + foreach ($value in $written.Values) { + Write-PCLog -Level INFO -Message "Report written to $value" + } + + [pscustomobject]$written } } diff --git a/src/PCTools/Public/Get-PCLogPath.ps1 b/src/PCTools/Public/Get-PCLogPath.ps1 index bcbadd0..6cbdf0e 100644 --- a/src/PCTools/Public/Get-PCLogPath.ps1 +++ b/src/PCTools/Public/Get-PCLogPath.ps1 @@ -12,5 +12,9 @@ function Get-PCLogPath { [OutputType([string])] param() + # The log file is prepared on first use rather than at import, so a caller + # asking where it is counts as first use. + if (-not $script:PCLogReady) { Initialize-PCLog } + $script:PCLogPath } diff --git a/src/PCTools/Public/Get-PCMaintenanceProfile.ps1 b/src/PCTools/Public/Get-PCMaintenanceProfile.ps1 index ad81f89..1772cb5 100644 --- a/src/PCTools/Public/Get-PCMaintenanceProfile.ps1 +++ b/src/PCTools/Public/Get-PCMaintenanceProfile.ps1 @@ -12,6 +12,9 @@ function Get-PCMaintenanceProfile { Note what is and is not in Recommended: Prefetch cleanup and the network stack reset are deliberately excluded, because both cost the user something and neither belongs in a preset someone runs without reading. + The same reasoning keeps Clear-PCWindowsOld, Clear-PCEventLog and + Remove-PCAppxPackage out of every profile here - each gives up something + that cannot be got back, so each has to be asked for by name. .PARAMETER Name Return only this profile. @@ -72,6 +75,44 @@ function Get-PCMaintenanceProfile { @{ Action = 'Test-PCDisk' } ) } + [pscustomobject]@{ + PSTypeName = 'PCTools.MaintenanceProfile' + Name = 'Storage' + Description = 'The deeper disk reclaims: update and delivery caches, crash dumps, thumbnails. No repairs.' + RestorePoint = $false + Actions = @( + @{ Action = 'Clear-PCTempFile' } + @{ Action = 'Clear-PCRecycleBin' } + @{ Action = 'Clear-PCBrowserCache' } + @{ Action = 'Clear-PCWindowsUpdateCache' } + @{ Action = 'Clear-PCDeliveryOptimization' } + @{ Action = 'Clear-PCCrashDump' } + @{ Action = 'Clear-PCThumbnailCache' } + ) + } + [pscustomobject]@{ + PSTypeName = 'PCTools.MaintenanceProfile' + Name = 'Health' + Description = 'Read-only. Reports how the machine is doing and changes nothing at all.' + RestorePoint = $false + Actions = @( + @{ Action = 'Test-PCDisk'; Parameters = @{ ScanOnly = $true } } + @{ Action = 'Repair-PCSystemImage'; Parameters = @{ ScanOnly = $true } } + ) + } + [pscustomobject]@{ + PSTypeName = 'PCTools.MaintenanceProfile' + Name = 'Weekly' + Description = 'The sensible default for a schedule: reclaim space, refresh DNS, no restart.' + RestorePoint = $false + Actions = @( + @{ Action = 'Clear-PCTempFile' } + @{ Action = 'Clear-PCRecycleBin' } + @{ Action = 'Clear-PCBrowserCache' } + @{ Action = 'Clear-PCDeliveryOptimization' } + @{ Action = 'Clear-PCDnsCache' } + ) + } [pscustomobject]@{ PSTypeName = 'PCTools.MaintenanceProfile' Name = 'NetworkRepair' diff --git a/src/PCTools/Public/Get-PCResultSummary.ps1 b/src/PCTools/Public/Get-PCResultSummary.ps1 new file mode 100644 index 0000000..f7a8cea --- /dev/null +++ b/src/PCTools/Public/Get-PCResultSummary.ps1 @@ -0,0 +1,98 @@ +function Get-PCResultSummary { + <# + .SYNOPSIS + Reduces a set of action results to the one line a person actually reads. + + .DESCRIPTION + "Freed 4.2 GB - 6 actions ok - 1 warning - restart required" is the + answer to "what just happened". Every host wants it and none of them + should compute it themselves: the GUI's summary bar, the CLI's closing + line and the HTML report all call this, so they cannot disagree. + + .PARAMETER Result + The PCTools.ActionResult objects to summarise. Anything else in the + pipeline is ignored, so a mixed batch of results and reports can be + piped straight in. + + .EXAMPLE + Invoke-PCMaintenance -ProfileName Quick | Get-PCResultSummary + + .EXAMPLE + (Get-PCResultSummary -Result $results).Text + 6 action(s) - 5 ok - 1 warning - 4.2 GB reclaimed + + .OUTPUTS + PCTools.ResultSummary + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowEmptyCollection()] + [object[]]$Result + ) + + begin { + $collected = [System.Collections.Generic.List[object]]::new() + } + + process { + foreach ($item in $Result) { + if ($null -eq $item) { continue } + if ($item.PSObject.TypeNames -contains 'PCTools.ActionResult') { $collected.Add($item) } + } + } + + end { + $all = @($collected) + + $succeeded = @($all | Where-Object Status -eq 'Success').Count + $warned = @($all | Where-Object Status -eq 'Warning').Count + $failed = @($all | Where-Object Status -eq 'Failed').Count + $skipped = @($all | Where-Object Status -eq 'Skipped').Count + # Measure-Object emits nothing at all for an empty collection, so under + # Set-StrictMode -Version Latest reading .Sum off it is a terminating + # error rather than $null. An empty batch is a normal case here - a + # network-only report has no action results - so it is guarded, not + # assumed away. + $freed = 0L + if ($all.Count -gt 0) { + $measured = $all | Measure-Object -Property BytesFreed -Sum + if ($measured -and $null -ne $measured.Sum) { $freed = [long]$measured.Sum } + } + $reboot = @($all | Where-Object RebootRequired).Count -gt 0 + + $elapsed = [timespan]::Zero + foreach ($item in $all) { + if ($item.Duration -is [timespan]) { $elapsed += $item.Duration } + } + + $parts = @() + if ($all.Count -eq 0) { + $parts += 'Nothing ran' + } + else { + $parts += '{0} action(s)' -f $all.Count + $parts += '{0} ok' -f $succeeded + if ($warned) { $parts += '{0} warning' -f $warned } + if ($failed) { $parts += '{0} failed' -f $failed } + if ($skipped) { $parts += '{0} skipped' -f $skipped } + if ($freed -gt 0) { $parts += '{0} reclaimed' -f (Format-PCByteSize -Bytes $freed) } + if ($reboot) { $parts += 'restart required' } + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.ResultSummary' + Total = $all.Count + Succeeded = $succeeded + Warnings = $warned + Failed = $failed + Skipped = $skipped + BytesFreed = [long]$freed + FreedDisplay = Format-PCByteSize -Bytes $freed + RebootRequired = $reboot + Duration = $elapsed + Text = $parts -join ' - ' + } + } +} diff --git a/src/PCTools/Public/Health/Get-PCBatteryReport.ps1 b/src/PCTools/Public/Health/Get-PCBatteryReport.ps1 new file mode 100644 index 0000000..2a01889 --- /dev/null +++ b/src/PCTools/Public/Health/Get-PCBatteryReport.ps1 @@ -0,0 +1,75 @@ +function Get-PCBatteryReport { + <# + .SYNOPSIS + Reports battery wear: how much capacity the battery has actually lost. + + .DESCRIPTION + "My laptop does not last as long as it used to" has a number behind it, + and Windows knows it: the difference between the battery's design + capacity and what it can hold today. + + Read from the ROOT\WMI battery classes rather than by shelling out to + powercfg /batteryreport, which writes an HTML file to disk and would + then have to be parsed back out of it. + + Returns nothing on a desktop, which is the correct answer there. + + .EXAMPLE + Get-PCBatteryReport | Format-List + + .OUTPUTS + PCTools.BatteryReport + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + $statics = @() + try { + $statics = @(Get-CimInstance -Namespace 'ROOT\WMI' -ClassName BatteryStaticData -ErrorAction Stop) + } + catch { + Write-PCLog -Level DEBUG -Message "No battery static data: $($_.Exception.Message)" + return + } + + if ($statics.Count -eq 0) { return } + + $fullCharge = @() + try { $fullCharge = @(Get-CimInstance -Namespace 'ROOT\WMI' -ClassName BatteryFullChargedCapacity -ErrorAction Stop) } catch { } + + $status = $null + try { $status = Get-CimInstance -ClassName Win32_Battery -ErrorAction Stop } catch { } + + foreach ($battery in $statics) { + $design = [long]$battery.DesignedCapacity + + $current = 0L + $match = $fullCharge | Where-Object { $_.InstanceName -eq $battery.InstanceName } | Select-Object -First 1 + if ($match) { $current = [long]$match.FullChargedCapacity } + elseif ($fullCharge.Count -gt 0) { $current = [long]$fullCharge[0].FullChargedCapacity } + + $healthPercent = if ($design -gt 0 -and $current -gt 0) { + [math]::Round(($current / $design) * 100, 1) + } + else { $null } + + $verdict = if ($null -eq $healthPercent) { 'Capacity could not be read.' } + elseif ($healthPercent -ge 80) { 'Healthy: the battery holds {0}% of its original capacity.' -f $healthPercent } + elseif ($healthPercent -ge 60) { 'Worn: the battery holds {0}% of its original capacity. Runtime will be noticeably shorter.' -f $healthPercent } + else { 'Badly worn: the battery holds only {0}% of its original capacity and is due for replacement.' -f $healthPercent } + + [pscustomobject]@{ + PSTypeName = 'PCTools.BatteryReport' + Name = [string]$battery.DeviceName + Manufacturer = [string]$battery.ManufactureName + Chemistry = [string]$battery.Chemistry + SerialNumber = [string]$battery.SerialNumber + DesignCapacity = $design + CurrentCapacity = $current + HealthPercent = $healthPercent + ChargeRemaining = if ($status) { [int]$status.EstimatedChargeRemaining } else { $null } + Verdict = $verdict + } + } +} diff --git a/src/PCTools/Public/Health/Get-PCBootPerformance.ps1 b/src/PCTools/Public/Health/Get-PCBootPerformance.ps1 new file mode 100644 index 0000000..a105294 --- /dev/null +++ b/src/PCTools/Public/Health/Get-PCBootPerformance.ps1 @@ -0,0 +1,166 @@ +function Get-PCBootPerformance { + <# + .SYNOPSIS + How long this machine takes to start, and what is slowing it down. + + .DESCRIPTION + Windows measures its own boot and writes the result to + Microsoft-Windows-Diagnostics-Performance/Operational. Event 100 carries + the total boot time and its breakdown; events 101 to 110 name the + specific application, service or driver that degraded it and by how many + milliseconds. + + That second part is the useful bit and almost nobody knows it exists. It + turns "my PC is slow to start" into "this updater costs you 9 seconds + every boot", which is a decision somebody can act on with + Disable-PCStartupItem. + + The log is disabled on some systems and on Windows Server; there the + result is empty rather than an error. + + .PARAMETER Last + How many boots to report. + + .EXAMPLE + Get-PCBootPerformance | Format-List + + .EXAMPLE + (Get-PCBootPerformance).Degradations | Format-Table Name, Type, PenaltyMs + + .OUTPUTS + PCTools.BootPerformance + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [ValidateRange(1, 50)] + [int]$Last = 10 + ) + + $logName = 'Microsoft-Windows-Diagnostics-Performance/Operational' + + $bootEvents = @() + try { + $bootEvents = @(Get-WinEvent -FilterHashtable @{ LogName = $logName; Id = 100 } -MaxEvents $Last -ErrorAction Stop) + } + catch { + Write-PCLog -Level DEBUG -Message "Boot performance log unavailable: $($_.Exception.Message)" + return [pscustomobject]@{ + PSTypeName = 'PCTools.BootPerformance' + Available = $false + Boots = @() + AverageMs = $null + LastMs = $null + Degradations = @() + Verdict = 'Windows boot performance logging is not available on this machine, so boot time cannot be measured.' + } + } + + $boots = @($bootEvents | ForEach-Object { + # The interesting numbers live in the event's named XML properties + # rather than the rendered message, which is localised prose. + $xml = $null + try { $xml = ([xml]$_.ToXml()).Event.EventData.Data } catch { } + + $value = { + param($DataName) + if (-not $xml) { return $null } + $node = $xml | Where-Object { $_.Name -eq $DataName } + if ($node) { [long]$node.'#text' } else { $null } + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.BootRecord' + Time = $_.TimeCreated + TotalMs = & $value 'BootTime' + MainPathMs = & $value 'MainPathBootTime' + PostBootMs = & $value 'BootPostBootTime' + DegradationMs = & $value 'BootDegradationTime' + } + }) + + $degradations = @() + try { + # 101 application, 102 driver, 103 service, 106 background, 109 device. + foreach ($event in @(Get-WinEvent -FilterHashtable @{ + LogName = $logName + Id = 101, 102, 103, 106, 109 + } -MaxEvents 100 -ErrorAction Stop)) { + + $xml = $null + try { $xml = ([xml]$event.ToXml()).Event.EventData.Data } catch { continue } + if (-not $xml) { continue } + + $named = @{} + foreach ($node in $xml) { $named[$node.Name] = $node.'#text' } + + $penalty = 0 + foreach ($key in 'TotalTime', 'Time', 'DegradationTime') { + if ($named.ContainsKey($key) -and $named[$key]) { $penalty = [long]$named[$key]; break } + } + if ($penalty -le 0) { continue } + + $name = '' + foreach ($key in 'Name', 'FriendlyName', 'FileName', 'ServiceName') { + if ($named.ContainsKey($key) -and $named[$key]) { $name = [string]$named[$key]; break } + } + if (-not $name) { continue } + + $degradations += [pscustomobject]@{ + PSTypeName = 'PCTools.BootDegradation' + Name = $name + Type = switch ($event.Id) { + 101 { 'Application' } + 102 { 'Driver' } + 103 { 'Service' } + 106 { 'Background task' } + 109 { 'Device' } + default { 'Other' } + } + PenaltyMs = $penalty + Time = $event.TimeCreated + } + } + } + catch { + Write-PCLog -Level DEBUG -Message "Could not read boot degradation events: $($_.Exception.Message)" + } + + # The same offender appears once per boot; report its worst showing. + $worst = @($degradations | + Group-Object Name | + ForEach-Object { $_.Group | Sort-Object PenaltyMs -Descending | Select-Object -First 1 } | + Sort-Object PenaltyMs -Descending | + Select-Object -First 15) + + $measured = @($boots | Where-Object { $_.TotalMs }) + $average = if ($measured.Count -gt 0) { [int](($measured.TotalMs | Measure-Object -Average).Average) } else { $null } + $last = if ($measured.Count -gt 0) { [int]$measured[0].TotalMs } else { $null } + + $verdict = if (-not $average) { + 'No completed boot measurements are recorded yet.' + } + elseif ($average -gt 90000) { + 'Boot takes {0:n1} seconds on average, which is very slow.' -f ($average / 1000) + } + elseif ($average -gt 45000) { + 'Boot takes {0:n1} seconds on average, which is slower than it should be.' -f ($average / 1000) + } + else { + 'Boot takes {0:n1} seconds on average.' -f ($average / 1000) + } + + if ($worst.Count -gt 0) { + $verdict += ' The largest single cost is {0} at {1:n1} seconds.' -f $worst[0].Name, ($worst[0].PenaltyMs / 1000) + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.BootPerformance' + Available = $true + Boots = $boots + AverageMs = $average + LastMs = $last + Degradations = $worst + Verdict = $verdict + } +} diff --git a/src/PCTools/Public/Health/Get-PCDiskHealth.ps1 b/src/PCTools/Public/Health/Get-PCDiskHealth.ps1 new file mode 100644 index 0000000..4b2b8dc --- /dev/null +++ b/src/PCTools/Public/Health/Get-PCDiskHealth.ps1 @@ -0,0 +1,86 @@ +function Get-PCDiskHealth { + <# + .SYNOPSIS + Reads SMART-derived health and wear for every physical disk. + + .DESCRIPTION + The one diagnostic in this module that can tell somebody to buy a drive + before it takes their data with it. Storage Spaces exposes the useful + SMART fields through Get-StorageReliabilityCounter without needing a + vendor tool: reallocated sectors, read and write error counts, power-on + hours and, on SSDs, the wear percentage. + + Every field is optional in practice. Plenty of consumer drives, and most + USB enclosures, report nothing at all - a blank column here means the + drive did not answer, not that the drive is fine. The Health column + distinguishes the two. + + .EXAMPLE + Get-PCDiskHealth | Format-Table FriendlyName, MediaType, Health, Wear, PowerOnHours, Temperature + + .OUTPUTS + PCTools.DiskHealth + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + $disks = @() + try { + $disks = @(Get-PhysicalDisk -ErrorAction Stop) + } + catch { + Write-PCLog -Level WARN -Message "Could not enumerate physical disks: $($_.Exception.Message)" + return + } + + foreach ($disk in $disks) { + $counter = $null + try { + # Needs elevation on most systems, and simply returns nothing on + # drives behind a USB bridge. + $counter = $disk | Get-StorageReliabilityCounter -ErrorAction Stop + } + catch { + Write-PCLog -Level DEBUG -Message "No reliability counters for $($disk.FriendlyName): $($_.Exception.Message)" + } + + $wear = $null + if ($counter -and $null -ne $counter.Wear) { $wear = [int]$counter.Wear } + + $reallocated = $null + if ($counter -and $null -ne $counter.ReadErrorsUncorrected) { $reallocated = [int]$counter.ReadErrorsUncorrected } + + # HealthStatus is the operating system's own verdict and is the most + # trustworthy single field; the counters refine it. + $health = [string]$disk.HealthStatus + $concern = @() + + if ($health -and $health -ne 'Healthy') { $concern += "Windows reports the disk as $health" } + if ($null -ne $wear -and $wear -ge 80) { $concern += "$wear% of rated write endurance used" } + if ($null -ne $reallocated -and $reallocated -gt 0) { $concern += "$reallocated uncorrected read error(s)" } + + $verdict = if ($concern.Count -gt 0) { 'Attention' } + elseif ($health -eq 'Healthy') { 'Healthy' } + else { 'Unknown' } + + [pscustomobject]@{ + PSTypeName = 'PCTools.DiskHealth' + FriendlyName = $disk.FriendlyName + SerialNumber = $disk.SerialNumber + MediaType = [string]$disk.MediaType + BusType = [string]$disk.BusType + Size = [long]$disk.Size + SizeDisplay = Format-PCByteSize -Bytes ([long]$disk.Size) + HealthStatus = $health + Health = $verdict + Wear = $wear + PowerOnHours = if ($counter) { $counter.PowerOnHours } else { $null } + Temperature = if ($counter) { $counter.Temperature } else { $null } + ReadErrors = if ($counter) { $counter.ReadErrorsTotal } else { $null } + WriteErrors = if ($counter) { $counter.WriteErrorsTotal } else { $null } + CountersReadable = [bool]$counter + Concerns = $concern + } + } +} diff --git a/src/PCTools/Public/Health/Get-PCEventSummary.ps1 b/src/PCTools/Public/Health/Get-PCEventSummary.ps1 new file mode 100644 index 0000000..17cdbb1 --- /dev/null +++ b/src/PCTools/Public/Health/Get-PCEventSummary.ps1 @@ -0,0 +1,136 @@ +function Get-PCEventSummary { + <# + .SYNOPSIS + Summarises what has been going wrong, grouped rather than listed. + + .DESCRIPTION + Event Viewer answers "what happened at 14:32". The more useful question + for a machine that is misbehaving is "what keeps happening", and that + one it answers badly: thousands of rows, no grouping, no ranking. + + This reads the error and critical entries from the last N days and + groups them by source and event ID, so a machine with an intermittent + fault shows it as one row with a count of 340 rather than 340 rows. + + Bugchecks (event 1001 from BugCheck, and 41 from Kernel-Power - the + unexpected-shutdown one) are pulled out separately, because a machine + that has blue-screened is a different conversation from one with a noisy + driver. + + .PARAMETER Days + How far back to look. + + .PARAMETER LogName + Which logs to read. Defaults to System and Application. + + .PARAMETER Top + How many grouped rows to return. + + .EXAMPLE + Get-PCEventSummary -Days 14 + + .EXAMPLE + (Get-PCEventSummary).Crashes + + .OUTPUTS + PCTools.EventSummary + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [ValidateRange(1, 365)] + [int]$Days = 7, + + [string[]]$LogName = @('System', 'Application'), + + [ValidateRange(1, 100)] + [int]$Top = 15 + ) + + $since = (Get-Date).AddDays(-$Days) + Write-PCLog -Level INFO -Message "Reading events since $since" + + $events = @() + foreach ($log in $LogName) { + try { + # Level 1 is Critical, 2 is Error. Filtering in the XPath query + # rather than with Where-Object matters here: the unfiltered System + # log on a busy machine is hundreds of thousands of records, and + # pulling them all into PowerShell to discard 98% of them is the + # difference between one second and forty. + $events += @(Get-WinEvent -FilterHashtable @{ + LogName = $log + Level = 1, 2 + StartTime = $since + } -ErrorAction Stop) + } + catch { + # "No events were found" is a legitimate answer, not a failure. + if ($_.Exception.Message -notmatch 'No events were found') { + Write-PCLog -Level DEBUG -Message "Could not read ${log}: $($_.Exception.Message)" + } + } + } + + $grouped = @($events | + Group-Object -Property @{ Expression = { '{0}|{1}' -f $_.ProviderName, $_.Id } } | + Sort-Object Count -Descending | + Select-Object -First $Top | + ForEach-Object { + $sample = $_.Group[0] + [pscustomobject]@{ + PSTypeName = 'PCTools.EventGroup' + Provider = $sample.ProviderName + EventId = $sample.Id + Level = $sample.LevelDisplayName + Count = $_.Count + FirstSeen = ($_.Group | Sort-Object TimeCreated | Select-Object -First 1).TimeCreated + LastSeen = ($_.Group | Sort-Object TimeCreated -Descending | Select-Object -First 1).TimeCreated + Message = ($sample.Message -split "`r?`n" | Where-Object { $_ }) | Select-Object -First 1 + } + }) + + $crashes = @($events | + Where-Object { + ($_.ProviderName -eq 'Microsoft-Windows-Kernel-Power' -and $_.Id -eq 41) -or + ($_.ProviderName -eq 'BugCheck') -or + ($_.ProviderName -eq 'Microsoft-Windows-WER-SystemErrorReporting') + } | + Sort-Object TimeCreated -Descending | + Select-Object -First 10 | + ForEach-Object { + [pscustomobject]@{ + PSTypeName = 'PCTools.CrashEvent' + Time = $_.TimeCreated + Provider = $_.ProviderName + EventId = $_.Id + Message = ($_.Message -split "`r?`n" | Where-Object { $_ }) | Select-Object -First 1 + } + }) + + $diskErrors = @($events | Where-Object { $_.ProviderName -in 'disk', 'Ntfs', 'volmgr' }).Count + + $verdict = if ($crashes.Count -gt 0) { + 'This machine has crashed or lost power unexpectedly {0} time(s) in the last {1} day(s).' -f $crashes.Count, $Days + } + elseif ($diskErrors -gt 0) { + '{0} disk or filesystem error(s) in the last {1} day(s). Run Get-PCDiskHealth and Test-PCDisk.' -f $diskErrors, $Days + } + elseif ($events.Count -eq 0) { + 'No errors or critical events in the last {0} day(s).' -f $Days + } + else { + '{0} error/critical event(s) in the last {1} day(s), none of them crashes.' -f $events.Count, $Days + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.EventSummary' + Since = $since + Days = $Days + Total = $events.Count + Crashes = $crashes + DiskErrors = $diskErrors + Groups = $grouped + Verdict = $verdict + } +} diff --git a/src/PCTools/Public/Health/Get-PCHealthReport.ps1 b/src/PCTools/Public/Health/Get-PCHealthReport.ps1 new file mode 100644 index 0000000..2da4a79 --- /dev/null +++ b/src/PCTools/Public/Health/Get-PCHealthReport.ps1 @@ -0,0 +1,207 @@ +function Get-PCHealthReport { + <# + .SYNOPSIS + One graded answer to "how is this machine doing". + + .DESCRIPTION + Runs the read-only health checks and reduces them to a score out of 100 + with the reasons attached. Nothing here changes anything, so it is safe + to run on any machine at any time - it is the command the GUI dashboard + opens with. + + The score is a summary, not a measurement: its job is to rank findings + so the worst problem is the one at the top. The findings themselves are + what to act on, and each one names the command that addresses it. + + Sections run concurrently where they are independent, since most of them + are waiting on WMI rather than computing. + + .PARAMETER Days + How far back the event history looks. + + .PARAMETER SkipDiskUsage + Leave out the disk usage scan, which is the only slow section on a large + drive. + + .EXAMPLE + Get-PCHealthReport | Format-List Score, Grade, Findings + + .EXAMPLE + (Get-PCHealthReport).Findings | Format-Table Severity, Area, Message, Command + + .OUTPUTS + PCTools.HealthReport + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [ValidateRange(1, 90)] + [int]$Days = 7, + + [switch]$SkipDiskUsage + ) + + Write-PCLog -Level INFO -Message 'Building health report' + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + $findings = [System.Collections.Generic.List[object]]::new() + $addFinding = { + param([string]$Severity, [string]$Area, [string]$Message, [string]$Command) + $findings.Add([pscustomobject]@{ + PSTypeName = 'PCTools.HealthFinding' + Severity = $Severity + Area = $Area + Message = $Message + Command = $Command + }) + } + + # Each section is wrapped so one unavailable subsystem - no BitLocker on + # Home, no battery on a desktop, no reliability counters behind USB - cannot + # take the whole report down with it. + $safely = { + param([string]$Area, [scriptblock]$Work) + try { & $Work } + catch { + Write-PCLog -Level DEBUG -Message "Health section '$Area' failed: $($_.Exception.Message)" + $null + } + } + + $system = & $safely 'System' { Get-PCSystemInfo } + $disks = & $safely 'Disk' { @(Get-PCDiskSpace) } + $health = & $safely 'Disk' { @(Get-PCDiskHealth) } + $security = & $safely 'Security' { Get-PCSecurityStatus } + $events = & $safely 'Events' { Get-PCEventSummary -Days $Days } + $boot = & $safely 'Boot' { Get-PCBootPerformance } + $battery = & $safely 'Battery' { @(Get-PCBatteryReport) } + $startup = & $safely 'Startup' { @(Get-PCStartupItem -Source Registry, Folder | Where-Object Enabled) } + + $score = 100 + + foreach ($disk in @($disks)) { + switch ($disk.Pressure) { + 'Critical' { + $score -= 25 + & $addFinding 'Critical' 'Disk' ("{0} has only {1} free ({2}%)." -f $disk.Drive, $disk.FreeDisplay, $disk.PercentFree) 'Invoke-PCMaintenance -ProfileName Storage' + } + 'Low' { + $score -= 12 + & $addFinding 'Warning' 'Disk' ("{0} is low on space: {1} free ({2}%)." -f $disk.Drive, $disk.FreeDisplay, $disk.PercentFree) 'Get-PCDiskUsage' + } + 'Tight' { + $score -= 4 + & $addFinding 'Info' 'Disk' ("{0} has {1} free ({2}%)." -f $disk.Drive, $disk.FreeDisplay, $disk.PercentFree) 'Invoke-PCMaintenance -ProfileName Quick' + } + } + } + + foreach ($disk in @($health)) { + if ($disk.Health -eq 'Attention') { + $score -= 30 + & $addFinding 'Critical' 'Disk' ("{0}: {1}" -f $disk.FriendlyName, ($disk.Concerns -join '; ')) 'Get-PCDiskHealth' + } + } + + if ($security) { + foreach ($item in @($security.Findings)) { + # A pending restart is worth flagging but is not a security failure. + if ($item -match 'restart is pending') { + $score -= 3 + & $addFinding 'Info' 'Updates' $item 'Restart-Computer' + } + else { + $score -= 8 + & $addFinding 'Warning' 'Security' $item 'Get-PCSecurityStatus' + } + } + } + + if ($events) { + if (@($events.Crashes).Count -gt 0) { + $score -= 20 + & $addFinding 'Critical' 'Stability' $events.Verdict 'Get-PCEventSummary' + } + elseif ($events.DiskErrors -gt 0) { + $score -= 15 + & $addFinding 'Warning' 'Stability' $events.Verdict 'Test-PCDisk' + } + } + + if ($boot -and $boot.Available -and $boot.AverageMs) { + if ($boot.AverageMs -gt 90000) { + $score -= 10 + & $addFinding 'Warning' 'Boot' $boot.Verdict 'Get-PCBootPerformance' + } + elseif ($boot.AverageMs -gt 45000) { + $score -= 5 + & $addFinding 'Info' 'Boot' $boot.Verdict 'Get-PCStartupItem' + } + } + + foreach ($cell in @($battery)) { + if ($null -ne $cell.HealthPercent -and $cell.HealthPercent -lt 60) { + $score -= 8 + & $addFinding 'Warning' 'Battery' $cell.Verdict 'Get-PCBatteryReport' + } + elseif ($null -ne $cell.HealthPercent -and $cell.HealthPercent -lt 80) { + $score -= 3 + & $addFinding 'Info' 'Battery' $cell.Verdict 'Get-PCBatteryReport' + } + } + + $startupCount = @($startup).Count + if ($startupCount -gt 15) { + $score -= 5 + & $addFinding 'Info' 'Startup' "$startupCount programs start with Windows." 'Get-PCStartupItem' + } + + $usage = $null + if (-not $SkipDiskUsage) { + $usage = & $safely 'Usage' { Get-PCDiskUsage -Top 10 } + } + + if ($score -lt 0) { $score = 0 } + $stopwatch.Stop() + + $grade = if ($score -ge 90) { 'Excellent' } + elseif ($score -ge 75) { 'Good' } + elseif ($score -ge 55) { 'Fair' } + elseif ($score -ge 35) { 'Poor' } + else { 'Critical' } + + $summary = if (@($findings).Count -eq 0) { + 'Nothing needs attention.' + } + else { + $critical = @($findings | Where-Object Severity -eq 'Critical').Count + $warning = @($findings | Where-Object Severity -eq 'Warning').Count + $parts = @() + if ($critical) { $parts += "$critical needing attention now" } + if ($warning) { $parts += "$warning worth looking at" } + if ($parts.Count -eq 0) { $parts += '{0} minor note(s)' -f @($findings).Count } + $parts -join ', ' + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.HealthReport' + ComputerName = $env:COMPUTERNAME + Generated = Get-Date + Score = $score + Grade = $grade + Summary = $summary + Findings = @($findings | Sort-Object @{ Expression = { + switch ($_.Severity) { 'Critical' { 0 } 'Warning' { 1 } default { 2 } } + } }) + System = $system + DiskSpace = @($disks) + DiskHealth = @($health) + Security = $security + Events = $events + Boot = $boot + Battery = @($battery) + StartupCount = $startupCount + DiskUsage = $usage + Duration = $stopwatch.Elapsed + } +} diff --git a/src/PCTools/Public/Health/Get-PCSecurityStatus.ps1 b/src/PCTools/Public/Health/Get-PCSecurityStatus.ps1 new file mode 100644 index 0000000..0491057 --- /dev/null +++ b/src/PCTools/Public/Health/Get-PCSecurityStatus.ps1 @@ -0,0 +1,171 @@ +function Get-PCSecurityStatus { + <# + .SYNOPSIS + Reports this machine's security posture. Reads only; changes nothing. + + .DESCRIPTION + Defender, firewall, BitLocker, UAC, SmartScreen and pending updates, in + one place, because they are configured in six different places and + nobody checks all six. + + This command deliberately has no counterpart that turns things on. A + maintenance tool that silently reconfigures antivirus or drops the + firewall is indistinguishable from malware, and a tool that turns + protection *on* without being asked is a tool that breaks somebody's + deliberate configuration. Each finding says what to do; doing it stays + the user's decision, in the Windows UI that owns the setting. + + .EXAMPLE + Get-PCSecurityStatus | Format-List + + .EXAMPLE + (Get-PCSecurityStatus).Findings + + .OUTPUTS + PCTools.SecurityStatus + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + $findings = [System.Collections.Generic.List[string]]::new() + + # --- Defender ----------------------------------------------------------- + $defender = [ordered]@{ + Available = $false + RealTimeProtection = $null + AntivirusEnabled = $null + SignatureAge = $null + SignatureVersion = '' + LastQuickScan = $null + } + + try { + $status = Get-MpComputerStatus -ErrorAction Stop + $defender.Available = $true + $defender.RealTimeProtection = [bool]$status.RealTimeProtectionEnabled + $defender.AntivirusEnabled = [bool]$status.AntivirusEnabled + $defender.SignatureAge = [int]$status.AntivirusSignatureAge + $defender.SignatureVersion = [string]$status.AntivirusSignatureVersion + $defender.LastQuickScan = $status.QuickScanEndTime + + if (-not $status.RealTimeProtectionEnabled) { + $findings.Add('Defender real-time protection is off.') + } + if ([int]$status.AntivirusSignatureAge -gt 7) { + $findings.Add("Defender signatures are $([int]$status.AntivirusSignatureAge) days old.") + } + } + catch { + # Absent on Server core installs, and replaced wholesale by third-party + # suites - neither of which is a problem worth reporting as one. + Write-PCLog -Level DEBUG -Message "Defender status unavailable: $($_.Exception.Message)" + } + + # --- Third-party antivirus --------------------------------------------- + $antivirusProducts = @() + try { + $antivirusProducts = @(Get-CimInstance -Namespace 'root\SecurityCenter2' -ClassName AntiVirusProduct -ErrorAction Stop | + ForEach-Object { $_.displayName }) + } + catch { } + + # --- Firewall ----------------------------------------------------------- + $firewall = @() + try { + foreach ($profileEntry in @(Get-NetFirewallProfile -ErrorAction Stop)) { + $firewall += [pscustomobject]@{ + Name = $profileEntry.Name + Enabled = [bool]$profileEntry.Enabled + } + if (-not $profileEntry.Enabled) { + $findings.Add("The $($profileEntry.Name) firewall profile is off.") + } + } + } + catch { + Write-PCLog -Level DEBUG -Message "Firewall profiles unavailable: $($_.Exception.Message)" + } + + # --- BitLocker ---------------------------------------------------------- + $bitlocker = @() + try { + foreach ($volume in @(Get-BitLockerVolume -ErrorAction Stop)) { + $bitlocker += [pscustomobject]@{ + MountPoint = $volume.MountPoint + ProtectionStatus = [string]$volume.ProtectionStatus + EncryptionMethod = [string]$volume.EncryptionMethod + } + } + + $systemVolume = $bitlocker | Where-Object { $_.MountPoint -eq $env:SystemDrive } + if ($systemVolume -and $systemVolume.ProtectionStatus -ne 'On') { + $findings.Add("$env:SystemDrive is not encrypted with BitLocker.") + } + } + catch { + # Home editions have no BitLocker cmdlets at all. + Write-PCLog -Level DEBUG -Message "BitLocker status unavailable: $($_.Exception.Message)" + } + + # --- UAC ---------------------------------------------------------------- + $uacEnabled = $null + try { + $policy = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction Stop + $uacEnabled = [int]$policy.EnableLUA -eq 1 + if (-not $uacEnabled) { + $findings.Add('User Account Control is disabled, so elevation prompts are suppressed.') + } + } + catch { } + + # --- SmartScreen -------------------------------------------------------- + $smartScreen = '' + try { + $explorerPolicy = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' -ErrorAction SilentlyContinue + if ($explorerPolicy -and $explorerPolicy.PSObject.Properties.Name -contains 'EnableSmartScreen') { + $smartScreen = if ([int]$explorerPolicy.EnableSmartScreen -eq 1) { 'On (policy)' } else { 'Off (policy)' } + } + else { + $shellPolicy = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer' -ErrorAction SilentlyContinue + if ($shellPolicy -and $shellPolicy.PSObject.Properties.Name -contains 'SmartScreenEnabled') { + $smartScreen = [string]$shellPolicy.SmartScreenEnabled + } + } + } + catch { } + + if ($smartScreen -match '^(Off|Off \(policy\))$') { + $findings.Add('SmartScreen application reputation checking is off.') + } + + # --- Pending reboot ----------------------------------------------------- + $rebootPending = $false + $rebootKeys = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' + ) + foreach ($key in $rebootKeys) { + if (Test-Path -LiteralPath $key) { $rebootPending = $true; break } + } + if ($rebootPending) { + $findings.Add('A restart is pending; updates are not fully applied until it happens.') + } + + $grade = if ($findings.Count -eq 0) { 'Good' } + elseif ($findings.Count -le 2) { 'Fair' } + else { 'Needs attention' } + + [pscustomobject]@{ + PSTypeName = 'PCTools.SecurityStatus' + Grade = $grade + Defender = [pscustomobject]$defender + AntivirusProducts = $antivirusProducts + Firewall = $firewall + BitLocker = $bitlocker + UacEnabled = $uacEnabled + SmartScreen = $smartScreen + RebootPending = $rebootPending + Findings = @($findings) + } +} diff --git a/src/PCTools/Public/Health/Get-PCSystemInfo.ps1 b/src/PCTools/Public/Health/Get-PCSystemInfo.ps1 new file mode 100644 index 0000000..c52bfb2 --- /dev/null +++ b/src/PCTools/Public/Health/Get-PCSystemInfo.ps1 @@ -0,0 +1,100 @@ +function Get-PCSystemInfo { + <# + .SYNOPSIS + What this machine is. + + .DESCRIPTION + The first question in any support conversation, answered in one object: + model, CPU, memory, Windows build, uptime, firmware mode and Secure Boot + state. + + The Windows build number matters more than the version name - "Windows + 11" spans several builds with materially different behaviour - so it is + reported in full alongside the display version. + + .EXAMPLE + Get-PCSystemInfo | Format-List + + .OUTPUTS + PCTools.SystemInfo + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + $computer = $null + $os = $null + $cpu = $null + $bios = $null + + try { $computer = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop } catch { } + try { $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop } catch { } + try { $cpu = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop)[0] } catch { } + try { $bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop } catch { } + + $uptime = $null + $lastBoot = $null + if ($os -and $os.LastBootUpTime) { + $lastBoot = $os.LastBootUpTime + $uptime = (Get-Date) - $lastBoot + } + + # The display version (23H2 and so on) is only in the registry. + $displayVersion = '' + try { + $currentVersion = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop + $displayVersion = if ($currentVersion.PSObject.Properties.Name -contains 'DisplayVersion') { + [string]$currentVersion.DisplayVersion + } + elseif ($currentVersion.PSObject.Properties.Name -contains 'ReleaseId') { + [string]$currentVersion.ReleaseId + } + else { '' } + } + catch { } + + $secureBoot = $null + try { $secureBoot = Confirm-SecureBootUEFI -ErrorAction Stop } + catch { + # Throws rather than returning false on a legacy BIOS machine, which is + # itself the answer. + $secureBoot = $false + } + + $firmware = 'Unknown' + try { + $firmware = if ($env:firmware_type) { $env:firmware_type } + elseif ($secureBoot) { 'UEFI' } + else { 'Unknown' } + } + catch { } + + $memoryBytes = if ($computer) { [long]$computer.TotalPhysicalMemory } else { 0L } + + [pscustomobject]@{ + PSTypeName = 'PCTools.SystemInfo' + ComputerName = $env:COMPUTERNAME + User = "$env:USERDOMAIN\$env:USERNAME" + Manufacturer = if ($computer) { $computer.Manufacturer } else { '' } + Model = if ($computer) { $computer.Model } else { '' } + SerialNumber = if ($bios) { $bios.SerialNumber } else { '' } + BiosVersion = if ($bios) { ($bios.SMBIOSBIOSVersion) } else { '' } + Firmware = $firmware + SecureBoot = $secureBoot + OperatingSystem = if ($os) { $os.Caption } else { '' } + DisplayVersion = $displayVersion + Build = if ($os) { "$($os.Version) (build $($os.BuildNumber))" } else { '' } + Architecture = if ($os) { $os.OSArchitecture } else { '' } + InstalledOn = if ($os) { $os.InstallDate } else { $null } + Processor = if ($cpu) { $cpu.Name } else { '' } + Cores = if ($cpu) { [int]$cpu.NumberOfCores } else { $null } + LogicalCores = if ($cpu) { [int]$cpu.NumberOfLogicalProcessors } else { $null } + Memory = $memoryBytes + MemoryDisplay = Format-PCByteSize -Bytes $memoryBytes + LastBoot = $lastBoot + Uptime = $uptime + UptimeDisplay = if ($uptime) { '{0}d {1}h {2}m' -f $uptime.Days, $uptime.Hours, $uptime.Minutes } else { '' } + IsAdmin = Test-PCAdmin + PSVersion = $PSVersionTable.PSVersion.ToString() + } +} diff --git a/src/PCTools/Public/Import-PCConfiguration.ps1 b/src/PCTools/Public/Import-PCConfiguration.ps1 index e3fdd76..03bf717 100644 --- a/src/PCTools/Public/Import-PCConfiguration.ps1 +++ b/src/PCTools/Public/Import-PCConfiguration.ps1 @@ -66,7 +66,10 @@ function Import-PCConfiguration { throw "'$Path' has no 'profiles' array." } - $exported = (Get-Module PCTools).ExportedFunctions.Keys + # Extension actions are valid in a profile even though they are not + # exported - Invoke-PCMaintenance resolves them through the extension table. + # Validating against exports alone would reject a profile that works. + $exported = @((Get-Module PCTools).ExportedFunctions.Keys) + @($script:PCExtension.Keys) $loaded = [System.Collections.Generic.List[object]]::new() foreach ($entry in @($config.profiles)) { @@ -88,7 +91,9 @@ function Import-PCConfiguration { throw "Profile '$($entry.name)' has an action with no 'action' name." } if ($exported -notcontains $step.action) { - throw "Profile '$($entry.name)' references '$($step.action)', which PCTools does not export. Run Get-Command -Module PCTools for the list." + throw ("Profile '{0}' references '{1}', which is neither a PCTools command nor a registered extension. " + + "Run Get-Command -Module PCTools for the commands, and Get-PCExtension for the extensions." -f + $entry.name, $step.action) } $parameters = @{} diff --git a/src/PCTools/Public/Invoke-PCMaintenance.ps1 b/src/PCTools/Public/Invoke-PCMaintenance.ps1 index 45a1331..fb0c0f2 100644 --- a/src/PCTools/Public/Invoke-PCMaintenance.ps1 +++ b/src/PCTools/Public/Invoke-PCMaintenance.ps1 @@ -89,17 +89,34 @@ function Invoke-PCMaintenance { $parameters = if ($step.ContainsKey('Parameters')) { $step.Parameters.Clone() } else { @{} } $command = Get-Command -Name $name -Module $script:ModuleName -ErrorAction SilentlyContinue - if (-not $command) { - New-PCActionResult -Action $name -Status Failed -Detail "No such action in $script:ModuleName" + + # An extension action is not an exported command - it is a scriptblock + # held in the extension table - so it is looked up separately. Only a + # name that was actually registered resolves this way, which keeps an + # unregistered name a clear failure rather than something that quietly + # picks up a function from the caller's session. + $extension = $null + if (-not $command -and $script:PCExtension.ContainsKey($name)) { + $extension = $script:PCExtension[$name] + } + + if (-not $command -and -not $extension) { + $detail = if ($script:PCExtension.Count -gt 0) { + "'$name' is neither a $script:ModuleName command nor a registered extension. Get-PCExtension lists the extensions." + } + else { + "No such action in $script:ModuleName" + } + New-PCActionResult -Action $name -Status Failed -Detail $detail continue } # Pass -WhatIf and -Confirm through so a preflight really is a preflight. - if ($command.Parameters.ContainsKey('WhatIf')) { + if ($command -and $command.Parameters.ContainsKey('WhatIf')) { $parameters['WhatIf'] = $WhatIfPreference } - $result = & $command @parameters + $result = if ($extension) { & $extension.Command @parameters } else { & $command @parameters } $result if ($result.Status -eq 'Failed' -and -not $ContinueOnFailure) { diff --git a/src/PCTools/Public/Invoke-PCTools.ps1 b/src/PCTools/Public/Invoke-PCTools.ps1 new file mode 100644 index 0000000..878f60e --- /dev/null +++ b/src/PCTools/Public/Invoke-PCTools.ps1 @@ -0,0 +1,193 @@ +function Invoke-PCTools { + <# + .SYNOPSIS + The command-line front door: run a profile, diagnose the network, or open + the window. + + .DESCRIPTION + One entry point that behaves sensibly however it is called. With no + arguments it opens the GUI, which is what someone typing `pctools` in a + terminal means. With a profile it runs that profile and returns, which is + what a scheduled task or a CI step means. + + It adds no orchestration of its own - the work is Invoke-PCMaintenance + and Get-PCNetworkReport, unchanged - only argument handling, output + formatting and an exit code. + + .PARAMETER ProfileName + The maintenance profile to run. See Get-PCMaintenanceProfile. + + .PARAMETER Action + Explicit action names instead of a profile. + + .PARAMETER Diagnose + Run a network diagnostic instead of maintenance and print the verdict. + + .PARAMETER Gui + Open the window. This is the default when nothing else is asked for. + + .PARAMETER Json + Emit the results as JSON on stdout instead of formatted text, so the + output can be piped into anything that reads JSON. + + .PARAMETER Report + Also write a report file. Html, Json, Text or Csv; see Export-PCReport. + + .PARAMETER ReportPath + Where to write the report. Defaults to the desktop. + + .PARAMETER Quiet + Suppress the human-readable summary. Results still go to the pipeline + and the exit code is still set. + + .PARAMETER SkipRestorePoint + Run without taking a restore point first. + + .PARAMETER Exit + Terminate the host with the computed exit code when the run finishes. + This is what the `pctools` shim passes; do not pass it interactively + unless closing the session is what you want. + + Exit codes: + 0 every action succeeded + 1 at least one warning, nothing failed + 2 at least one action failed + 3010 everything succeeded but a restart is required + + .EXAMPLE + Invoke-PCTools + + Opens the window. + + .EXAMPLE + Invoke-PCTools -ProfileName Quick -Json + + .EXAMPLE + Invoke-PCTools -ProfileName Recommended -Report Html -Quiet + + .EXAMPLE + Invoke-PCTools -Diagnose + + .OUTPUTS + PCTools.ActionResult, or PCTools.NetworkReport with -Diagnose. + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Gui')] + [OutputType([pscustomobject])] + param( + [Parameter(Position = 0, ParameterSetName = 'Profile')] + [Alias('Profile')] + [string]$ProfileName, + + [Parameter(Mandatory, ParameterSetName = 'Action')] + [string[]]$Action, + + [Parameter(Mandatory, ParameterSetName = 'Diagnose')] + [switch]$Diagnose, + + [Parameter(ParameterSetName = 'Diagnose')] + [switch]$Full, + + [Parameter(ParameterSetName = 'Gui')] + [switch]$Gui, + + [Parameter(ParameterSetName = 'Gui')] + [ValidateSet('Dark', 'Light')] + [string]$Theme = 'Dark', + + [Parameter(ParameterSetName = 'Profile')] + [Parameter(ParameterSetName = 'Action')] + [switch]$SkipRestorePoint, + + [switch]$Json, + + [ValidateSet('Html', 'Json', 'Text', 'Csv')] + [string]$Report, + + [string]$ReportPath, + + [switch]$Quiet, + + [switch]$Exit + ) + + if ($PSCmdlet.ParameterSetName -eq 'Gui') { + Start-PCTools -Theme $Theme + return + } + + $results = switch ($PSCmdlet.ParameterSetName) { + 'Diagnose' { + $report = Get-PCNetworkReport -Full:$Full + + if (-not $Quiet -and -not $Json) { + Write-Host '' + Write-Host " $($report.Verdict.Verdict)" -ForegroundColor $( + if ($report.Verdict.Healthy) { 'Green' } else { 'Yellow' }) + Write-Host " $($report.Verdict.Advice)" + Write-Host '' + $report.Tests | Format-Table Layer, Target, Success, LatencyMs, Detail -AutoSize | + Out-String | Write-Host + } + + $report + } + 'Action' { + Invoke-PCMaintenance -Action $Action -SkipRestorePoint:$SkipRestorePoint + } + default { + $name = if ($ProfileName) { $ProfileName } else { 'Quick' } + Invoke-PCMaintenance -ProfileName $name -SkipRestorePoint:$SkipRestorePoint + } + } + + $results = @($results) + + if ($Report) { + $exportParams = @{ Format = $Report } + if ($ReportPath) { $exportParams['Path'] = $ReportPath } + $written = $results | Export-PCReport @exportParams + + if (-not $Quiet -and $written) { + foreach ($path in $written.PSObject.Properties.Value) { + if ($path) { Write-Host "Report: $path" } + } + } + } + + $actionResults = @($results | Where-Object { $_.PSObject.TypeNames -contains 'PCTools.ActionResult' }) + + if (-not $Quiet -and -not $Json -and $actionResults.Count -gt 0) { + Write-Host '' + $actionResults | Format-Table Action, Status, FreedDisplay, Detail -AutoSize | + Out-String | Write-Host + Write-Host (Get-PCResultSummary -Result $actionResults).Text + Write-Host '' + } + + if ($Json) { + # ErrorRecord does not serialise usefully and can be enormous. + $results | + Select-Object -Property * -ExcludeProperty ErrorRecord | + ConvertTo-Json -Depth 8 -WarningAction SilentlyContinue + } + else { + $results + } + + $code = 0 + if ($actionResults.Count -gt 0) { + if (@($actionResults | Where-Object Status -eq 'Failed').Count -gt 0) { $code = 2 } + elseif (@($actionResults | Where-Object Status -eq 'Warning').Count -gt 0) { $code = 1 } + elseif (@($actionResults | Where-Object RebootRequired).Count -gt 0) { $code = 3010 } + } + elseif ($PSCmdlet.ParameterSetName -eq 'Diagnose') { + $code = if ($results[0].Verdict.Healthy) { 0 } else { 2 } + } + + $script:PCLastExitCode = $code + + if ($Exit) { + # Deliberate: this terminates the host. Only the pctools shim passes it. + exit $code + } +} diff --git a/src/PCTools/Public/Network/Get-PCNetworkAdapter.ps1 b/src/PCTools/Public/Network/Get-PCNetworkAdapter.ps1 index 24d48cd..2b56b2a 100644 --- a/src/PCTools/Public/Network/Get-PCNetworkAdapter.ps1 +++ b/src/PCTools/Public/Network/Get-PCNetworkAdapter.ps1 @@ -9,6 +9,13 @@ function Get-PCNetworkAdapter { adapters without addresses, and Net Diag listed addresses in a second table the user had to correlate by hand. + Three CIM queries, not two per adapter. The first version called + Get-NetIPConfiguration and Get-NetIPInterface inside the loop, so a + laptop carrying Wi-Fi, Ethernet, Bluetooth, a Hyper-V switch and a VPN + adapter paid twenty-odd round-trips to WMI to build one table. Asking + for everything once and joining on ifIndex in memory gives the same + objects for a fixed three. + .PARAMETER ConnectedOnly Return only adapters whose status is Up. @@ -37,20 +44,37 @@ function Get-PCNetworkAdapter { $adapters = @($adapters | Where-Object { $_.Status -eq 'Up' }) } - foreach ($adapter in $adapters) { - $config = $null - try { - $config = Get-NetIPConfiguration -InterfaceIndex $adapter.ifIndex -ErrorAction Stop + if ($adapters.Count -eq 0) { return } + + # Ask once, index by interface, join in memory. + $configByIndex = @{} + try { + foreach ($item in @(Get-NetIPConfiguration -All -ErrorAction Stop)) { + $configByIndex[[int]$item.InterfaceIndex] = $item } - catch { - Write-PCLog -Level DEBUG -Message "No IP configuration for $($adapter.Name): $($_.Exception.Message)" + } + catch { + Write-PCLog -Level DEBUG -Message "Get-NetIPConfiguration failed: $($_.Exception.Message)" + } + + $dhcpByIndex = @{} + try { + foreach ($item in @(Get-NetIPInterface -AddressFamily IPv4 -ErrorAction Stop)) { + $dhcpByIndex[[int]$item.InterfaceIndex] = ($item.Dhcp -eq 'Enabled') } + } + catch { + Write-PCLog -Level DEBUG -Message "Get-NetIPInterface failed: $($_.Exception.Message)" + } + + foreach ($adapter in $adapters) { + $index = [int]$adapter.ifIndex + + $config = $null + if ($configByIndex.ContainsKey($index)) { $config = $configByIndex[$index] } $isDhcp = $null - try { - $isDhcp = (Get-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction Stop).Dhcp -eq 'Enabled' - } - catch { } + if ($dhcpByIndex.ContainsKey($index)) { $isDhcp = $dhcpByIndex[$index] } [pscustomobject]@{ PSTypeName = 'PCTools.NetworkAdapter' diff --git a/src/PCTools/Public/Network/Get-PCNetworkReport.ps1 b/src/PCTools/Public/Network/Get-PCNetworkReport.ps1 index a5ec5fa..66c811f 100644 --- a/src/PCTools/Public/Network/Get-PCNetworkReport.ps1 +++ b/src/PCTools/Public/Network/Get-PCNetworkReport.ps1 @@ -69,12 +69,37 @@ function Get-PCNetworkReport { WlanInterfaces = @{ File = 'netsh.exe'; Args = @('wlan', 'show', 'interfaces') } } + # Six read-only captures that do not depend on each other. Serially the + # user waited for their sum; concurrently, for the slowest of them. + Write-PCLog -Level INFO -Message "Capturing $($commands.Count) system views" + + $keys = @($commands.Keys) + $descriptors = foreach ($key in $keys) { + @{ Key = $key; File = $commands[$key].File; Args = $commands[$key].Args } + } + + $captureBody = { + param($Descriptor, $Timeout) + $run = Invoke-PCProcess -FilePath $Descriptor.File -ArgumentList $Descriptor.Args -TimeoutSeconds $Timeout + $run.Output + } + + $completed = @(Invoke-PCParallel -InputObject @($descriptors) -ScriptBlock $captureBody ` + -ArgumentList @($TimeoutSeconds) ` + -InitScript (Get-PCFunctionSource -Name 'Invoke-PCProcess' -IncludeLogStub) ` + -ThrottleLimit 6 -TimeoutSeconds ([math]::Max(60, $TimeoutSeconds * 2))) + + $byIndex = @{} + foreach ($item in $completed) { $byIndex[$item.Index] = $item } + $extras = [ordered]@{} - foreach ($key in $commands.Keys) { - $command = $commands[$key] - Write-PCLog -Level INFO -Message "Capturing $key" - $run = Invoke-PCProcess -FilePath $command.File -ArgumentList $command.Args -TimeoutSeconds $TimeoutSeconds - $extras[$key] = $run.Output + for ($i = 0; $i -lt $keys.Count; $i++) { + $value = '(not captured)' + if ($byIndex.ContainsKey($i)) { + if ($byIndex[$i].Output) { $value = [string]$byIndex[$i].Output } + elseif ($byIndex[$i].Error) { $value = "Capture failed: $($byIndex[$i].Error)" } + } + $extras[$keys[$i]] = $value } $report.Full = [pscustomobject]$extras diff --git a/src/PCTools/Public/Network/Test-PCConnectivity.ps1 b/src/PCTools/Public/Network/Test-PCConnectivity.ps1 index 10934b5..4ead816 100644 --- a/src/PCTools/Public/Network/Test-PCConnectivity.ps1 +++ b/src/PCTools/Public/Network/Test-PCConnectivity.ps1 @@ -13,6 +13,12 @@ function Test-PCConnectivity { broken" produce very different first failures, and the original printed the results in whatever order the tests were written. + The probes run concurrently and are then sorted back into layer order. + Ordering the *output* is the diagnostic value; ordering the *execution* + only ever cost time, and on a broken network it cost the most - nine + probes each waiting out its own timeout, one after another. The wall + clock is now the slowest single probe rather than the sum. + .PARAMETER DnsName Names to resolve. Defaults to two well-known hosts. @@ -46,54 +52,120 @@ function Test-PCConnectivity { } } - # 1. Adapter + # 1. Adapter. This one is not a probe and it decides what the gateway probe + # aims at, so it stays sequential and first. $connected = @(Get-PCNetworkAdapter -ConnectedOnly) & $emit 'Adapter' 'Local adapters' ($connected.Count -gt 0) ` $(if ($connected.Count -gt 0) { "$($connected.Count) adapter(s) up: $(($connected.Name) -join ', ')" } else { 'No adapter is up' }) $null - # 2. Gateway $gateway = ($connected | Where-Object Gateway | Select-Object -First 1).Gateway if ($gateway) { $gateway = ($gateway -split ',')[0].Trim() } + # Everything below is independent, so describe the probes as data and run + # the lot at once. Layer is carried on each descriptor and used to restore + # the diagnostic ordering afterwards. + $probes = [System.Collections.Generic.List[object]]::new() + if ($gateway) { - $reply = Test-PCPing -Target $gateway -TimeoutSeconds $TimeoutSeconds - & $emit 'Gateway' $gateway $reply.Success $reply.Detail $reply.LatencyMs - } - else { - & $emit 'Gateway' '(none)' $false 'No default gateway is configured' $null + $probes.Add(@{ Layer = 'Gateway'; Kind = 'Ping'; Target = $gateway }) } - # 3. Internet routing, by IP so DNS cannot mask a routing failure + # Internet routing is probed by IP so DNS cannot mask a routing failure. foreach ($target in @('1.1.1.1', '8.8.8.8')) { - $reply = Test-PCPing -Target $target -TimeoutSeconds $TimeoutSeconds - & $emit 'Internet' $target $reply.Success $reply.Detail $reply.LatencyMs + $probes.Add(@{ Layer = 'Internet'; Kind = 'Ping'; Target = $target }) } - # 4. DNS resolution foreach ($name in $DnsName) { - $resolved = $null - try { - $records = Resolve-DnsName -Name $name -Type A -DnsOnly -ErrorAction Stop | - Where-Object { $_.IPAddress } - $resolved = ($records.IPAddress) -join ', ' - } - catch { - try { - $resolved = ([System.Net.Dns]::GetHostAddresses($name) | - Where-Object AddressFamily -eq 'InterNetwork' | - ForEach-Object IPAddressToString) -join ', ' + $probes.Add(@{ Layer = 'DNS'; Kind = 'Dns'; Target = $name }) + } + + foreach ($endpoint in @(@{ Host = 'www.google.com'; Port = 443 }, @{ Host = '1.1.1.1'; Port = 53 })) { + $probes.Add(@{ Layer = 'TCP'; Kind = 'Tcp'; Target = $endpoint.Host; Port = $endpoint.Port }) + } + + # The probe helpers travel into the runspaces as source rather than being + # reimplemented here, so there is still exactly one ping implementation. + $init = Get-PCFunctionSource -Name 'Test-PCPing', 'Test-PCTcpPort' + + $probeBody = { + param($Probe, $Timeout) + + switch ($Probe.Kind) { + 'Ping' { + $reply = Test-PCPing -Target $Probe.Target -TimeoutSeconds $Timeout + @{ Success = $reply.Success; Detail = $reply.Detail; LatencyMs = $reply.LatencyMs } } - catch { $resolved = $null } - } + 'Tcp' { + $result = Test-PCTcpPort -TargetHost $Probe.Target -Port $Probe.Port -TimeoutSeconds $Timeout + @{ Success = $result.Success; Detail = $result.Detail; LatencyMs = $result.LatencyMs } + } + 'Dns' { + $resolved = $null + try { + $records = Resolve-DnsName -Name $Probe.Target -Type A -DnsOnly -ErrorAction Stop | + Where-Object { $_.IPAddress } + $resolved = ($records.IPAddress) -join ', ' + } + catch { + # Resolve-DnsName is Windows-only and can fail for reasons + # unrelated to DNS being broken. The framework resolver is + # the second opinion before calling resolution failed. + try { + $resolved = ([System.Net.Dns]::GetHostAddresses($Probe.Target) | + Where-Object AddressFamily -eq 'InterNetwork' | + ForEach-Object IPAddressToString) -join ', ' + } + catch { $resolved = $null } + } - & $emit 'DNS' $name ([bool]$resolved) ` - $(if ($resolved) { "Resolved to $resolved" } else { 'Resolution failed' }) $null + @{ + Success = [bool]$resolved + Detail = if ($resolved) { "Resolved to $resolved" } else { 'Resolution failed' } + LatencyMs = $null + } + } + } } - # 5. TCP reachability - foreach ($endpoint in @(@{ Host = 'www.google.com'; Port = 443 }, @{ Host = '1.1.1.1'; Port = 53 })) { - $result = Test-PCTcpPort -TargetHost $endpoint.Host -Port $endpoint.Port -TimeoutSeconds $TimeoutSeconds - & $emit 'TCP' "$($endpoint.Host):$($endpoint.Port)" $result.Success $result.Detail $result.LatencyMs + # One timeout for the batch, with headroom: the probes overlap, so the batch + # cannot legitimately need more than a single probe's budget plus startup. + $batchTimeout = [math]::Max(30, $TimeoutSeconds * 4) + + $completed = @(Invoke-PCParallel -InputObject $probes.ToArray() -ScriptBlock $probeBody ` + -ArgumentList @($TimeoutSeconds) -InitScript $init ` + -ThrottleLimit 8 -TimeoutSeconds $batchTimeout) + + $byIndex = @{} + foreach ($item in $completed) { $byIndex[$item.Index] = $item } + + # Emit in layer order - the whole diagnostic depends on it. + $layerOrder = 'Gateway', 'Internet', 'DNS', 'TCP' + + foreach ($layer in $layerOrder) { + # A missing gateway is a finding, and it belongs in the Gateway + # position rather than tacked on after the TCP rows. + if ($layer -eq 'Gateway' -and -not $gateway) { + & $emit 'Gateway' '(none)' $false 'No default gateway is configured' $null + continue + } + + for ($i = 0; $i -lt $probes.Count; $i++) { + $probe = $probes[$i] + if ($probe.Layer -ne $layer) { continue } + + $label = if ($probe.Kind -eq 'Tcp') { "$($probe.Target):$($probe.Port)" } else { $probe.Target } + + $outcome = $null + if ($byIndex.ContainsKey($i)) { $outcome = $byIndex[$i].Output } + + if (-not $outcome) { + $reason = if ($byIndex.ContainsKey($i) -and $byIndex[$i].Error) { $byIndex[$i].Error } else { 'no result' } + & $emit $layer $label $false "Probe did not complete ($reason)" $null + continue + } + + & $emit $layer $label $outcome.Success $outcome.Detail $outcome.LatencyMs + } } } diff --git a/src/PCTools/Public/Network/Test-PCDnsServer.ps1 b/src/PCTools/Public/Network/Test-PCDnsServer.ps1 new file mode 100644 index 0000000..915103b --- /dev/null +++ b/src/PCTools/Public/Network/Test-PCDnsServer.ps1 @@ -0,0 +1,180 @@ +function Test-PCDnsServer { + <# + .SYNOPSIS + Benchmarks DNS resolvers and says which one to use. + + .DESCRIPTION + DNS latency is paid on nearly every request and is invisible until + somebody measures it. A router forwarding to an overloaded ISP resolver + can add 80 ms to the start of every connection, which reads to the user + as "the internet is slow" and shows up in no speed test. + + Each candidate resolver is queried for several names and the median + response time reported. Median rather than mean on purpose: one 2-second + timeout would dominate an average and misrepresent an otherwise fine + resolver. + + The currently configured resolver is included in the comparison, since + the only question worth answering is whether changing would help. + + .PARAMETER Server + Resolvers to test. Defaults to the configured one plus Cloudflare, + Google and Quad9. + + .PARAMETER Name + Names to resolve for the measurement. + + .PARAMETER TimeoutSeconds + Per-query timeout. + + .EXAMPLE + Test-PCDnsServer | Format-Table Server, Provider, MedianMs, Success, Recommendation + + .OUTPUTS + PCTools.DnsBenchmark + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string[]]$Server, + + [string[]]$Name = @('www.microsoft.com', 'www.wikipedia.org', 'www.github.com', 'www.bbc.co.uk'), + + [ValidateRange(1, 30)] + [int]$TimeoutSeconds = 3 + ) + + $providers = @{ + '1.1.1.1' = 'Cloudflare' + '1.0.0.1' = 'Cloudflare' + '8.8.8.8' = 'Google' + '8.8.4.4' = 'Google' + '9.9.9.9' = 'Quad9' + '149.112.112.112' = 'Quad9' + '208.67.222.222' = 'OpenDNS' + } + + $candidates = [System.Collections.Generic.List[string]]::new() + $configured = @() + + if ($Server) { + foreach ($item in $Server) { $candidates.Add($item) } + } + else { + # Whatever this machine is actually using, so the comparison answers + # "would changing help" rather than just ranking public resolvers. + try { + $configured = @(Get-PCNetworkAdapter -ConnectedOnly | + Where-Object DnsServers | + ForEach-Object { $_.DnsServers -split ',' } | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -and $_ -notmatch ':' } | + Select-Object -Unique) + } + catch { } + + foreach ($item in $configured) { $candidates.Add($item) } + foreach ($item in '1.1.1.1', '8.8.8.8', '9.9.9.9') { + if ($candidates -notcontains $item) { $candidates.Add($item) } + } + } + + if ($candidates.Count -eq 0) { + Write-PCLog -Level WARN -Message 'No DNS servers to test.' + return + } + + Write-PCLog -Level INFO -Message "Benchmarking $($candidates.Count) DNS server(s)" + + $benchBody = { + param($DnsServer, $Names, $Timeout) + + $latencies = [System.Collections.Generic.List[double]]::new() + $failures = 0 + + foreach ($target in $Names) { + $watch = [System.Diagnostics.Stopwatch]::StartNew() + try { + # -DnsOnly and -NoHostsFile keep the cache and the hosts file + # out of the measurement; without them a warm cache reports + # sub-millisecond times for every server equally. + $answer = Resolve-DnsName -Name $target -Server $DnsServer -Type A ` + -DnsOnly -NoHostsFile -QuickTimeout -ErrorAction Stop + $watch.Stop() + + if ($answer) { $latencies.Add($watch.Elapsed.TotalMilliseconds) } + else { $failures++ } + } + catch { + $watch.Stop() + $failures++ + } + } + + @{ + Latencies = $latencies.ToArray() + Failures = $failures + } + } + + $completed = @(Invoke-PCParallel -InputObject $candidates.ToArray() -ScriptBlock $benchBody ` + -ArgumentList @($Name, $TimeoutSeconds) -ThrottleLimit 6 ` + -TimeoutSeconds ([math]::Max(30, $TimeoutSeconds * $Name.Count + 10))) + + $results = foreach ($item in $completed) { + $dnsServer = [string]$item.Input + $outcome = $item.Output + + $latencies = @() + $failures = $Name.Count + if ($outcome) { + $latencies = @($outcome.Latencies) + $failures = [int]$outcome.Failures + } + + $median = $null + if ($latencies.Count -gt 0) { + $sorted = @($latencies | Sort-Object) + $median = if ($sorted.Count % 2 -eq 1) { + $sorted[[int]([math]::Floor($sorted.Count / 2))] + } + else { + ($sorted[($sorted.Count / 2) - 1] + $sorted[$sorted.Count / 2]) / 2 + } + $median = [math]::Round($median, 1) + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.DnsBenchmark' + Server = $dnsServer + Provider = if ($providers.ContainsKey($dnsServer)) { $providers[$dnsServer] } + elseif ($configured -contains $dnsServer) { 'Currently configured' } + else { 'Unknown' } + InUse = $configured -contains $dnsServer + Success = $latencies.Count + Failures = $failures + MedianMs = $median + } + } + + $ranked = @($results | Where-Object { $null -ne $_.MedianMs } | Sort-Object MedianMs) + $best = $ranked | Select-Object -First 1 + $current = $ranked | Where-Object InUse | Select-Object -First 1 + + foreach ($result in $results) { + $recommendation = if (-not $best) { '' } + elseif ($result.Server -eq $best.Server -and $current -and $current.Server -eq $best.Server) { + 'Fastest, and already in use.' + } + elseif ($result.Server -eq $best.Server -and $current) { + 'Fastest. {0:n0} ms faster than the configured resolver: Set-PCDnsServer -Name -Server {1}' -f + ($current.MedianMs - $best.MedianMs), $result.Server + } + elseif ($result.Server -eq $best.Server) { + 'Fastest of those tested.' + } + else { '' } + + $result | Add-Member -NotePropertyName Recommendation -NotePropertyValue $recommendation -PassThru + } +} diff --git a/src/PCTools/Public/Network/Test-PCMtu.ps1 b/src/PCTools/Public/Network/Test-PCMtu.ps1 index 3cd6b66..479bd44 100644 --- a/src/PCTools/Public/Network/Test-PCMtu.ps1 +++ b/src/PCTools/Public/Network/Test-PCMtu.ps1 @@ -36,65 +36,128 @@ function Test-PCMtu { Write-PCLog -Level INFO -Message "Probing path MTU to $Target" - $ping = [System.Net.NetworkInformation.Ping]::new() - $dontFragment = [System.Net.NetworkInformation.PingOptions]::new(64, $true) + # A payload probe is one don't-fragment ping. Probes at different sizes are + # independent, so a round of them costs one timeout rather than one each - + # which is what turns an eleven-step serial binary search into two rounds. + $probeBody = { + param($PayloadSize, $Destination, $Timeout) + + $ping = [System.Net.NetworkInformation.Ping]::new() + $options = [System.Net.NetworkInformation.PingOptions]::new(64, $true) - $probe = { - param($PayloadSize) - $buffer = [byte[]]::new($PayloadSize) try { - $reply = $ping.Send($Target, $TimeoutSeconds * 1000, $buffer, $dontFragment) - return $reply.Status -eq 'Success' + $buffer = [byte[]]::new($PayloadSize) + $reply = $ping.Send($Destination, $Timeout * 1000, $buffer, $options) + return ($reply.Status -eq 'Success') } catch { return $false } + finally { + $ping.Dispose() + } } - try { - # 1472 = 1500 - 28, the largest payload that fits a standard Ethernet MTU. - $low = 0 - $high = 1472 - - if (& $probe $high) { - return [pscustomobject]@{ - PSTypeName = 'PCTools.MtuProbe' - Target = $Target - PayloadSize = $high - Mtu = $high + 28 - Standard = $true - Detail = 'Full 1500-byte MTU path; no fragmentation issue.' - } - } + $extra = @($Target, $TimeoutSeconds) + $batchTimeout = [math]::Max(30, $TimeoutSeconds * 4) - if (-not (& $probe 0)) { - return [pscustomobject]@{ - PSTypeName = 'PCTools.MtuProbe' - Target = $Target - PayloadSize = $null - Mtu = $null - Standard = $false - Detail = "$Target does not answer ICMP at all, so the MTU cannot be probed this way." - } - } + # Deliberately not a closure. GetNewClosure() rebinds a scriptblock to a + # fresh scope that is no longer attached to the module's session state, so a + # closure here could not see Invoke-PCParallel - a private module function - + # and every probe failed with "the term is not recognized". A plain block + # invoked from this same scope resolves both the helper and the variables + # below it by ordinary scoping. + $probeMany = { + param($Sizes) + + $answers = @{} + $completed = @(Invoke-PCParallel -InputObject $Sizes -ScriptBlock $probeBody ` + -ArgumentList $extra -ThrottleLimit 8 -TimeoutSeconds $batchTimeout) - while ($high - $low -gt 1) { - $middle = [int](($low + $high) / 2) - if (& $probe $middle) { $low = $middle } else { $high = $middle } + foreach ($item in $completed) { + $answers[[int]$item.Input] = [bool]$item.Output } + $answers + } - $mtu = $low + 28 + # 1472 = 1500 - 28, the largest payload that fits a standard Ethernet MTU. + # The bracket is probed in one round: it answers "is the path standard", + # "does it answer ICMP at all", and "roughly where does it break" together. + $bracket = @(1472, 1400, 1280, 1024, 576, 0) + $answers = & $probeMany $bracket - [pscustomobject]@{ + if ($answers[1472]) { + return [pscustomobject]@{ PSTypeName = 'PCTools.MtuProbe' Target = $Target - PayloadSize = $low - Mtu = $mtu + PayloadSize = 1472 + Mtu = 1500 + Standard = $true + Detail = 'Full 1500-byte MTU path; no fragmentation issue.' + } + } + + if (-not $answers[0]) { + return [pscustomobject]@{ + PSTypeName = 'PCTools.MtuProbe' + Target = $Target + PayloadSize = $null + Mtu = $null Standard = $false - Detail = "Path MTU is $mtu, below the standard 1500. Large transfers and TLS handshakes can stall on a path like this; a PPPoE or VPN link is the usual cause." + Detail = "$Target does not answer ICMP at all, so the MTU cannot be probed this way." } } - finally { - $ping.Dispose() + + # Narrow to the pair the bracket straddles, then binary-search inside it. + # Sorted ascending, $low is the largest size that got through and $high the + # smallest that did not. + $sorted = $bracket | Sort-Object + $low = 0 + $high = 1472 + + foreach ($size in $sorted) { + if ($answers[$size]) { $low = $size } + else { $high = $size; break } + } + + while ($high - $low -gt 1) { + # Three probes per round instead of one: the search space shrinks by a + # factor of four per round for the same wall-clock cost as one probe. + $step = [int](($high - $low) / 4) + if ($step -lt 1) { $step = 1 } + + $candidates = @() + foreach ($multiplier in 1, 2, 3) { + $candidate = $low + ($step * $multiplier) + if ($candidate -gt $low -and $candidate -lt $high -and $candidates -notcontains $candidate) { + $candidates += $candidate + } + } + + if ($candidates.Count -eq 0) { break } + + $round = & $probeMany $candidates + + $movedLow = $low + $movedHigh = $high + foreach ($candidate in ($candidates | Sort-Object)) { + if ($round[$candidate]) { $movedLow = $candidate } + elseif ($movedHigh -eq $high -or $candidate -lt $movedHigh) { $movedHigh = $candidate; break } + } + + if ($movedLow -eq $low -and $movedHigh -eq $high) { break } + $low = $movedLow + $high = $movedHigh + } + + $mtu = $low + 28 + + [pscustomobject]@{ + PSTypeName = 'PCTools.MtuProbe' + Target = $Target + PayloadSize = $low + Mtu = $mtu + Standard = $false + Detail = "Path MTU is $mtu, below the standard 1500. Large transfers and TLS handshakes can stall on a path like this; a PPPoE or VPN link is the usual cause." } } diff --git a/src/PCTools/Public/Network/Test-PCRoute.ps1 b/src/PCTools/Public/Network/Test-PCRoute.ps1 index 47002ce..e077bcc 100644 --- a/src/PCTools/Public/Network/Test-PCRoute.ps1 +++ b/src/PCTools/Public/Network/Test-PCRoute.ps1 @@ -42,43 +42,66 @@ function Test-PCRoute { Write-PCLog -Level INFO -Message "Tracing route to $Target" - $ping = [System.Net.NetworkInformation.Ping]::new() - $buffer = [byte[]]::new(32) + # Every TTL is an independent probe, so send them all at once and sort the + # replies afterwards. Serially this walked up to MaxHops timeouts one after + # another - twenty hops at three seconds each is a minute of waiting to be + # told the third hop is where it stops. This is what tracert itself does. + $hopBody = { + param($Ttl, $Destination, $Timeout) - try { - for ($ttl = 1; $ttl -le $MaxHops; $ttl++) { - $options = [System.Net.NetworkInformation.PingOptions]::new($ttl, $true) + $ping = [System.Net.NetworkInformation.Ping]::new() + $buffer = [byte[]]::new(32) + $options = [System.Net.NetworkInformation.PingOptions]::new($Ttl, $true) + + try { + $reply = $ping.Send($Destination, $Timeout * 1000, $buffer, $options) - $reply = $null - $status = 'Unknown' $address = '*' + if ($reply.Address) { $address = $reply.Address.ToString() } + $latency = $null + if ($reply.Status -eq 'Success' -or $reply.Status -eq 'TtlExpired') { + $latency = $reply.RoundtripTime + } - try { - $reply = $ping.Send($Target, $TimeoutSeconds * 1000, $buffer, $options) - $status = $reply.Status.ToString() + @{ Address = $address; LatencyMs = $latency; Status = $reply.Status.ToString() } + } + catch { + @{ Address = '*'; LatencyMs = $null; Status = 'Error' } + } + finally { + $ping.Dispose() + } + } - if ($reply.Address) { $address = $reply.Address.ToString() } - if ($reply.Status -in 'Success', 'TtlExpired') { $latency = $reply.RoundtripTime } - } - catch { - $status = 'Error' - } + $completed = @(Invoke-PCParallel -InputObject (1..$MaxHops) -ScriptBlock $hopBody ` + -ArgumentList @($Target, $TimeoutSeconds) ` + -ThrottleLimit ([math]::Min(16, $MaxHops)) ` + -TimeoutSeconds ([math]::Max(30, $TimeoutSeconds * 4))) - [pscustomobject]@{ - PSTypeName = 'PCTools.RouteHop' - Hop = $ttl - Address = $address - LatencyMs = $latency - Status = $status - } + $byTtl = @{} + foreach ($item in $completed) { + if ($item.Output) { $byTtl[[int]$item.Input] = $item.Output } + } + + for ($ttl = 1; $ttl -le $MaxHops; $ttl++) { + $hop = $null + if ($byTtl.ContainsKey($ttl)) { $hop = $byTtl[$ttl] } - # Reaching the destination ends the trace; TtlExpired is an - # intermediate hop and means keep going. - if ($reply -and $reply.Status -eq 'Success') { break } + if (-not $hop) { + $hop = @{ Address = '*'; LatencyMs = $null; Status = 'Unknown' } } - } - finally { - $ping.Dispose() + + [pscustomobject]@{ + PSTypeName = 'PCTools.RouteHop' + Hop = $ttl + Address = $hop.Address + LatencyMs = $hop.LatencyMs + Status = $hop.Status + } + + # Reaching the destination ends the trace; every TTL beyond it was + # probed too, and reporting those would be noise. + if ($hop.Status -eq 'Success') { break } } } diff --git a/src/PCTools/Public/Network/Test-PCThroughput.ps1 b/src/PCTools/Public/Network/Test-PCThroughput.ps1 new file mode 100644 index 0000000..b78c8df --- /dev/null +++ b/src/PCTools/Public/Network/Test-PCThroughput.ps1 @@ -0,0 +1,142 @@ +function Test-PCThroughput { + <# + .SYNOPSIS + Measures download throughput against an HTTP endpoint. + + .DESCRIPTION + The speed test the README has promised since v0.1. Be clear about what + it measures: a single HTTP stream from one server. That is not the same + number a multi-stream speed test site reports, and it will usually be + lower - a single TCP connection is limited by latency and window size in + a way that eight parallel ones are not. + + What it is good for is the comparison. Run it, change something, run it + again. A link that manages 3 Mbit/s on a 200 Mbit/s connection has a + problem worth finding, and that conclusion holds regardless of the + single-stream caveat. + + Nothing is written to disk: the payload is counted and discarded as it + arrives. + + .PARAMETER Url + What to download. Defaults to a Cloudflare test endpoint. + + .PARAMETER Seconds + How long to measure for. + + .PARAMETER TimeoutSeconds + Give up if the endpoint does not respond. + + .EXAMPLE + Test-PCThroughput + + .EXAMPLE + Test-PCThroughput -Url 'https://speed.hetzner.de/100MB.bin' -Seconds 15 + + .OUTPUTS + PCTools.ThroughputResult + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string]$Url = 'https://speed.cloudflare.com/__down?bytes=104857600', + + [ValidateRange(3, 60)] + [int]$Seconds = 10, + + [ValidateRange(5, 120)] + [int]$TimeoutSeconds = 30 + ) + + Write-PCLog -Level INFO -Message "Measuring throughput from $Url" + + # TLS 1.2 is not the default on Windows PowerShell 5.1 and every endpoint + # worth testing against requires it. + try { + [System.Net.ServicePointManager]::SecurityProtocol = + [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 + } + catch { } + + $client = $null + $stream = $null + $response = $null + $bytes = 0L + $stopwatch = $null + $failure = '' + + try { + $client = [System.Net.Http.HttpClient]::new() + $client.Timeout = [timespan]::FromSeconds($TimeoutSeconds) + + # ResponseHeadersRead so measurement starts when the first byte lands + # rather than after the whole body has been buffered. + $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $Url) + $responseTask = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead) + + if (-not $responseTask.Wait($TimeoutSeconds * 1000)) { + throw "No response within $TimeoutSeconds seconds." + } + + $response = $responseTask.Result + if (-not $response.IsSuccessStatusCode) { + throw "The server returned $([int]$response.StatusCode) $($response.ReasonPhrase)." + } + + $streamTask = $response.Content.ReadAsStreamAsync() + if (-not $streamTask.Wait($TimeoutSeconds * 1000)) { + throw 'The response body did not start within the timeout.' + } + $stream = $streamTask.Result + + $buffer = [byte[]]::new(81920) + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $budget = [timespan]::FromSeconds($Seconds) + + while ($stopwatch.Elapsed -lt $budget) { + $readTask = $stream.ReadAsync($buffer, 0, $buffer.Length) + if (-not $readTask.Wait($TimeoutSeconds * 1000)) { break } + + $read = $readTask.Result + if ($read -le 0) { break } + $bytes += $read + } + + $stopwatch.Stop() + } + catch { + if ($stopwatch) { $stopwatch.Stop() } + $failure = $_.Exception.Message + if ($_.Exception.InnerException) { $failure = $_.Exception.InnerException.Message } + } + finally { + if ($stream) { try { $stream.Dispose() } catch { } } + if ($response) { try { $response.Dispose() } catch { } } + if ($client) { try { $client.Dispose() } catch { } } + } + + $elapsed = if ($stopwatch) { $stopwatch.Elapsed.TotalSeconds } else { 0 } + $mbps = if ($elapsed -gt 0 -and $bytes -gt 0) { [math]::Round((($bytes * 8) / $elapsed) / 1MB, 2) } else { $null } + + $detail = if ($failure) { + "Throughput could not be measured: $failure" + } + elseif (-not $mbps) { + 'No data was transferred.' + } + else { + 'Downloaded {0} in {1:n1} s: {2} Mbit/s on a single stream. A multi-stream speed test will usually report a higher figure.' -f + (Format-PCByteSize -Bytes $bytes), $elapsed, $mbps + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.ThroughputResult' + Url = $Url + Success = [bool]$mbps + BytesReceived = $bytes + BytesDisplay = Format-PCByteSize -Bytes $bytes + Seconds = [math]::Round($elapsed, 2) + Mbps = $mbps + Detail = $detail + } +} diff --git a/src/PCTools/Public/Software/Get-PCAppxPackage.ps1 b/src/PCTools/Public/Software/Get-PCAppxPackage.ps1 new file mode 100644 index 0000000..f5d201d --- /dev/null +++ b/src/PCTools/Public/Software/Get-PCAppxPackage.ps1 @@ -0,0 +1,94 @@ +function Get-PCAppxPackage { + <# + .SYNOPSIS + Lists installed Store-style apps and grades what is safe to remove. + + .DESCRIPTION + Get-AppxPackage returns 200 packages of which about 190 are framework + libraries, runtime components and things the shell needs to function. + This grades them: + + Removable - on the curated list of preinstalled consumer apps + Framework - a shared runtime; removing it breaks whatever uses it + System - part of the shell or a system surface + Other - installed by the user, so their business rather than ours + + Only Removable is offered to Remove-PCAppxPackage. Nothing on the list + was included because a blog post said so; each one is a preinstalled + consumer application whose removal has no effect on anything else. + + .PARAMETER RemovableOnly + Return only packages on the curated removable list. + + .PARAMETER AllUsers + Report packages for every user. Needs elevation. + + .EXAMPLE + Get-PCAppxPackage -RemovableOnly | Format-Table DisplayName, Name, SizeDisplay + + .OUTPUTS + PCTools.AppxPackage + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [switch]$RemovableOnly, + + [switch]$AllUsers + ) + + if (-not (Get-Command Get-AppxPackage -ErrorAction SilentlyContinue)) { + Write-PCLog -Level WARN -Message 'Get-AppxPackage is not available on this system.' + return + } + + $removable = Get-PCRemovableAppxName + + $packages = @() + try { + $packages = if ($AllUsers) { + Assert-PCAdmin -Action 'Listing packages for all users' + @(Get-AppxPackage -AllUsers -ErrorAction Stop) + } + else { + @(Get-AppxPackage -ErrorAction Stop) + } + } + catch { + Write-PCLog -Level WARN -Message "Could not enumerate packages: $($_.Exception.Message)" + return + } + + foreach ($package in $packages) { + $category = if ($package.IsFramework) { 'Framework' } + elseif ($removable -contains $package.Name) { 'Removable' } + elseif ($package.SignatureKind -eq 'System' -or $package.NonRemovable) { 'System' } + else { 'Other' } + + if ($RemovableOnly -and $category -ne 'Removable') { continue } + + $size = 0L + try { + if ($package.InstallLocation -and (Test-Path -LiteralPath $package.InstallLocation)) { + foreach ($file in [System.IO.Directory]::EnumerateFiles( + $package.InstallLocation, '*', [System.IO.SearchOption]::AllDirectories)) { + try { $size += ([System.IO.FileInfo]$file).Length } catch { continue } + } + } + } + catch { } + + [pscustomobject]@{ + PSTypeName = 'PCTools.AppxPackage' + Name = $package.Name + DisplayName = if ($package.DisplayName) { $package.DisplayName } else { $package.Name } + Publisher = $package.Publisher + Version = [string]$package.Version + Category = $category + CanRemove = $category -eq 'Removable' + InstallLocation = $package.InstallLocation + Size = $size + SizeDisplay = Format-PCByteSize -Bytes $size + } + } +} diff --git a/src/PCTools/Public/Software/Get-PCDriverIssue.ps1 b/src/PCTools/Public/Software/Get-PCDriverIssue.ps1 new file mode 100644 index 0000000..a707c93 --- /dev/null +++ b/src/PCTools/Public/Software/Get-PCDriverIssue.ps1 @@ -0,0 +1,129 @@ +function Get-PCDriverIssue { + <# + .SYNOPSIS + Finds devices Windows is reporting a problem with. + + .DESCRIPTION + Device Manager marks a faulty device with a yellow triangle and hides + the reason behind two clicks and a numeric code. The code is the useful + part - "code 28" and "code 43" are entirely different problems with + entirely different fixes - so it is decoded here. + + Also reports drivers dated more than three years ago, which is a weak + signal on its own (a stable driver may be legitimately old) and so is + reported separately from actual faults rather than mixed in with them. + + .PARAMETER IncludeOldDrivers + Also list drivers older than the age threshold. + + .PARAMETER OldDriverYears + How old a driver must be to be listed. Defaults to 3. + + .EXAMPLE + Get-PCDriverIssue | Format-Table Name, Status, ProblemCode, Problem + + .OUTPUTS + PCTools.DriverIssue + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [switch]$IncludeOldDrivers, + + [ValidateRange(1, 20)] + [int]$OldDriverYears = 3 + ) + + # The Configuration Manager error codes that actually turn up on a consumer + # machine, with what each one means in practice. + $problemText = @{ + 1 = 'Not configured correctly. Reinstall the driver.' + 3 = 'The driver may be corrupted, or the system is low on memory.' + 10 = 'The device cannot start. Usually a driver or firmware fault.' + 12 = 'Not enough free resources. Two devices are contending for the same ones.' + 14 = 'The device needs a restart to work.' + 16 = 'Windows cannot identify all the resources this device uses.' + 18 = 'Reinstall the drivers for this device.' + 19 = 'The registry entry for this device is incomplete or damaged.' + 21 = 'Windows is removing this device.' + 22 = 'This device is disabled.' + 24 = 'The device is not present, is not working properly, or does not have all its drivers installed.' + 28 = 'The drivers for this device are not installed.' + 29 = 'Disabled because the firmware did not give it the resources it needs.' + 31 = 'This device is not working properly because Windows cannot load the required drivers.' + 32 = 'A driver for this device has been disabled. An alternate driver may be providing the function.' + 33 = 'Windows cannot determine which resources are required for this device.' + 34 = 'Windows cannot determine the settings for this device. Configure it in the firmware.' + 35 = 'The firmware does not include enough information to configure and use this device properly.' + 36 = 'This device is requesting a PCI interrupt but is configured for an ISA interrupt.' + 37 = 'Windows cannot initialize the device driver for this hardware.' + 38 = 'A previous instance of the driver is still in memory. Restart to clear it.' + 39 = 'The driver is corrupted, or missing.' + 40 = 'The service key information in the registry is missing or recorded incorrectly.' + 41 = 'Windows loaded the driver but cannot find the hardware.' + 42 = 'A duplicate device is already running.' + 43 = 'Windows stopped this device because it reported problems.' + 44 = 'An application or service shut this device down.' + 45 = 'This device is not connected.' + 47 = 'The device is prepared for safe removal but has not been removed.' + 48 = 'The software for this device is blocked from starting because it is known to have problems.' + 49 = 'Windows cannot start new hardware devices because the system hive is too large.' + 52 = 'Windows cannot verify the digital signature for the drivers required by this device.' + } + + try { + $devices = @(Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction Stop | + Where-Object { $_.ConfigManagerErrorCode -and [int]$_.ConfigManagerErrorCode -ne 0 }) + } + catch { + Write-PCLog -Level WARN -Message "Could not enumerate devices: $($_.Exception.Message)" + return + } + + foreach ($device in $devices) { + $code = [int]$device.ConfigManagerErrorCode + + [pscustomobject]@{ + PSTypeName = 'PCTools.DriverIssue' + Name = $device.Name + DeviceId = $device.DeviceID + Manufacturer = $device.Manufacturer + Status = 'Problem' + ProblemCode = $code + Problem = if ($problemText.ContainsKey($code)) { $problemText[$code] } else { "Configuration Manager error code $code." } + DriverDate = $null + DriverVersion = '' + } + } + + if (-not $IncludeOldDrivers) { return } + + $cutoff = (Get-Date).AddYears(-$OldDriverYears) + + try { + foreach ($driver in @(Get-CimInstance -ClassName Win32_PnPSignedDriver -ErrorAction Stop)) { + if (-not $driver.DriverDate) { continue } + if ($driver.DriverDate -ge $cutoff) { continue } + + # Microsoft's own inbox drivers are old by design and being old is + # not a fault in them, so they would be pure noise here. + if ($driver.DriverProviderName -eq 'Microsoft') { continue } + if (-not $driver.DeviceName) { continue } + + [pscustomobject]@{ + PSTypeName = 'PCTools.DriverIssue' + Name = $driver.DeviceName + DeviceId = $driver.DeviceID + Manufacturer = $driver.DriverProviderName + Status = 'Old' + ProblemCode = $null + Problem = 'Driver dates from {0:yyyy-MM-dd}. Old is not the same as broken; check the vendor only if this device misbehaves.' -f $driver.DriverDate + DriverDate = $driver.DriverDate + DriverVersion = $driver.DriverVersion + } + } + } + catch { + Write-PCLog -Level DEBUG -Message "Could not enumerate signed drivers: $($_.Exception.Message)" + } +} diff --git a/src/PCTools/Public/Software/Get-PCWindowsUpdate.ps1 b/src/PCTools/Public/Software/Get-PCWindowsUpdate.ps1 new file mode 100644 index 0000000..237d4c7 --- /dev/null +++ b/src/PCTools/Public/Software/Get-PCWindowsUpdate.ps1 @@ -0,0 +1,103 @@ +function Get-PCWindowsUpdate { + <# + .SYNOPSIS + Lists the Windows updates that apply to this machine but are not + installed. + + .DESCRIPTION + Uses the Microsoft.Update.Session COM API directly rather than depending + on the PSWindowsUpdate module from the Gallery. That is a deliberate + trade: the COM API is more verbose to drive, but it is present on every + Windows machine, needs no install, and adds no third-party code to a + tool that runs elevated. + + Searching contacts Windows Update and can take a minute on a machine + that has not checked in for a while. Nothing is downloaded or installed. + + .PARAMETER IncludeHidden + Include updates a user has hidden. Hidden usually means deliberately + declined, so they are excluded by default. + + .PARAMETER IncludeDriver + Include driver updates. Windows Update drivers are frequently older than + what the vendor ships, so they are excluded by default. + + .PARAMETER TimeoutSeconds + Give up on the search after this long. + + .EXAMPLE + Get-PCWindowsUpdate | Format-Table Title, SizeDisplay, RebootRequired + + .OUTPUTS + PCTools.WindowsUpdate + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [switch]$IncludeHidden, + + [switch]$IncludeDriver, + + [ValidateRange(30, 1800)] + [int]$TimeoutSeconds = 300 + ) + + if (-not $script:IsWindowsPlatform) { + Write-PCLog -Level WARN -Message 'Windows Update is only available on Windows.' + return + } + + Write-PCLog -Level INFO -Message 'Searching Windows Update' + + $session = $null + try { + $session = New-Object -ComObject 'Microsoft.Update.Session' + } + catch { + Write-PCLog -Level WARN -Message "Could not create a Windows Update session: $($_.Exception.Message)" + return + } + + try { + $searcher = $session.CreateUpdateSearcher() + + # IsInstalled=0 and IsHidden filter server-side, which is much faster + # than pulling everything back and filtering here. + $criteria = "IsInstalled=0 and IsHidden=$(if ($IncludeHidden) { '1' } else { '0' })" + if (-not $IncludeDriver) { $criteria += " and Type='Software'" } + + $result = $searcher.Search($criteria) + + foreach ($update in $result.Updates) { + $size = 0L + try { $size = [long]$update.MaxDownloadSize } catch { } + + $categories = @() + try { foreach ($category in $update.Categories) { $categories += $category.Name } } catch { } + + $knowledgeBase = @() + try { foreach ($article in $update.KBArticleIDs) { $knowledgeBase += "KB$article" } } catch { } + + [pscustomobject]@{ + PSTypeName = 'PCTools.WindowsUpdate' + Title = $update.Title + UpdateId = $update.Identity.UpdateID + KB = $knowledgeBase -join ', ' + Categories = $categories + Size = $size + SizeDisplay = Format-PCByteSize -Bytes $size + RebootRequired = [bool]$update.RebootRequired + Mandatory = [bool]$update.IsMandatory + Description = $update.Description + } + } + } + catch { + Write-PCLog -Level WARN -Message "Windows Update search failed: $($_.Exception.Message)" + } + finally { + if ($session) { + try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($session) } catch { } + } + } +} diff --git a/src/PCTools/Public/Software/Install-PCWindowsUpdate.ps1 b/src/PCTools/Public/Software/Install-PCWindowsUpdate.ps1 new file mode 100644 index 0000000..37f25ad --- /dev/null +++ b/src/PCTools/Public/Software/Install-PCWindowsUpdate.ps1 @@ -0,0 +1,122 @@ +function Install-PCWindowsUpdate { + <# + .SYNOPSIS + Downloads and installs pending Windows updates. + + .DESCRIPTION + The counterpart to Get-PCWindowsUpdate, over the same COM API. Updates + are accepted, downloaded and installed in one pass, and the result says + whether a restart is needed rather than triggering one - restarting + somebody's machine without asking is not a maintenance tool's decision + to make. + + This never installs a driver update unless one is named explicitly. + Windows Update drivers routinely supersede a working vendor driver with + an older generic one, and doing that unattended is how a maintenance run + breaks somebody's audio. + + .PARAMETER UpdateId + Install only these update IDs, from Get-PCWindowsUpdate. + + .PARAMETER TimeoutMinutes + Overall budget for download and installation. + + .EXAMPLE + Install-PCWindowsUpdate -WhatIf + + .EXAMPLE + Get-PCWindowsUpdate | Where-Object Title -like '*Cumulative*' | + ForEach-Object { Install-PCWindowsUpdate -UpdateId $_.UpdateId } + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + [OutputType([pscustomobject])] + param( + [string[]]$UpdateId, + + [ValidateRange(5, 480)] + [int]$TimeoutMinutes = 120 + ) + + Invoke-PCAction -Name 'Install-PCWindowsUpdate' -Body { + Assert-PCAdmin -Action 'Installing Windows updates' + + $session = New-Object -ComObject 'Microsoft.Update.Session' + + try { + $searcher = $session.CreateUpdateSearcher() + $result = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'") + + $wanted = New-Object -ComObject 'Microsoft.Update.UpdateColl' + $titles = @() + + foreach ($update in $result.Updates) { + if ($UpdateId -and $UpdateId -notcontains $update.Identity.UpdateID) { continue } + + if (-not $update.EulaAccepted) { + try { $update.AcceptEula() } catch { } + } + + [void]$wanted.Add($update) + $titles += $update.Title + } + + if ($wanted.Count -eq 0) { + return @{ Status = 'Skipped'; Detail = 'No applicable updates are pending.' } + } + + if (-not $PSCmdlet.ShouldProcess("$($wanted.Count) update(s)", 'Download and install')) { + return @{ + Status = 'Skipped' + Detail = '{0} update(s) would be installed: {1}' -f $wanted.Count, ($titles -join '; ') + Data = @{ Pending = $titles } + } + } + + Write-PCLog -Level INFO -Message "Downloading $($wanted.Count) update(s)" + + $downloader = $session.CreateUpdateDownloader() + $downloader.Updates = $wanted + $downloadResult = $downloader.Download() + + # 2 is orcSucceeded, 3 is orcSucceededWithErrors. + if ($downloadResult.ResultCode -notin 2, 3) { + return @{ + Status = 'Failed' + Detail = "Download failed with result code $($downloadResult.ResultCode)." + Data = @{ Pending = $titles } + } + } + + Write-PCLog -Level INFO -Message "Installing $($wanted.Count) update(s)" + + $installer = $session.CreateUpdateInstaller() + $installer.Updates = $wanted + $installResult = $installer.Install() + + $installed = @() + $failed = @() + for ($i = 0; $i -lt $wanted.Count; $i++) { + $code = $installResult.GetUpdateResult($i).ResultCode + if ($code -in 2, 3) { $installed += $titles[$i] } else { $failed += "$($titles[$i]) (code $code)" } + } + + @{ + Status = if ($failed.Count -eq 0) { 'Success' } + elseif ($installed.Count -gt 0) { 'Warning' } + else { 'Failed' } + Detail = '{0} installed, {1} failed{2}' -f $installed.Count, $failed.Count, + $(if ($installResult.RebootRequired) { '; a restart is required' } else { '' }) + RebootRequired = [bool]$installResult.RebootRequired + Data = @{ Installed = $installed; Failed = $failed } + } + } + finally { + if ($session) { + try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($session) } catch { } + } + } + } +} diff --git a/src/PCTools/Public/Software/Remove-PCAppxPackage.ps1 b/src/PCTools/Public/Software/Remove-PCAppxPackage.ps1 new file mode 100644 index 0000000..4ad2fca --- /dev/null +++ b/src/PCTools/Public/Software/Remove-PCAppxPackage.ps1 @@ -0,0 +1,133 @@ +function Remove-PCAppxPackage { + <# + .SYNOPSIS + Removes preinstalled apps from the curated removable list. Nothing else. + + .DESCRIPTION + Every package named is checked against Get-PCRemovableAppxName and + refused if it is not on it. There is no -Force to override that, on + purpose: the failure mode of debloating scripts is removing something + load-bearing, and an escape hatch here would be used by exactly the + people who should not use it. Remove-AppxPackage is still available for + anyone who genuinely means it. + + Removal is per-user by default. -AllUsers also removes the provisioned + copy, so new accounts on the machine do not get the app back - which is + usually what somebody actually wants and almost never what they get. + + .PARAMETER Name + Package names to remove, from Get-PCAppxPackage. + + .PARAMETER All + Remove everything on the curated list that is currently installed. + + .PARAMETER AllUsers + Also remove the provisioned package, so new user accounts do not receive + it. Needs elevation. + + .EXAMPLE + Get-PCAppxPackage -RemovableOnly | Format-Table DisplayName, SizeDisplay + Remove-PCAppxPackage -Name 'Microsoft.BingWeather' -WhatIf + + .EXAMPLE + Remove-PCAppxPackage -All -WhatIf + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Name')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory, Position = 0, ParameterSetName = 'Name')] + [string[]]$Name, + + [Parameter(Mandatory, ParameterSetName = 'All')] + [switch]$All, + + [switch]$AllUsers + ) + + Invoke-PCAction -Name 'Remove-PCAppxPackage' -Body { + if (-not (Get-Command Get-AppxPackage -ErrorAction SilentlyContinue)) { + return @{ Status = 'Failed'; Detail = 'Appx cmdlets are not available on this system.' } + } + + if ($AllUsers) { Assert-PCAdmin -Action 'Removing a provisioned package' } + + $allowed = Get-PCRemovableAppxName + $installed = @(Get-PCAppxPackage -RemovableOnly) + + # @() around the whole expression: an `if` that yields a one-element + # array assigns a scalar, and .Count on it then throws under strict + # mode. Removing one named package is the normal case. + $targets = @(if ($All) { + $installed + } + else { + @($installed | Where-Object { $Name -contains $_.Name }) + }) + + # Anything asked for by name that is not on the allowlist is refused + # loudly rather than silently ignored - the user needs to know their + # instruction was not carried out, and why. + $refused = @() + if ($Name) { + foreach ($requested in $Name) { + if ($allowed -notcontains $requested) { $refused += $requested } + } + } + + if ($targets.Count -eq 0 -and $refused.Count -eq 0) { + return @{ Status = 'Skipped'; Detail = 'No matching removable package is installed.' } + } + + $removed = @() + $failed = @() + $bytes = 0L + + foreach ($package in $targets) { + if (-not $PSCmdlet.ShouldProcess($package.DisplayName, 'Remove app')) { continue } + + try { + Get-AppxPackage -Name $package.Name -ErrorAction Stop | + Remove-AppxPackage -ErrorAction Stop + + $removed += $package.DisplayName + $bytes += [long]$package.Size + Write-PCLog -Level INFO -Message "Removed app: $($package.Name)" + } + catch { + $failed += "$($package.Name) ($($_.Exception.Message))" + continue + } + + if ($AllUsers) { + try { + Get-AppxProvisionedPackage -Online -ErrorAction Stop | + Where-Object { $_.DisplayName -eq $package.Name } | + ForEach-Object { Remove-AppxProvisionedPackage -Online -PackageName $_.PackageName -ErrorAction Stop | Out-Null } + } + catch { + Write-PCLog -Level WARN -Message "Removed for this user, but the provisioned copy of $($package.Name) remains: $($_.Exception.Message)" + } + } + } + + $parts = @() + if ($removed.Count) { $parts += '{0} removed: {1}' -f $removed.Count, ($removed -join ', ') } + if ($refused.Count) { + $parts += 'refused as not on the removable list: {0}' -f ($refused -join ', ') + } + if ($failed.Count) { $parts += '{0} failed: {1}' -f $failed.Count, ($failed -join ', ') } + + @{ + Status = if ($failed.Count -eq 0 -and $refused.Count -eq 0 -and $removed.Count -gt 0) { 'Success' } + elseif ($removed.Count -gt 0) { 'Warning' } + elseif ($refused.Count -gt 0 -or $failed.Count -gt 0) { 'Failed' } + else { 'Skipped' } + Detail = if ($parts.Count) { $parts -join '; ' } else { 'Nothing to do.' } + BytesFreed = $bytes + Data = @{ Removed = $removed; Refused = $refused; Failed = $failed } + } + } +} diff --git a/src/PCTools/Public/Software/Update-PCApplication.ps1 b/src/PCTools/Public/Software/Update-PCApplication.ps1 new file mode 100644 index 0000000..9554536 --- /dev/null +++ b/src/PCTools/Public/Software/Update-PCApplication.ps1 @@ -0,0 +1,148 @@ +function Update-PCApplication { + <# + .SYNOPSIS + Upgrades installed applications through winget. + + .DESCRIPTION + Out-of-date applications are a bigger practical security exposure on a + home machine than missing Windows patches, and winget already knows how + to fix that for anything it manages. + + Two things this does that a bare `winget upgrade --all` does not: it + reports per-package outcomes as structured results, and it skips + packages pinned with `winget pin` rather than fighting them. + + .PARAMETER Id + Upgrade only these package IDs. + + .PARAMETER Exclude + Package IDs to leave alone. Useful for the one application whose + auto-update you do not trust. + + .PARAMETER ListOnly + Report what is out of date without upgrading anything. Equivalent to + -WhatIf, and kept because it reads better in a script. + + .PARAMETER TimeoutMinutes + Per-package timeout. + + .EXAMPLE + Update-PCApplication -ListOnly + + .EXAMPLE + Update-PCApplication -Exclude 'Mozilla.Firefox' + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [OutputType([pscustomobject])] + param( + [string[]]$Id, + + [string[]]$Exclude = @(), + + [switch]$ListOnly, + + [ValidateRange(1, 120)] + [int]$TimeoutMinutes = 20 + ) + + Invoke-PCAction -Name 'Update-PCApplication' -Body { + if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) { + return @{ + Status = 'Failed' + Detail = 'winget is not available. Install "App Installer" from the Microsoft Store, then try again.' + Data = @{ WingetAvailable = $false } + } + } + + $list = Invoke-PCProcess -FilePath 'winget.exe' -ArgumentList @( + 'upgrade', '--include-unknown', '--accept-source-agreements', '--disable-interactivity' + ) -TimeoutSeconds 180 + + if ($list.TimedOut) { + return @{ Status = 'Failed'; Detail = 'winget did not respond within 3 minutes.' } + } + + # winget writes a fixed-width table with no machine-readable option that + # is stable across versions. The Id column is the only field needed and + # it is reliably the second whitespace-separated token on a data row. + $available = @() + foreach ($line in ($list.Output -split "`r?`n")) { + if ($line -match '^\s*$') { continue } + if ($line -match '^\s*(Name|-{3,})') { continue } + if ($line -match 'upgrades available|No installed package|package\(s\) have') { continue } + + $columns = $line -split '\s{2,}' | Where-Object { $_ } + if ($columns.Count -lt 4) { continue } + + $packageId = $columns[1].Trim() + if ($packageId -notmatch '\.') { continue } + + $available += [pscustomobject]@{ + Name = $columns[0].Trim() + Id = $packageId + Version = $columns[2].Trim() + Available = $columns[3].Trim() + } + } + + if ($Id) { $available = @($available | Where-Object { $Id -contains $_.Id }) } + if ($Exclude.Count) { $available = @($available | Where-Object { $Exclude -notcontains $_.Id }) } + + if ($available.Count -eq 0) { + return @{ Status = 'Success'; Detail = 'Everything winget manages is up to date.'; Data = @{ Upgraded = @() } } + } + + if ($ListOnly) { + return @{ + Status = 'Success' + Detail = '{0} package(s) out of date: {1}' -f $available.Count, (($available.Id) -join ', ') + Data = @{ Available = $available } + } + } + + $upgraded = @() + $failed = @() + $skipped = @() + + foreach ($package in $available) { + if (-not $PSCmdlet.ShouldProcess($package.Id, "Upgrade $($package.Version) to $($package.Available)")) { + $skipped += $package.Id + continue + } + + Write-PCLog -Level INFO -Message "winget upgrade $($package.Id)" + + $run = Invoke-PCProcess -FilePath 'winget.exe' -ArgumentList @( + 'upgrade', '--id', $package.Id, '--exact', '--silent', + '--accept-source-agreements', '--accept-package-agreements', + '--disable-interactivity' + ) -TimeoutSeconds ($TimeoutMinutes * 60) + + # 0x8A15002B: no applicable upgrade, which after a successful + # upgrade elsewhere in the run is a normal outcome, not a failure. + $noUpgrade = $run.ExitCode -eq -1978335189 + + if ($run.TimedOut) { $failed += "$($package.Id) (timed out)" } + elseif ($run.ExitCode -eq 0) { $upgraded += $package.Id } + elseif ($noUpgrade) { $skipped += $package.Id } + else { + $failed += "$($package.Id) (exit $($run.ExitCode))" + Write-PCLog -Level WARN -Message "winget upgrade failed for $($package.Id): $($run.Output)" + } + } + + $parts = @() + if ($upgraded.Count) { $parts += "$($upgraded.Count) upgraded" } + if ($skipped.Count) { $parts += "$($skipped.Count) skipped" } + if ($failed.Count) { $parts += "$($failed.Count) failed: $($failed -join ', ')" } + + @{ + Status = if ($failed.Count -eq 0) { 'Success' } elseif ($upgraded.Count) { 'Warning' } else { 'Failed' } + Detail = if ($parts.Count) { $parts -join '; ' } else { 'Nothing to do.' } + Data = @{ Upgraded = $upgraded; Skipped = $skipped; Failed = $failed } + } + } +} diff --git a/src/PCTools/Public/Start-PCTools.ps1 b/src/PCTools/Public/Start-PCTools.ps1 new file mode 100644 index 0000000..28fb48f --- /dev/null +++ b/src/PCTools/Public/Start-PCTools.ps1 @@ -0,0 +1,86 @@ +function Start-PCTools { + <# + .SYNOPSIS + Opens the PC Tools window. + + .DESCRIPTION + The GUI ships inside the module, so this works from a PowerShell Gallery + install with nothing else downloaded: + + Install-Module PCTools -Scope CurrentUser + Start-PCTools + + WinForms needs a single-threaded apartment. The shell relaunches itself + under one when the host does not provide it, which is what makes this + callable from pwsh.exe as well as powershell.exe. + + .PARAMETER Theme + Dark or Light. Defaults to Dark. + + .PARAMETER Page + The page to open on. Defaults to the dashboard. + + .PARAMETER Elevate + Relaunch elevated. Repair, network and system-cache actions need + administrator rights; the window runs happily without them and tells you + which actions are unavailable. + + .EXAMPLE + Start-PCTools + + .EXAMPLE + Start-PCTools -Theme Light -Page Network + + .EXAMPLE + pctools + + The alias, for when the window is all you want. + #> + [CmdletBinding()] + [Alias('pctools')] + [OutputType([void])] + param( + [ValidateSet('Dark', 'Light')] + [string]$Theme = 'Dark', + + [ValidateSet('Dashboard', 'Maintenance', 'Storage', 'Health', 'Network', 'Software', 'Preferences', 'Log')] + [string]$Page = 'Dashboard', + + [switch]$Elevate + ) + + $shellPath = Join-Path $PSScriptRoot '..\Shell\Start-PCToolsShell.ps1' + $shellPath = [System.IO.Path]::GetFullPath($shellPath) + + if (-not (Test-Path -LiteralPath $shellPath)) { + throw "The PC Tools shell is missing from the module. Expected it at: $shellPath" + } + + if (-not $script:IsWindowsPlatform) { + throw 'The PC Tools window needs Windows and WinForms. The commands themselves run anywhere the actions make sense; see Get-Command -Module PCTools.' + } + + if ($Elevate -and -not (Test-PCAdmin)) { + Write-PCLog -Level INFO -Message 'Relaunching PC Tools elevated.' + + $arguments = @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', + '-File', ('"{0}"' -f $shellPath), + '-Theme', $Theme, '-Page', $Page + ) + + try { + Start-Process -FilePath 'powershell.exe' -ArgumentList $arguments -Verb RunAs -ErrorAction Stop + } + catch { + # A declined UAC prompt is a choice, not a failure worth a stack trace. + Write-Warning "Elevation was declined or failed: $($_.Exception.Message)" + } + return + } + + # Run in a child scope. The shell defines several dozen UI helpers and holds + # the form for the lifetime of the window; dot-sourcing it would leave all + # of that behind in the module scope after the window closed. + & $shellPath -Theme $Theme -Page $Page +} diff --git a/src/PCTools/Public/Startup/Disable-PCStartupItem.ps1 b/src/PCTools/Public/Startup/Disable-PCStartupItem.ps1 new file mode 100644 index 0000000..9a4565b --- /dev/null +++ b/src/PCTools/Public/Startup/Disable-PCStartupItem.ps1 @@ -0,0 +1,111 @@ +function Disable-PCStartupItem { + <# + .SYNOPSIS + Switches off a startup item, reversibly. + + .DESCRIPTION + Reversibility is the whole design. Deleting a Run key entry is + irreversible and loses the command line; this writes the same + StartupApproved flag Task Manager writes, so the entry stays where it + is and Enable-PCStartupItem puts it back. Scheduled tasks are disabled + rather than unregistered for the same reason. + + Pass names from Get-PCStartupItem. + + .PARAMETER Name + The startup item names to disable, as reported by Get-PCStartupItem. + + .PARAMETER InputObject + Startup items straight from Get-PCStartupItem, so the two compose. + + .EXAMPLE + Get-PCStartupItem | Where-Object Name -like 'Spotify*' | Disable-PCStartupItem -WhatIf + + .EXAMPLE + Disable-PCStartupItem -Name 'OneDrive' + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'Name')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory, Position = 0, ParameterSetName = 'Name')] + [string[]]$Name, + + [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Object')] + [object[]]$InputObject + ) + + begin { + $targets = [System.Collections.Generic.List[object]]::new() + } + + process { + if ($PSCmdlet.ParameterSetName -eq 'Object') { + foreach ($item in $InputObject) { $targets.Add($item) } + } + } + + end { + Invoke-PCAction -Name 'Disable-PCStartupItem' -Body { + # @() around the whole expression: an `if` that yields a + # one-element array assigns a scalar, and .Count on it then throws + # under strict mode. Disabling a single item is the usual case. + $items = @(if ($targets.Count -gt 0) { + @($targets) + } + else { + $all = @(Get-PCStartupItem) + @($all | Where-Object { $Name -contains $_.Name }) + }) + + if ($items.Count -eq 0) { + return @{ + Status = 'Warning' + Detail = 'No matching startup item. Run Get-PCStartupItem to see the names.' + } + } + + $disabled = @() + $failed = @() + $already = @() + + foreach ($item in $items) { + if (-not $item.Enabled) { $already += $item.Name; continue } + if (-not $PSCmdlet.ShouldProcess($item.Name, "Disable ($($item.Source))")) { continue } + + try { + if ($item.Source -eq 'ScheduledTask') { + $path = Split-Path -Parent $item.Id + if (-not $path.EndsWith('\')) { $path = "$path\" } + Disable-ScheduledTask -TaskName $item.Name -TaskPath $path -ErrorAction Stop | Out-Null + } + else { + Set-PCStartupApproval -Name $item.Id -Source $item.Source -Scope $item.Scope -Enabled $false + } + + $disabled += $item.Name + Write-PCLog -Level INFO -Message "Disabled startup item: $($item.Name)" + } + catch { + $failed += "$($item.Name) ($($_.Exception.Message))" + } + } + + $parts = @() + if ($disabled.Count) { $parts += '{0} disabled: {1}' -f $disabled.Count, ($disabled -join ', ') } + if ($already.Count) { $parts += '{0} already off' -f $already.Count } + if ($failed.Count) { $parts += '{0} failed: {1}' -f $failed.Count, ($failed -join ', ') } + + @{ + Status = if ($failed.Count -eq 0 -and $disabled.Count -gt 0) { 'Success' } + elseif ($disabled.Count -gt 0) { 'Warning' } + elseif ($failed.Count -gt 0) { 'Failed' } + else { 'Skipped' } + Detail = if ($parts.Count) { $parts -join '; ' } else { 'Nothing to do.' } + Data = @{ Disabled = $disabled; AlreadyDisabled = $already; Failed = $failed } + } + } + } +} diff --git a/src/PCTools/Public/Startup/Enable-PCStartupItem.ps1 b/src/PCTools/Public/Startup/Enable-PCStartupItem.ps1 new file mode 100644 index 0000000..d56aacb --- /dev/null +++ b/src/PCTools/Public/Startup/Enable-PCStartupItem.ps1 @@ -0,0 +1,102 @@ +function Enable-PCStartupItem { + <# + .SYNOPSIS + Switches a startup item back on. + + .DESCRIPTION + The other half of Disable-PCStartupItem. Because disabling never removed + anything, this is a flag flip rather than a reinstall. + + .PARAMETER Name + The startup item names to enable, as reported by Get-PCStartupItem. + + .PARAMETER InputObject + Startup items straight from Get-PCStartupItem. + + .EXAMPLE + Enable-PCStartupItem -Name 'OneDrive' + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low', DefaultParameterSetName = 'Name')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory, Position = 0, ParameterSetName = 'Name')] + [string[]]$Name, + + [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Object')] + [object[]]$InputObject + ) + + begin { + $targets = [System.Collections.Generic.List[object]]::new() + } + + process { + if ($PSCmdlet.ParameterSetName -eq 'Object') { + foreach ($item in $InputObject) { $targets.Add($item) } + } + } + + end { + Invoke-PCAction -Name 'Enable-PCStartupItem' -Body { + # See Disable-PCStartupItem: @() around the whole expression so a + # single match stays an array. + $items = @(if ($targets.Count -gt 0) { + @($targets) + } + else { + $all = @(Get-PCStartupItem) + @($all | Where-Object { $Name -contains $_.Name }) + }) + + if ($items.Count -eq 0) { + return @{ + Status = 'Warning' + Detail = 'No matching startup item. Run Get-PCStartupItem to see the names.' + } + } + + $enabled = @() + $failed = @() + $already = @() + + foreach ($item in $items) { + if ($item.Enabled) { $already += $item.Name; continue } + if (-not $PSCmdlet.ShouldProcess($item.Name, "Enable ($($item.Source))")) { continue } + + try { + if ($item.Source -eq 'ScheduledTask') { + $path = Split-Path -Parent $item.Id + if (-not $path.EndsWith('\')) { $path = "$path\" } + Enable-ScheduledTask -TaskName $item.Name -TaskPath $path -ErrorAction Stop | Out-Null + } + else { + Set-PCStartupApproval -Name $item.Id -Source $item.Source -Scope $item.Scope -Enabled $true + } + + $enabled += $item.Name + Write-PCLog -Level INFO -Message "Enabled startup item: $($item.Name)" + } + catch { + $failed += "$($item.Name) ($($_.Exception.Message))" + } + } + + $parts = @() + if ($enabled.Count) { $parts += '{0} enabled: {1}' -f $enabled.Count, ($enabled -join ', ') } + if ($already.Count) { $parts += '{0} already on' -f $already.Count } + if ($failed.Count) { $parts += '{0} failed: {1}' -f $failed.Count, ($failed -join ', ') } + + @{ + Status = if ($failed.Count -eq 0 -and $enabled.Count -gt 0) { 'Success' } + elseif ($enabled.Count -gt 0) { 'Warning' } + elseif ($failed.Count -gt 0) { 'Failed' } + else { 'Skipped' } + Detail = if ($parts.Count) { $parts -join '; ' } else { 'Nothing to do.' } + Data = @{ Enabled = $enabled; AlreadyEnabled = $already; Failed = $failed } + } + } + } +} diff --git a/src/PCTools/Public/Startup/Get-PCService.ps1 b/src/PCTools/Public/Startup/Get-PCService.ps1 new file mode 100644 index 0000000..7d6c436 --- /dev/null +++ b/src/PCTools/Public/Startup/Get-PCService.ps1 @@ -0,0 +1,91 @@ +function Get-PCService { + <# + .SYNOPSIS + Lists services with their startup type and what they belong to. + + .DESCRIPTION + Get-Service tells you a service is running. It does not tell you whether + it starts automatically, what binary is behind it, or whether it is part + of Windows - which are the three things anybody deciding what to switch + off actually needs. + + Every row is graded: Critical services are on the refusal list that + Set-PCServiceStartup will not touch, Windows services ship with the OS, + and ThirdParty is everything else. The last group is where the wins are. + + .PARAMETER Name + Limit to these service names. + + .PARAMETER StartupType + Limit to services with this startup type. + + .PARAMETER ThirdPartyOnly + Show only services that did not come with Windows. + + .EXAMPLE + Get-PCService -ThirdPartyOnly | Where-Object StartupType -eq 'Automatic' | + Format-Table Name, DisplayName, Status, Path + + .OUTPUTS + PCTools.Service + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string[]]$Name, + + [ValidateSet('Automatic', 'AutomaticDelayedStart', 'Manual', 'Disabled')] + [string[]]$StartupType, + + [switch]$ThirdPartyOnly + ) + + $services = @() + try { + $services = @(Get-CimInstance -ClassName Win32_Service -ErrorAction Stop) + } + catch { + Write-PCLog -Level WARN -Message "Could not read services: $($_.Exception.Message)" + return + } + + $critical = Get-PCCriticalServiceName + + foreach ($service in $services) { + if ($Name -and $Name -notcontains $service.Name) { continue } + + # DelayedAutoStart is a separate flag; Win32_Service reports the mode as + # 'Auto' either way, and the difference matters to boot time. + $mode = switch ($service.StartMode) { + 'Auto' { if ($service.DelayedAutoStart) { 'AutomaticDelayedStart' } else { 'Automatic' } } + 'Manual' { 'Manual' } + 'Disabled' { 'Disabled' } + default { [string]$service.StartMode } + } + + if ($StartupType -and $StartupType -notcontains $mode) { continue } + + $path = [string]$service.PathName + + # The binary's location is the most reliable signal available without + # checking signatures: a service running out of System32 came with + # Windows, one running out of Program Files did not. + $category = if ($critical -contains $service.Name) { 'Critical' } + elseif ($path -match '(?i)\\Windows\\(System32|SysWOW64|servicing)\\') { 'Windows' } + else { 'ThirdParty' } + + if ($ThirdPartyOnly -and $category -ne 'ThirdParty') { continue } + + [pscustomobject]@{ + PSTypeName = 'PCTools.Service' + Name = $service.Name + DisplayName = $service.DisplayName + Status = $service.State + StartupType = $mode + Category = $category + Path = $path + Description = $service.Description + CanDisable = $category -ne 'Critical' + } + } +} diff --git a/src/PCTools/Public/Startup/Get-PCStartupItem.ps1 b/src/PCTools/Public/Startup/Get-PCStartupItem.ps1 new file mode 100644 index 0000000..6caea63 --- /dev/null +++ b/src/PCTools/Public/Startup/Get-PCStartupItem.ps1 @@ -0,0 +1,182 @@ +function Get-PCStartupItem { + <# + .SYNOPSIS + Lists everything that starts with Windows, from every place it can hide. + + .DESCRIPTION + Task Manager's Startup tab shows the Run keys and the Startup folders. + It does not show logon-triggered scheduled tasks, which is where a good + deal of modern updater and telemetry software actually lives. Somebody + who disables everything in Task Manager and still has eleven things + starting is usually looking at scheduled tasks. + + So all four sources are read into one list: + + - HKLM and HKCU Run and RunOnce keys, 32- and 64-bit views + - the per-user and all-users Startup folders + - scheduled tasks with a logon or boot trigger + + Enabled state comes from StartupApproved, which is where Task Manager + records what the user has switched off; a Run key entry disabled in Task + Manager is still present in the registry, and reporting it as enabled + would be wrong. + + .PARAMETER IncludeDisabled + Include items that are present but switched off. On by default, since + "what did I disable" is half the reason to look. + + .PARAMETER Source + Limit to Registry, Folder or ScheduledTask. + + .EXAMPLE + Get-PCStartupItem | Format-Table Name, Source, Enabled, Publisher, Command + + .EXAMPLE + Get-PCStartupItem | Where-Object Enabled | Measure-Object + + .OUTPUTS + PCTools.StartupItem + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [bool]$IncludeDisabled = $true, + + [ValidateSet('Registry', 'Folder', 'ScheduledTask')] + [string[]]$Source + ) + + $emit = { + param($Name, $ItemSource, $Location, $Command, $Enabled, $Scope, $Id) + [pscustomobject]@{ + PSTypeName = 'PCTools.StartupItem' + Name = $Name + Source = $ItemSource + Scope = $Scope + Enabled = $Enabled + Command = $Command + Location = $Location + Id = $Id + } + } + + $wanted = if ($Source) { $Source } else { 'Registry', 'Folder', 'ScheduledTask' } + $items = [System.Collections.Generic.List[object]]::new() + + # StartupApproved records what Task Manager has switched off. The first byte + # of the value carries the flag: an even value is enabled, odd is disabled. + $approvedState = @{} + $approvedKeys = @( + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run' + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder' + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run' + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder' + ) + + foreach ($key in $approvedKeys) { + if (-not (Test-Path -LiteralPath $key)) { continue } + try { + $properties = Get-ItemProperty -LiteralPath $key -ErrorAction Stop + foreach ($property in $properties.PSObject.Properties) { + if ($property.Name -like 'PS*') { continue } + $value = $property.Value + if ($value -is [byte[]] -and $value.Length -gt 0) { + $approvedState[$property.Name] = (($value[0] % 2) -eq 0) + } + } + } + catch { + Write-PCLog -Level DEBUG -Message "Could not read ${key}: $($_.Exception.Message)" + } + } + + if ($wanted -contains 'Registry') { + $runKeys = @( + @{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'; Scope = 'CurrentUser' } + @{ Path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce'; Scope = 'CurrentUser' } + @{ Path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run'; Scope = 'AllUsers' } + @{ Path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce'; Scope = 'AllUsers' } + @{ Path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'; Scope = 'AllUsers (32-bit)' } + @{ Path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce'; Scope = 'AllUsers (32-bit)' } + ) + + foreach ($runKey in $runKeys) { + if (-not (Test-Path -LiteralPath $runKey.Path)) { continue } + + try { + $properties = Get-ItemProperty -LiteralPath $runKey.Path -ErrorAction Stop + } + catch { + Write-PCLog -Level DEBUG -Message "Could not read $($runKey.Path): $($_.Exception.Message)" + continue + } + + foreach ($property in $properties.PSObject.Properties) { + if ($property.Name -like 'PS*') { continue } + + $enabled = $true + if ($approvedState.ContainsKey($property.Name)) { $enabled = $approvedState[$property.Name] } + + $items.Add((& $emit $property.Name 'Registry' $runKey.Path ([string]$property.Value) $enabled $runKey.Scope $property.Name)) + } + } + } + + if ($wanted -contains 'Folder') { + $startupFolders = @( + @{ Path = [Environment]::GetFolderPath('Startup'); Scope = 'CurrentUser' } + @{ Path = [Environment]::GetFolderPath('CommonStartup'); Scope = 'AllUsers' } + ) + + foreach ($folder in $startupFolders) { + if (-not $folder.Path -or -not (Test-Path -LiteralPath $folder.Path)) { continue } + + foreach ($file in @(Get-ChildItem -LiteralPath $folder.Path -File -Force -ErrorAction SilentlyContinue)) { + if ($file.Name -eq 'desktop.ini') { continue } + + $enabled = $true + if ($approvedState.ContainsKey($file.Name)) { $enabled = $approvedState[$file.Name] } + + $items.Add((& $emit $file.BaseName 'Folder' $folder.Path $file.FullName $enabled $folder.Scope $file.Name)) + } + } + } + + if ($wanted -contains 'ScheduledTask') { + try { + foreach ($task in @(Get-ScheduledTask -ErrorAction Stop)) { + $triggers = @($task.Triggers | Where-Object { + $_.CimClass.CimClassName -in 'MSFT_TaskLogonTrigger', 'MSFT_TaskBootTrigger' + }) + if ($triggers.Count -eq 0) { continue } + + # Microsoft's own maintenance tasks are not "startup software" in + # the sense anybody means when they ask what starts with Windows, + # and listing 200 of them buries the four that matter. + if ($task.TaskPath -like '\Microsoft\Windows\*') { continue } + + $command = '' + try { + $actions = @($task.Actions | Where-Object { $_.Execute }) + if ($actions.Count -gt 0) { + $command = ($actions | ForEach-Object { ($_.Execute, $_.Arguments -join ' ').Trim() }) -join ' ; ' + } + } + catch { } + + $items.Add((& $emit $task.TaskName 'ScheduledTask' $task.TaskPath $command ` + ($task.State -ne 'Disabled') 'AllUsers' "$($task.TaskPath)$($task.TaskName)")) + } + } + catch { + Write-PCLog -Level DEBUG -Message "Could not enumerate scheduled tasks: $($_.Exception.Message)" + } + } + + $output = $items + if (-not $IncludeDisabled) { + $output = @($items | Where-Object Enabled) + } + + $output | Sort-Object @{ Expression = 'Enabled'; Descending = $true }, Source, Name +} diff --git a/src/PCTools/Public/Startup/Set-PCServiceStartup.ps1 b/src/PCTools/Public/Startup/Set-PCServiceStartup.ps1 new file mode 100644 index 0000000..38a1965 --- /dev/null +++ b/src/PCTools/Public/Startup/Set-PCServiceStartup.ps1 @@ -0,0 +1,120 @@ +function Set-PCServiceStartup { + <# + .SYNOPSIS + Changes a service's startup type, refusing the ones that matter. + + .DESCRIPTION + Set-Service will happily disable the Base Filtering Engine and leave you + with a machine that has no firewall and no VPN. This will not: the + services on Get-PCCriticalServiceName are refused outright, and there is + no -Force to talk it round. If you genuinely need to disable one of + them, Set-Service is still right there - but it should be a deliberate + act, not something a maintenance tool does on your behalf. + + The previous startup type is returned in the result, so a change can be + undone without having recorded it first. + + This is not in any maintenance profile and it never will be. Which + services a given machine needs depends on what that machine does. + + .PARAMETER Name + The service to change. + + .PARAMETER StartupType + Automatic, AutomaticDelayedStart, Manual or Disabled. + + .PARAMETER StopNow + Also stop the service if it is running and the new type is Disabled. + + .EXAMPLE + Set-PCServiceStartup -Name 'Fax' -StartupType Disabled -WhatIf + + .EXAMPLE + Set-PCServiceStartup -Name 'SysMain' -StartupType Manual + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory, Position = 0)] + [string[]]$Name, + + [Parameter(Mandatory, Position = 1)] + [ValidateSet('Automatic', 'AutomaticDelayedStart', 'Manual', 'Disabled')] + [string]$StartupType, + + [switch]$StopNow + ) + + Invoke-PCAction -Name 'Set-PCServiceStartup' -Body { + Assert-PCAdmin -Action 'Changing a service startup type' + + $critical = Get-PCCriticalServiceName + $changed = @() + $refused = @() + $failed = @() + $previous = @{} + + foreach ($serviceName in $Name) { + if ($critical -contains $serviceName) { + $refused += $serviceName + Write-PCLog -Level WARN -Message "Refusing to reconfigure $serviceName - it is on the critical list." + continue + } + + $service = Get-CimInstance -ClassName Win32_Service -Filter "Name='$serviceName'" -ErrorAction SilentlyContinue + if (-not $service) { + $failed += "$serviceName (no such service)" + continue + } + + $was = if ($service.StartMode -eq 'Auto' -and $service.DelayedAutoStart) { 'AutomaticDelayedStart' } + elseif ($service.StartMode -eq 'Auto') { 'Automatic' } + else { [string]$service.StartMode } + + $previous[$serviceName] = $was + + if ($was -eq $StartupType) { continue } + + if (-not $PSCmdlet.ShouldProcess($serviceName, "Set startup type from $was to $StartupType")) { continue } + + try { + # Set-Service gained AutomaticDelayedStart only in PowerShell 6, + # and this module runs on 5.1, so sc.exe covers that one case. + if ($StartupType -eq 'AutomaticDelayedStart') { + $run = Invoke-PCProcess -FilePath 'sc.exe' -ArgumentList @('config', $serviceName, 'start=', 'delayed-auto') -TimeoutSeconds 30 + if ($run.ExitCode -ne 0) { throw "sc.exe config failed with exit code $($run.ExitCode): $($run.Output)" } + } + else { + Set-Service -Name $serviceName -StartupType $StartupType -ErrorAction Stop + } + + if ($StopNow -and $StartupType -eq 'Disabled' -and $service.State -eq 'Running') { + Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue + } + + $changed += "$serviceName ($was -> $StartupType)" + Write-PCLog -Level INFO -Message "Service $serviceName startup type changed from $was to $StartupType" + } + catch { + $failed += "$serviceName ($($_.Exception.Message))" + } + } + + $parts = @() + if ($changed.Count) { $parts += '{0} changed: {1}' -f $changed.Count, ($changed -join ', ') } + if ($refused.Count) { $parts += 'refused as critical: {0}' -f ($refused -join ', ') } + if ($failed.Count) { $parts += '{0} failed: {1}' -f $failed.Count, ($failed -join ', ') } + + @{ + Status = if ($failed.Count -eq 0 -and $refused.Count -eq 0 -and $changed.Count -gt 0) { 'Success' } + elseif ($changed.Count -gt 0) { 'Warning' } + elseif ($failed.Count -gt 0 -or $refused.Count -gt 0) { 'Failed' } + else { 'Skipped' } + Detail = if ($parts.Count) { $parts -join '; ' } else { 'Nothing to change.' } + Data = @{ Changed = $changed; Refused = $refused; Failed = $failed; PreviousStartupType = $previous } + } + } +} diff --git a/src/PCTools/Public/Storage/Clear-PCCrashDump.ps1 b/src/PCTools/Public/Storage/Clear-PCCrashDump.ps1 new file mode 100644 index 0000000..b662a5c --- /dev/null +++ b/src/PCTools/Public/Storage/Clear-PCCrashDump.ps1 @@ -0,0 +1,105 @@ +function Clear-PCCrashDump { + <# + .SYNOPSIS + Removes crash dumps and Windows Error Reporting queues. + + .DESCRIPTION + A single kernel memory dump can be as large as installed RAM - 32 GB on + a machine with 32 GB of it - and Windows keeps one from every bugcheck + alongside a minidump each. The WER queues add up too, quietly. + + Read Get-PCEventSummary before running this on a machine that is + actually crashing: the dumps are the evidence. On a machine that is + working fine, they are 30 GB of nothing. + + .PARAMETER IncludeMemoryDump + Also delete the full MEMORY.DMP, which is where most of the space is. + On by default. + + .PARAMETER IncludeMinidump + Also delete the per-crash minidumps. Small, and the only record of what + crashed; off by default for that reason. + + .EXAMPLE + Clear-PCCrashDump -WhatIf + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [OutputType([pscustomobject])] + param( + [bool]$IncludeMemoryDump = $true, + + [switch]$IncludeMinidump + ) + + Invoke-PCAction -Name 'Clear-PCCrashDump' -Body { + Assert-PCAdmin -Action 'Clearing crash dumps' + + $bytes = 0L + $removed = 0 + $cleared = @() + + if ($IncludeMemoryDump) { + $memoryDump = Join-Path $env:SystemRoot 'MEMORY.DMP' + if (Test-Path -LiteralPath $memoryDump) { + if ($PSCmdlet.ShouldProcess($memoryDump, 'Remove')) { + try { + $size = (Get-Item -LiteralPath $memoryDump -Force).Length + Remove-Item -LiteralPath $memoryDump -Force -ErrorAction Stop + $bytes += $size + $removed++ + $cleared += 'MEMORY.DMP' + } + catch { + Write-PCLog -Level WARN -Message "MEMORY.DMP could not be removed: $($_.Exception.Message)" + } + } + } + } + + if ($IncludeMinidump) { + $minidumpDir = Join-Path $env:SystemRoot 'Minidump' + if (Test-Path -LiteralPath $minidumpDir) { + $outcome = Clear-PCFolderContent -Path $minidumpDir + if (-not $outcome.Missing -and $outcome.ItemsRemoved -gt 0) { + $bytes += $outcome.BytesFreed + $removed += $outcome.ItemsRemoved + $cleared += 'Minidump' + } + } + } + + # WER report queues: everything crashed applications left behind. + $werPaths = @( + (Join-Path $env:ProgramData 'Microsoft\Windows\WER\ReportQueue') + (Join-Path $env:ProgramData 'Microsoft\Windows\WER\ReportArchive') + (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\WER\ReportQueue') + (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\WER\ReportArchive') + (Join-Path $env:LOCALAPPDATA 'CrashDumps') + ) + + foreach ($path in $werPaths) { + if (-not $path -or -not (Test-Path -LiteralPath $path)) { continue } + $outcome = Clear-PCFolderContent -Path $path + if ($outcome.Missing) { continue } + $bytes += $outcome.BytesFreed + $removed += $outcome.ItemsRemoved + if ($outcome.ItemsRemoved -gt 0) { $cleared += (Split-Path -Leaf $path) } + } + + @{ + Status = if ($removed -eq 0) { 'Skipped' } else { 'Success' } + Detail = if ($removed -eq 0) { + 'No crash dumps or error reports were found.' + } + else { + '{0} item(s) removed from {1}, {2} freed' -f + $removed, (($cleared | Select-Object -Unique) -join ', '), (Format-PCByteSize -Bytes $bytes) + } + BytesFreed = $bytes + Data = @{ Cleared = @($cleared | Select-Object -Unique); ItemsRemoved = $removed } + } + } +} diff --git a/src/PCTools/Public/Storage/Clear-PCDeliveryOptimization.ps1 b/src/PCTools/Public/Storage/Clear-PCDeliveryOptimization.ps1 new file mode 100644 index 0000000..8da009c --- /dev/null +++ b/src/PCTools/Public/Storage/Clear-PCDeliveryOptimization.ps1 @@ -0,0 +1,74 @@ +function Clear-PCDeliveryOptimization { + <# + .SYNOPSIS + Removes the Delivery Optimization download cache. + + .DESCRIPTION + Delivery Optimization keeps the update and Store payloads it has + downloaded so it can serve them to other machines on the network. On a + single-PC household that cache is pure cost, and it routinely reaches + several gigabytes. + + Windows reclaims it eventually. "Eventually" is not much help to + somebody who cannot install an update because the disk is full. + + Safe: this is a cache, and clearing it costs at most a re-download. + + .EXAMPLE + Clear-PCDeliveryOptimization -WhatIf + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [OutputType([pscustomobject])] + param() + + Invoke-PCAction -Name 'Clear-PCDeliveryOptimization' -Body { + Assert-PCAdmin -Action 'Clearing the Delivery Optimization cache' + + $cachePath = Join-Path $env:SystemRoot 'SoftwareDistribution\DeliveryOptimization' + $alternate = Join-Path $env:SystemRoot 'ServiceProfiles\NetworkService\AppData\Local\Microsoft\Windows\DeliveryOptimization' + + # The service holds its files open, so it stops first and starts again + # afterwards whatever happens. + $service = Get-Service -Name 'DoSvc' -ErrorAction SilentlyContinue + $wasRunning = $service -and $service.Status -eq 'Running' + + if ($wasRunning -and $PSCmdlet.ShouldProcess('DoSvc', 'Stop the Delivery Optimization service')) { + try { Stop-Service -Name 'DoSvc' -Force -ErrorAction Stop } + catch { Write-PCLog -Level WARN -Message "Could not stop DoSvc: $($_.Exception.Message)" } + } + + try { + $totalBytes = 0L + $totalRemoved = 0 + $cleaned = @() + + foreach ($path in @($cachePath, $alternate)) { + if (-not (Test-Path -LiteralPath $path)) { continue } + $outcome = Clear-PCFolderContent -Path $path + if ($outcome.Missing) { continue } + $totalBytes += $outcome.BytesFreed + $totalRemoved += $outcome.ItemsRemoved + $cleaned += $path + } + + if ($cleaned.Count -eq 0) { + return @{ Status = 'Warning'; Detail = 'No Delivery Optimization cache was found.' } + } + + @{ + Detail = '{0} freed from the Delivery Optimization cache' -f (Format-PCByteSize -Bytes $totalBytes) + BytesFreed = $totalBytes + Data = @{ Paths = $cleaned; ItemsRemoved = $totalRemoved } + } + } + finally { + if ($wasRunning) { + try { Start-Service -Name 'DoSvc' -ErrorAction Stop } + catch { Write-PCLog -Level WARN -Message "Could not restart DoSvc: $($_.Exception.Message)" } + } + } + } +} diff --git a/src/PCTools/Public/Storage/Clear-PCEventLog.ps1 b/src/PCTools/Public/Storage/Clear-PCEventLog.ps1 new file mode 100644 index 0000000..dca081e --- /dev/null +++ b/src/PCTools/Public/Storage/Clear-PCEventLog.ps1 @@ -0,0 +1,107 @@ +function Clear-PCEventLog { + <# + .SYNOPSIS + Clears Windows event logs. + + .DESCRIPTION + Event logs are capped and roll over on their own, so this is not a space + win worth chasing - a few hundred megabytes at most. It earns its place + for the other reason: clearing the logs before reproducing a fault means + Get-PCEventSummary afterwards shows only what the fault produced, + instead of six months of history to read past. + + Which is also why this is not in any maintenance profile, and why the + default is to clear only the noisy application logs rather than the + security log, whose contents are an audit record somebody may be + required to keep. + + .PARAMETER LogName + Specific logs to clear. Defaults to Application, System and Setup. + + .PARAMETER IncludeSecurity + Also clear the Security log. Off by default: that log is an audit trail + and clearing it is itself an audited event. + + .PARAMETER All + Clear every log with records in it. Verbose, slow, and rarely what you + want. + + .EXAMPLE + Clear-PCEventLog -WhatIf + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + [OutputType([pscustomobject])] + param( + [string[]]$LogName = @('Application', 'System', 'Setup'), + + [switch]$IncludeSecurity, + + [switch]$All + ) + + Invoke-PCAction -Name 'Clear-PCEventLog' -Body { + Assert-PCAdmin -Action 'Clearing event logs' + + $targets = @($LogName) + + if ($IncludeSecurity -and $targets -notcontains 'Security') { $targets += 'Security' } + + if ($All) { + try { + $targets = @(Get-WinEvent -ListLog * -ErrorAction Stop | + Where-Object { $_.RecordCount -gt 0 } | + ForEach-Object LogName) + } + catch { + Write-PCLog -Level WARN -Message "Could not enumerate event logs: $($_.Exception.Message)" + } + } + + $cleared = @() + $failed = @() + $records = 0 + + foreach ($name in $targets) { + $count = 0 + try { + $log = Get-WinEvent -ListLog $name -ErrorAction Stop + $count = [int]$log.RecordCount + } + catch { + $failed += "$name (not found)" + continue + } + + if ($count -eq 0) { continue } + if (-not $PSCmdlet.ShouldProcess($name, "Clear $count record(s)")) { continue } + + # wevtutil is the only reliable way to clear a modern channel; + # Clear-EventLog handles classic logs only. + $run = Invoke-PCProcess -FilePath 'wevtutil.exe' -ArgumentList @('cl', $name) -TimeoutSeconds 60 + + if ($run.ExitCode -eq 0 -and -not $run.TimedOut) { + $cleared += $name + $records += $count + } + else { + $failed += "$name (exit $($run.ExitCode))" + } + } + + $parts = @() + if ($cleared.Count) { $parts += '{0} log(s) cleared, {1} record(s)' -f $cleared.Count, $records } + if ($failed.Count) { $parts += '{0} failed: {1}' -f $failed.Count, ($failed -join ', ') } + + @{ + Status = if ($failed.Count -eq 0 -and $cleared.Count -gt 0) { 'Success' } + elseif ($cleared.Count -gt 0) { 'Warning' } + elseif ($failed.Count -gt 0) { 'Failed' } + else { 'Skipped' } + Detail = if ($parts.Count) { $parts -join '; ' } else { 'No logs had records to clear.' } + Data = @{ Cleared = $cleared; Failed = $failed; Records = $records } + } + } +} diff --git a/src/PCTools/Public/Storage/Clear-PCThumbnailCache.ps1 b/src/PCTools/Public/Storage/Clear-PCThumbnailCache.ps1 new file mode 100644 index 0000000..048680a --- /dev/null +++ b/src/PCTools/Public/Storage/Clear-PCThumbnailCache.ps1 @@ -0,0 +1,80 @@ +function Clear-PCThumbnailCache { + <# + .SYNOPSIS + Clears the Explorer thumbnail and icon caches. + + .DESCRIPTION + Worth doing for two different reasons. It reclaims a few hundred + megabytes on a machine with a large photo library, and it is the + standard fix for Explorer showing wrong, stale or blank thumbnails and + icons - a cosmetic fault with no other obvious cure. + + Explorer holds the cache files open, so it is restarted. Restart-PCExplorer + waits for the shell to come back rather than sleeping and hoping, which + is what stops this leaving somebody with no taskbar. + + Safe: Windows rebuilds both caches on demand. + + .PARAMETER SkipExplorerRestart + Leave Explorer running. The locked cache files will not be reclaimed + until the next sign-in. + + .EXAMPLE + Clear-PCThumbnailCache + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [OutputType([pscustomobject])] + param( + [switch]$SkipExplorerRestart + ) + + Invoke-PCAction -Name 'Clear-PCThumbnailCache' -Body { + $cacheDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Explorer' + + if (-not (Test-Path -LiteralPath $cacheDir)) { + return @{ Status = 'Warning'; Detail = "Explorer cache folder not found: $cacheDir" } + } + + $restarted = $false + if (-not $SkipExplorerRestart) { + if ($PSCmdlet.ShouldProcess('explorer.exe', 'Restart to release the cache files')) { + $restart = Restart-PCExplorer + $restarted = $restart.Status -eq 'Success' + } + } + + $bytes = 0L + $removed = 0 + $locked = 0 + + foreach ($file in @(Get-ChildItem -LiteralPath $cacheDir -Filter '*.db' -File -Force -ErrorAction SilentlyContinue)) { + if ($file.Name -notlike 'thumbcache_*' -and $file.Name -notlike 'iconcache_*') { continue } + if (-not $PSCmdlet.ShouldProcess($file.FullName, 'Remove')) { continue } + + try { + $size = $file.Length + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop + $bytes += $size + $removed++ + } + catch { + $locked++ + } + } + + $detail = '{0} cache file(s) removed, {1} freed' -f $removed, (Format-PCByteSize -Bytes $bytes) + if ($locked -gt 0) { + $detail += "; $locked still in use and will be rebuilt at the next sign-in" + } + + @{ + Status = if ($removed -eq 0 -and $locked -gt 0) { 'Warning' } else { 'Success' } + Detail = $detail + BytesFreed = $bytes + Data = @{ Removed = $removed; Locked = $locked; ExplorerRestarted = $restarted } + } + } +} diff --git a/src/PCTools/Public/Storage/Clear-PCWindowsOld.ps1 b/src/PCTools/Public/Storage/Clear-PCWindowsOld.ps1 new file mode 100644 index 0000000..5894b15 --- /dev/null +++ b/src/PCTools/Public/Storage/Clear-PCWindowsOld.ps1 @@ -0,0 +1,105 @@ +function Clear-PCWindowsOld { + <# + .SYNOPSIS + Removes the previous Windows installation left behind by a feature update. + + .DESCRIPTION + C:\Windows.old is the rollback copy a feature update leaves behind, and + it is routinely 15 to 30 GB. Windows removes it automatically after ten + days. + + This is the one cleanup here that genuinely costs something: deleting it + gives up the ability to roll back to the previous Windows build. The + confirmation is High for that reason, and the age of the folder is + reported so the decision is an informed one - inside the rollback window + it is worth keeping, outside it is dead weight. + + Ownership is taken first. The folder is owned by TrustedInstaller and a + plain recursive delete fails partway through, leaving a mess that is + harder to clear than what it started with. + + .PARAMETER MinimumAgeDays + Refuse to delete a Windows.old younger than this. Defaults to 10, which + is the rollback window Windows itself uses. Set to 0 to delete + regardless. + + .EXAMPLE + Clear-PCWindowsOld -WhatIf + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + [OutputType([pscustomobject])] + param( + [ValidateRange(0, 365)] + [int]$MinimumAgeDays = 10 + ) + + Invoke-PCAction -Name 'Clear-PCWindowsOld' -Body { + Assert-PCAdmin -Action 'Removing the previous Windows installation' + + $systemDrive = if ($env:SystemDrive) { $env:SystemDrive } else { 'C:' } + $path = Join-Path $systemDrive 'Windows.old' + + if (-not (Test-Path -LiteralPath $path)) { + return @{ + Status = 'Skipped' + Detail = 'No previous Windows installation is present; nothing to remove.' + } + } + + $folder = Get-Item -LiteralPath $path -Force + $ageDays = [int]((Get-Date) - $folder.CreationTime).TotalDays + + if ($MinimumAgeDays -gt 0 -and $ageDays -lt $MinimumAgeDays) { + return @{ + Status = 'Skipped' + Detail = ('Windows.old is {0} day(s) old and rollback to the previous build is still possible. ' + + 'It will be removed automatically at {1} days, or pass -MinimumAgeDays 0 to remove it now.') -f + $ageDays, $MinimumAgeDays + Data = @{ AgeDays = $ageDays } + } + } + + if (-not $PSCmdlet.ShouldProcess($path, 'Delete the previous Windows installation')) { + return @{ Status = 'Skipped'; Detail = 'Not confirmed.' } + } + + # TrustedInstaller owns most of this tree. Without taking ownership and + # granting rights the delete fails halfway through. + Write-PCLog -Level INFO -Message 'Taking ownership of Windows.old' + $takeown = Invoke-PCProcess -FilePath 'takeown.exe' -ArgumentList @('/F', $path, '/R', '/A', '/D', 'Y') -TimeoutSeconds 600 + if ($takeown.TimedOut) { + return @{ Status = 'Failed'; Detail = 'Taking ownership timed out; nothing was deleted.' } + } + + $icacls = Invoke-PCProcess -FilePath 'icacls.exe' -ArgumentList @($path, '/grant', 'Administrators:F', '/T', '/C') -TimeoutSeconds 600 + if ($icacls.TimedOut) { + return @{ Status = 'Failed'; Detail = 'Granting rights timed out; nothing was deleted.' } + } + + $outcome = Clear-PCFolderContent -Path $path + + $remaining = Test-Path -LiteralPath $path + if ($remaining) { + try { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop } + catch { Write-PCLog -Level WARN -Message "Windows.old could not be fully removed: $($_.Exception.Message)" } + $remaining = Test-Path -LiteralPath $path + } + + @{ + Status = if ($remaining) { 'Warning' } else { 'Success' } + Detail = if ($remaining) { + '{0} freed, but part of Windows.old could not be removed. Disk Cleanup with "Previous Windows installation(s)" ticked will finish the job.' -f + (Format-PCByteSize -Bytes $outcome.BytesFreed) + } + else { + '{0} freed; rollback to the previous Windows build is no longer possible' -f + (Format-PCByteSize -Bytes $outcome.BytesFreed) + } + BytesFreed = $outcome.BytesFreed + Data = @{ AgeDays = $ageDays; FullyRemoved = -not $remaining } + } + } +} diff --git a/src/PCTools/Public/Storage/Get-PCDiskSpace.ps1 b/src/PCTools/Public/Storage/Get-PCDiskSpace.ps1 new file mode 100644 index 0000000..aafd557 --- /dev/null +++ b/src/PCTools/Public/Storage/Get-PCDiskSpace.ps1 @@ -0,0 +1,81 @@ +function Get-PCDiskSpace { + <# + .SYNOPSIS + Reports free and used space per fixed drive. + + .DESCRIPTION + The question every cleanup starts with and the module could not answer: + how much room is there, and on which drive. Reported with a percentage + and a plain-language pressure grade, because "48.2 GB free" means + something very different on a 4 TB disk and a 128 GB one. + + .PARAMETER DriveLetter + Limit to these drives. Defaults to every fixed drive. + + .PARAMETER IncludeRemovable + Also report removable and network drives. + + .EXAMPLE + Get-PCDiskSpace | Format-Table Drive, Label, FreeDisplay, SizeDisplay, PercentFree, Pressure + + .OUTPUTS + PCTools.DiskSpace + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string[]]$DriveLetter, + + [switch]$IncludeRemovable + ) + + $volumes = @() + try { + # DriveType 3 is a local fixed disk; 2 removable, 4 network. + $wanted = if ($IncludeRemovable) { 2, 3, 4 } else { @(3) } + $volumes = @(Get-CimInstance -ClassName Win32_LogicalDisk -ErrorAction Stop | + Where-Object { $wanted -contains [int]$_.DriveType }) + } + catch { + Write-PCLog -Level WARN -Message "Could not read logical disks: $($_.Exception.Message)" + return + } + + foreach ($volume in $volumes) { + $letter = ($volume.DeviceID -replace ':$', '') + + if ($DriveLetter) { + $normalised = @($DriveLetter | ForEach-Object { $_.TrimEnd(':').ToUpperInvariant() }) + if ($normalised -notcontains $letter.ToUpperInvariant()) { continue } + } + + $size = [long]$volume.Size + $free = [long]$volume.FreeSpace + $used = $size - $free + + # A share or an unreadable volume reports zero, and dividing by it + # would be the only failure in an otherwise fine report. + $percentFree = if ($size -gt 0) { [math]::Round(($free / $size) * 100, 1) } else { $null } + + $pressure = if ($null -eq $percentFree) { 'Unknown' } + elseif ($percentFree -lt 5) { 'Critical' } + elseif ($percentFree -lt 10) { 'Low' } + elseif ($percentFree -lt 20) { 'Tight' } + else { 'Comfortable' } + + [pscustomobject]@{ + PSTypeName = 'PCTools.DiskSpace' + Drive = "${letter}:" + Label = $volume.VolumeName + FileSystem = $volume.FileSystem + Size = $size + SizeDisplay = Format-PCByteSize -Bytes $size + Used = $used + UsedDisplay = Format-PCByteSize -Bytes $used + Free = $free + FreeDisplay = Format-PCByteSize -Bytes $free + PercentFree = $percentFree + Pressure = $pressure + } + } +} diff --git a/src/PCTools/Public/Storage/Get-PCDiskUsage.ps1 b/src/PCTools/Public/Storage/Get-PCDiskUsage.ps1 new file mode 100644 index 0000000..9eba7a6 --- /dev/null +++ b/src/PCTools/Public/Storage/Get-PCDiskUsage.ps1 @@ -0,0 +1,230 @@ +function Get-PCDiskUsage { + <# + .SYNOPSIS + Finds what is actually using the space. + + .DESCRIPTION + "Disk full" is rarely solved by a temp clean. It is solved by finding + the 60 GB of old virtual machine images somebody forgot about. This + walks a tree once and reports the largest folders and the largest + individual files. + + The walk uses System.IO.Directory enumeration rather than + Get-ChildItem -Recurse. On a home folder with a few hundred thousand + files the provider overhead dominates, and this is meant to be run + interactively while somebody waits. + + Unreadable subtrees are skipped rather than throwing: a scan of C:\ + will always meet folders the current user cannot enter, and reporting + the 95% it could read is far more useful than failing. + + .PARAMETER Path + Where to start. Defaults to the user profile, which is where the + surprises usually are. + + .PARAMETER Top + How many entries to report in each list. + + .PARAMETER Depth + How deep to group folders. Depth 1 reports the immediate children of + Path, which is normally the level at which a person makes a decision. + + .PARAMETER MinimumSize + Ignore anything below this many bytes. Defaults to 10 MB. + + .EXAMPLE + Get-PCDiskUsage -Path C:\ -Top 15 + + .EXAMPLE + (Get-PCDiskUsage).LargestFolders | Format-Table Path, SizeDisplay, FileCount + + .OUTPUTS + PCTools.DiskUsage + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Position = 0)] + [string]$Path, + + [ValidateRange(1, 200)] + [int]$Top = 20, + + [ValidateRange(1, 4)] + [int]$Depth = 1, + + [long]$MinimumSize = 10MB + ) + + if (-not $Path) { + $Path = if ($env:USERPROFILE) { $env:USERPROFILE } else { [System.IO.Path]::GetTempPath() } + } + + if (-not (Test-Path -LiteralPath $Path)) { + throw "No such path: $Path" + } + + $Path = (Resolve-Path -LiteralPath $Path).ProviderPath + Write-PCLog -Level INFO -Message "Scanning $Path for disk usage" + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + $roots = @() + try { + $roots = @([System.IO.Directory]::EnumerateDirectories($Path)) + } + catch { + Write-PCLog -Level WARN -Message "Cannot enumerate ${Path}: $($_.Exception.Message)" + } + + # One self-contained walk per top-level folder, run concurrently. The + # measurement is read-only, so unlike deletion there is no ordering or + # safety constraint to respect here. + $measureBody = { + param($Folder, $GroupDepth, $Root, $Threshold) + + $bytes = 0L + $files = 0 + $largest = [System.Collections.Generic.List[object]]::new() + $subtotals = @{} + + $enumerated = $null + try { + $enumerated = [System.IO.Directory]::EnumerateFiles( + $Folder, '*', [System.IO.SearchOption]::AllDirectories) + } + catch { + try { $enumerated = [System.IO.Directory]::EnumerateFiles($Folder) } catch { $enumerated = @() } + } + + # A manual enumerator so an access denial part-way through a huge tree + # stops that branch instead of discarding everything counted so far. + $iterator = $enumerated.GetEnumerator() + while ($true) { + try { + if (-not $iterator.MoveNext()) { break } + } + catch { + break + } + + $file = $iterator.Current + try { + $length = ([System.IO.FileInfo]$file).Length + $bytes += $length + $files++ + + if ($length -ge $Threshold) { + $largest.Add(@{ Path = $file; Size = $length }) + } + + if ($GroupDepth -gt 1) { + $relative = $file.Substring($Root.Length).TrimStart('\', '/') + $parts = $relative -split '[\\/]' + if ($parts.Count -gt $GroupDepth) { + $key = ($parts[0..($GroupDepth - 1)]) -join [System.IO.Path]::DirectorySeparatorChar + if ($subtotals.ContainsKey($key)) { $subtotals[$key] += $length } + else { $subtotals[$key] = $length } + } + } + } + catch { + continue + } + } + + @{ + Folder = $Folder + Bytes = $bytes + FileCount = $files + Largest = $largest.ToArray() + Subtotals = $subtotals + } + } + + $folders = [System.Collections.Generic.List[object]]::new() + $largestFiles = [System.Collections.Generic.List[object]]::new() + $totalBytes = 0L + $totalFiles = 0 + + if ($roots.Count -gt 0) { + $completed = @(Invoke-PCParallel -InputObject $roots -ScriptBlock $measureBody ` + -ArgumentList @($Depth, $Path, $MinimumSize) -ThrottleLimit 6 -TimeoutSeconds 900) + + foreach ($item in $completed) { + $outcome = $item.Output + if (-not $outcome) { continue } + + $totalBytes += [long]$outcome.Bytes + $totalFiles += [int]$outcome.FileCount + + if ([long]$outcome.Bytes -ge $MinimumSize) { + $folders.Add([pscustomobject]@{ + PSTypeName = 'PCTools.DiskUsageEntry' + Path = $outcome.Folder + Size = [long]$outcome.Bytes + SizeDisplay = Format-PCByteSize -Bytes ([long]$outcome.Bytes) + FileCount = [int]$outcome.FileCount + }) + } + + foreach ($key in $outcome.Subtotals.Keys) { + $size = [long]$outcome.Subtotals[$key] + if ($size -lt $MinimumSize) { continue } + $folders.Add([pscustomobject]@{ + PSTypeName = 'PCTools.DiskUsageEntry' + Path = Join-Path $Path $key + Size = $size + SizeDisplay = Format-PCByteSize -Bytes $size + FileCount = $null + }) + } + + foreach ($file in $outcome.Largest) { + $largestFiles.Add([pscustomobject]@{ + PSTypeName = 'PCTools.DiskUsageEntry' + Path = $file.Path + Size = [long]$file.Size + SizeDisplay = Format-PCByteSize -Bytes ([long]$file.Size) + FileCount = $null + }) + } + } + } + + # Loose files directly under the root are easy to miss and occasionally the + # whole answer - an ISO on the desktop, a forgotten database dump. + try { + foreach ($file in [System.IO.Directory]::EnumerateFiles($Path)) { + try { + $length = ([System.IO.FileInfo]$file).Length + $totalBytes += $length + $totalFiles++ + if ($length -ge $MinimumSize) { + $largestFiles.Add([pscustomobject]@{ + PSTypeName = 'PCTools.DiskUsageEntry' + Path = $file + Size = $length + SizeDisplay = Format-PCByteSize -Bytes $length + FileCount = $null + }) + } + } + catch { continue } + } + } + catch { } + + $stopwatch.Stop() + + [pscustomobject]@{ + PSTypeName = 'PCTools.DiskUsage' + Path = $Path + TotalSize = $totalBytes + TotalDisplay = Format-PCByteSize -Bytes $totalBytes + FileCount = $totalFiles + Duration = $stopwatch.Elapsed + LargestFolders = @($folders | Sort-Object Size -Descending | Select-Object -First $Top) + LargestFiles = @($largestFiles | Sort-Object Size -Descending | Select-Object -First $Top) + } +} diff --git a/src/PCTools/Public/Storage/Optimize-PCVolume.ps1 b/src/PCTools/Public/Storage/Optimize-PCVolume.ps1 new file mode 100644 index 0000000..2a623fc --- /dev/null +++ b/src/PCTools/Public/Storage/Optimize-PCVolume.ps1 @@ -0,0 +1,146 @@ +function Optimize-PCVolume { + <# + .SYNOPSIS + Optimizes a drive the way that drive should be optimized. + + .DESCRIPTION + The reason this exists rather than a "defragment" button: defragmenting + an SSD is actively harmful. It writes the whole drive to rearrange data + whose physical location the controller abstracts away anyway, spending + write endurance for no gain. An SSD wants TRIM - tell the controller + which blocks are free - and an HDD wants defragmentation. + + So the media type is read first and the correct operation is chosen. + There is no switch to force the wrong one. + + .PARAMETER DriveLetter + Drives to optimize. Defaults to every fixed drive. + + .PARAMETER Analyze + Report what would be done and the current fragmentation, without + changing anything. + + .PARAMETER TimeoutMinutes + Per-drive timeout. A full defragmentation of a large, badly fragmented + HDD legitimately runs for hours. + + .EXAMPLE + Optimize-PCVolume -Analyze + + .EXAMPLE + Optimize-PCVolume -DriveLetter C + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [OutputType([pscustomobject])] + param( + [string[]]$DriveLetter, + + [switch]$Analyze, + + [ValidateRange(1, 480)] + [int]$TimeoutMinutes = 120 + ) + + Invoke-PCAction -Name 'Optimize-PCVolume' -Body { + Assert-PCAdmin -Action 'Optimizing a volume' + + $volumes = @() + try { + $volumes = @(Get-Volume -ErrorAction Stop | + Where-Object { $_.DriveLetter -and $_.DriveType -eq 'Fixed' }) + } + catch { + return @{ Status = 'Failed'; Detail = "Could not enumerate volumes: $($_.Exception.Message)" } + } + + if ($DriveLetter) { + $wanted = @($DriveLetter | ForEach-Object { $_.TrimEnd(':').ToUpperInvariant() }) + $volumes = @($volumes | Where-Object { $wanted -contains ([string]$_.DriveLetter).ToUpperInvariant() }) + } + + if ($volumes.Count -eq 0) { + return @{ Status = 'Warning'; Detail = 'No fixed volume matched.' } + } + + # Media type lives on the physical disk, not the volume, so the two have + # to be correlated through the partition. + $mediaByLetter = @{} + try { + foreach ($disk in @(Get-PhysicalDisk -ErrorAction Stop)) { + $partitions = @(Get-Partition -ErrorAction SilentlyContinue | + Where-Object { $_.DiskNumber -eq $disk.DeviceId -and $_.DriveLetter }) + foreach ($partition in $partitions) { + $mediaByLetter[[string]$partition.DriveLetter] = $disk.MediaType + } + } + } + catch { + Write-PCLog -Level DEBUG -Message "Could not read physical disk media types: $($_.Exception.Message)" + } + + $done = @() + $failed = @() + $reboot = $false + + foreach ($volume in $volumes) { + $letter = [string]$volume.DriveLetter + $media = if ($mediaByLetter.ContainsKey($letter)) { [string]$mediaByLetter[$letter] } else { 'Unspecified' } + + # SSD and unknown both get ReTrim. Trimming an HDD is a harmless + # no-op; defragmenting an SSD is not, so unknown media takes the + # safe branch. + $isRotational = $media -eq 'HDD' + $operation = if ($isRotational) { 'Defrag' } else { 'ReTrim' } + + if ($Analyze) { + try { + $analysis = Optimize-Volume -DriveLetter $letter -Analyze -Verbose:$false -ErrorAction Stop 4>&1 + $done += "{0}: {1} would be run ({2}){3}" -f $letter, $operation, $media, + $(if ($analysis) { " - $(($analysis | Out-String).Trim() -replace '\s+', ' ')" } else { '' }) + } + catch { + $failed += "$letter ($($_.Exception.Message))" + } + continue + } + + if (-not $PSCmdlet.ShouldProcess("${letter}:", "$operation ($media)")) { continue } + + Write-PCLog -Level INFO -Message "Optimizing ${letter}: with $operation (media: $media)" + + # defrag.exe rather than Optimize-Volume so the run is bounded by + # Invoke-PCProcess's timeout. A stuck defrag on a failing disk would + # otherwise hang the caller indefinitely. + $arguments = if ($isRotational) { @("${letter}:", '/O') } else { @("${letter}:", '/L') } + $run = Invoke-PCProcess -FilePath 'defrag.exe' -ArgumentList $arguments -TimeoutSeconds ($TimeoutMinutes * 60) + + if ($run.TimedOut) { + $failed += "$letter (timed out after $TimeoutMinutes minutes)" + } + elseif ($run.ExitCode -eq 0) { + $done += "${letter}: $operation" + } + else { + $failed += "$letter (exit $($run.ExitCode))" + Write-PCLog -Level WARN -Message "defrag ${letter}: $($run.Output)" + } + } + + $parts = @() + if ($done.Count) { $parts += $done -join '; ' } + if ($failed.Count) { $parts += 'failed: ' + ($failed -join ', ') } + + @{ + Status = if ($failed.Count -eq 0 -and $done.Count -gt 0) { 'Success' } + elseif ($done.Count -gt 0) { 'Warning' } + elseif ($failed.Count -gt 0) { 'Failed' } + else { 'Skipped' } + Detail = if ($parts.Count) { $parts -join ' | ' } else { 'Nothing to do.' } + RebootRequired = $reboot + Data = @{ Optimized = $done; Failed = $failed } + } + } +} diff --git a/src/Shell/Start-PCToolsShell.ps1 b/src/PCTools/Shell/Start-PCToolsShell.ps1 similarity index 67% rename from src/Shell/Start-PCToolsShell.ps1 rename to src/PCTools/Shell/Start-PCToolsShell.ps1 index 4fad825..de01993 100644 --- a/src/Shell/Start-PCToolsShell.ps1 +++ b/src/PCTools/Shell/Start-PCToolsShell.ps1 @@ -13,16 +13,27 @@ during a DISM run that the original tools would have frozen for half an hour. + It now lives inside the module, so a PowerShell Gallery install carries the + GUI as well as the commands. Start it with Start-PCTools rather than by + path; running this file directly still works and is what the STA relaunch + below does. + .PARAMETER Theme Dark or Light. Defaults to Dark. +.PARAMETER Page + The page to open on. Defaults to the dashboard. + .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File .\Start-PCToolsShell.ps1 #> [CmdletBinding()] param( [ValidateSet('Dark', 'Light')] - [string]$Theme = 'Dark' + [string]$Theme = 'Dark', + + [ValidateSet('Dashboard', 'Maintenance', 'Storage', 'Health', 'Network', 'Software', 'Preferences', 'Log')] + [string]$Page = 'Dashboard' ) Set-StrictMode -Version Latest @@ -37,7 +48,7 @@ if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') { Write-Warning 'This shell requires an STA host. Relaunching under Windows PowerShell...' $relaunch = @( '-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', - '-File', "`"$PSCommandPath`"", '-Theme', $Theme + '-File', "`"$PSCommandPath`"", '-Theme', $Theme, '-Page', $Page ) Start-Process -FilePath 'powershell.exe' -ArgumentList $relaunch return @@ -103,9 +114,22 @@ catch { #region Module import $script:ShellRoot = $PSScriptRoot -$modulePath = Join-Path (Split-Path -Parent $script:ShellRoot) 'PCTools\PCTools.psd1' -if (Test-Path -LiteralPath $modulePath) { +# The shell lives inside the module (src/PCTools/Shell), so the manifest is one +# level up. Start-PCTools has already imported it; running this file directly +# has not, which is why both cases are handled. +$modulePath = Join-Path (Split-Path -Parent $script:ShellRoot) 'PCTools.psd1' + +if (Get-Module -Name PCTools) { + # Already loaded - do not re-import. -Force would tear down and rebuild the + # module while Start-PCTools is executing out of it. + # Prefer the manifest: the background runspaces import this path, and a + # manifest import gives them the same export surface the UI thread has. + $loaded = Get-Module -Name PCTools + $loadedManifest = Join-Path $loaded.ModuleBase 'PCTools.psd1' + $modulePath = if (Test-Path -LiteralPath $loadedManifest) { $loadedManifest } else { $loaded.Path } +} +elseif (Test-Path -LiteralPath $modulePath) { Import-Module $modulePath -Force -ErrorAction Stop } elseif (Get-Module -ListAvailable -Name PCTools) { @@ -153,6 +177,16 @@ $controls = @{ NetworkGrid = $null PreferenceGrid = $null LogGrid = $null + HealthScore = $null + HealthSummary = $null + FindingGrid = $null + DiskGrid = $null + StorageGrid = $null + UsageGrid = $null + StartupGrid = $null + SoftwareGrid = $null + DashboardStatus = $null + HealthText = $null } $sync = [hashtable]::Synchronized(@{ @@ -538,7 +572,9 @@ function Show-Summary { $succeeded = @($Result | Where-Object Status -eq 'Success').Count $failed = @($Result | Where-Object Status -eq 'Failed').Count $warned = @($Result | Where-Object Status -eq 'Warning').Count - $freed = ($Result | Measure-Object -Property BytesFreed -Sum).Sum + $freed = 0L + $measured = $Result | Measure-Object -Property BytesFreed -Sum + if ($measured -and $null -ne $measured.Sum) { $freed = [long]$measured.Sum } $reboot = @($Result | Where-Object RebootRequired).Count -gt 0 $parts = @("$($Result.Count) action(s)", "$succeeded ok") @@ -742,6 +778,197 @@ function Show-Page { #endregion +#region Dashboard page + +<# + The landing page. It answers, without being asked, the three questions + somebody opening a maintenance tool actually has: is anything wrong, how + much disk is left, and when did this last get looked after. + + Everything here is read-only. Opening the window must never change the + machine. +#> + +$dashboardPage = New-Object System.Windows.Forms.TableLayoutPanel +$dashboardPage.ColumnCount = 1 +$dashboardPage.RowCount = 2 +[void]$dashboardPage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 190))) +[void]$dashboardPage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) + +$dashboardTop = New-Card -Title 'How this machine is doing' +$dashboardPage.Controls.Add($dashboardTop, 0, 0) + +$dashboardInner = New-Object System.Windows.Forms.TableLayoutPanel +$dashboardInner.Dock = 'Fill' +$dashboardInner.ColumnCount = 2 +$dashboardInner.RowCount = 1 +[void]$dashboardInner.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 150))) +[void]$dashboardInner.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) +$dashboardTop.Controls.Add($dashboardInner) +$dashboardInner.BringToFront() + +$healthScore = New-Object System.Windows.Forms.Label +$healthScore.Text = '--' +$healthScore.Dock = 'Fill' +$healthScore.TextAlign = 'MiddleCenter' +$healthScore.Font = New-ShellFont 40 ([System.Drawing.FontStyle]::Bold) +$healthScore.ForeColor = Get-ThemeColor Muted +$dashboardInner.Controls.Add($healthScore, 0, 0) +$script:Sync.Controls.HealthScore = $healthScore + +$dashboardRight = New-Object System.Windows.Forms.FlowLayoutPanel +$dashboardRight.Dock = 'Fill' +$dashboardRight.FlowDirection = 'TopDown' +$dashboardRight.WrapContents = $false +$dashboardInner.Controls.Add($dashboardRight, 1, 0) + +$healthSummary = New-Object System.Windows.Forms.Label +$healthSummary.Text = 'Press Check this PC for a read-only health report.' +$healthSummary.AutoSize = $false +$healthSummary.Width = 700 +$healthSummary.Height = 46 +$healthSummary.Font = $script:FontBold +$healthSummary.ForeColor = Get-ThemeColor Text +$dashboardRight.Controls.Add($healthSummary) +$script:Sync.Controls.HealthSummary = $healthSummary + +$dashboardStatus = New-Object System.Windows.Forms.Label +$dashboardStatus.Text = '' +$dashboardStatus.AutoSize = $false +$dashboardStatus.Width = 700 +$dashboardStatus.Height = 40 +$dashboardStatus.Font = $script:FontBody +$dashboardStatus.ForeColor = Get-ThemeColor Muted +$dashboardRight.Controls.Add($dashboardStatus) +$script:Sync.Controls.DashboardStatus = $dashboardStatus + +function Update-HealthReport { + <# + Renders a PCTools.HealthReport. Called on the UI thread from the task + callback, never from the runspace. + #> + param($Report) + + if (-not $Report) { return } + + $score = $script:Sync.Controls.HealthScore + $score.Text = [string]$Report.Score + $score.ForeColor = if ($Report.Score -ge 75) { Get-ThemeColor Success } + elseif ($Report.Score -ge 55) { Get-ThemeColor Warning } + else { Get-ThemeColor Danger } + + $script:Sync.Controls.HealthSummary.Text = "$($Report.Grade) - $($Report.Summary)" + + $grid = $script:Sync.Controls.FindingGrid + $grid.BeginUpdate() + try { + $grid.Items.Clear() + foreach ($finding in @($Report.Findings)) { + $row = New-Object System.Windows.Forms.ListViewItem([string]$finding.Severity) + [void]$row.SubItems.Add([string]$finding.Area) + [void]$row.SubItems.Add([string]$finding.Message) + [void]$row.SubItems.Add([string]$finding.Command) + $row.ForeColor = switch ($finding.Severity) { + 'Critical' { Get-ThemeColor Danger } + 'Warning' { Get-ThemeColor Warning } + default { Get-ThemeColor Muted } + } + [void]$grid.Items.Add($row) + } + + if (@($Report.Findings).Count -eq 0) { + $row = New-Object System.Windows.Forms.ListViewItem('OK') + [void]$row.SubItems.Add('') + [void]$row.SubItems.Add('Nothing needs attention.') + [void]$row.SubItems.Add('') + $row.ForeColor = Get-ThemeColor Success + [void]$grid.Items.Add($row) + } + } + finally { + $grid.EndUpdate() + } +} + +function Start-HealthCheck { + Start-ShellTask -Name 'Check this PC' -Script { + # Skipped because the dashboard should come back in seconds; the + # Storage page runs the full scan when it is asked to. + Get-PCHealthReport -SkipDiskUsage + } -OnSuccess { + param($report) + Update-HealthReport -Report $report + } +} + +function Update-DashboardStatus { + param($Text) + $script:Sync.Controls.DashboardStatus.Text = [string]$Text +} + +$dashboardButtons = New-Object System.Windows.Forms.FlowLayoutPanel +$dashboardButtons.Dock = 'Bottom' +$dashboardButtons.Height = 44 +$dashboardButtons.WrapContents = $false +$dashboardTop.Controls.Add($dashboardButtons) + +$checkButton = New-Button -Text 'Check this PC' -Width 170 -Accent -OnClick { Start-HealthCheck } +$dashboardButtons.Controls.Add($checkButton) +[void]$script:Sync.Controls.RunButtons.Add($checkButton) + +$quickCleanButton = New-Button -Text 'Quick clean' -Width 150 -OnClick { + Start-Maintenance -ProfileOverride 'Quick' +} +$dashboardButtons.Controls.Add($quickCleanButton) +[void]$script:Sync.Controls.RunButtons.Add($quickCleanButton) + +$historyButton = New-Button -Text 'Show history' -Width 150 -OnClick { + Start-ShellTask -Name 'Read history' -Script { + Get-PCHistory -Summary + } -OnSuccess { + param($summary) + Update-DashboardStatus -Text $summary.Text + } +} +$dashboardButtons.Controls.Add($historyButton) +[void]$script:Sync.Controls.RunButtons.Add($historyButton) + +$reportButton = New-Button -Text 'Save HTML report' -Width 170 -OnClick { + Start-ShellTask -Name 'Write report' -Script { + Get-PCHealthReport -SkipDiskUsage | Export-PCReport -Format Html, Json -Confirm:$false + } -OnSuccess { + param($written) + if ($written -and $written.HtmlPath) { + Update-DashboardStatus -Text "Report written to $($written.HtmlPath)" + Write-ShellLog -Level INFO -Message "Report written to $($written.HtmlPath)" + } + } +} +$dashboardButtons.Controls.Add($reportButton) +[void]$script:Sync.Controls.RunButtons.Add($reportButton) + +$findingCard = New-Card -Title 'What to look at' +$dashboardPage.Controls.Add($findingCard, 0, 1) + +$findingGrid = New-Object System.Windows.Forms.ListView +$findingGrid.Dock = 'Fill' +$findingGrid.View = 'Details' +$findingGrid.FullRowSelect = $true +$findingGrid.HideSelection = $false +$findingGrid.Font = $script:FontBody +$findingGrid.BorderStyle = 'None' +[void]$findingGrid.Columns.Add('Severity', 90) +[void]$findingGrid.Columns.Add('Area', 100) +[void]$findingGrid.Columns.Add('Finding', 620) +[void]$findingGrid.Columns.Add('Command', 220) +$findingCard.Controls.Add($findingGrid) +$findingGrid.BringToFront() +$script:Sync.Controls.FindingGrid = $findingGrid + +Register-Page -Name 'Dashboard' -Subtitle 'How this machine is doing, at a glance' -Panel $dashboardPage + +#endregion + #region Maintenance page $maintenancePage = New-Object System.Windows.Forms.TableLayoutPanel @@ -817,9 +1044,15 @@ function Start-Maintenance { the point of building the module on ShouldProcess rather than writing a separate "what would happen" description that can drift. #> - param([switch]$Preview) + param( + [switch]$Preview, - $profileName = Get-SelectedProfileName + # Lets another page run a named profile without first reaching into the + # Maintenance page's radio buttons to change what is selected there. + [string]$ProfileOverride + ) + + $profileName = if ($ProfileOverride) { $ProfileOverride } else { Get-SelectedProfileName } $skipRestore = $skipRestoreBox.Checked $grid = $script:Sync.Controls.MaintenanceGrid $grid.Items.Clear() @@ -841,7 +1074,18 @@ function Start-Maintenance { } if ($Preview) { $parameters['WhatIf'] = $true } - Invoke-PCMaintenance @parameters + $results = Invoke-PCMaintenance @parameters + + # Recorded here, on the runspace, rather than from a callback: the task + # guard is still set while OnSuccess/OnFinally run, so a nested + # Start-ShellTask would be turned away as busy. + # Previews are not recorded - they changed nothing, and counting them + # would skew every trend the history exists to show. + if (-not $Preview) { + $results | Save-PCHistory -ProfileName $ProfileName -Confirm:$false | Out-Null + } + + $results } -OnSuccess { param($results) @@ -917,6 +1161,454 @@ Register-Page -Name 'Maintenance' -Subtitle 'Clean up, repair and verify Windows #endregion +#region Storage page + +<# + Where the space went, and what can be got back. The two are separate + questions: the reclaims at the top are safe and repeatable, the usage scan + below finds the 40 GB of forgotten virtual machine images that no cleanup + action will ever touch. +#> + +$storagePage = New-Object System.Windows.Forms.TableLayoutPanel +$storagePage.ColumnCount = 1 +$storagePage.RowCount = 3 +[void]$storagePage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 150))) +[void]$storagePage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 190))) +[void]$storagePage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) + +$diskCard = New-Card -Title 'Drives' +$storagePage.Controls.Add($diskCard, 0, 0) + +$diskGrid = New-Object System.Windows.Forms.ListView +$diskGrid.Dock = 'Fill' +$diskGrid.View = 'Details' +$diskGrid.FullRowSelect = $true +$diskGrid.Font = $script:FontBody +$diskGrid.BorderStyle = 'None' +[void]$diskGrid.Columns.Add('Drive', 70) +[void]$diskGrid.Columns.Add('Label', 160) +[void]$diskGrid.Columns.Add('Free', 100) +[void]$diskGrid.Columns.Add('Size', 100) +[void]$diskGrid.Columns.Add('Free %', 80) +[void]$diskGrid.Columns.Add('Pressure', 120) +$diskCard.Controls.Add($diskGrid) +$diskGrid.BringToFront() +$script:Sync.Controls.DiskGrid = $diskGrid + +function Update-DiskGrid { + param([AllowEmptyCollection()][object[]]$Disk) + + $grid = $script:Sync.Controls.DiskGrid + $grid.BeginUpdate() + try { + $grid.Items.Clear() + foreach ($item in $Disk) { + $row = New-Object System.Windows.Forms.ListViewItem([string]$item.Drive) + [void]$row.SubItems.Add([string]$item.Label) + [void]$row.SubItems.Add([string]$item.FreeDisplay) + [void]$row.SubItems.Add([string]$item.SizeDisplay) + [void]$row.SubItems.Add($(if ($null -ne $item.PercentFree) { "$($item.PercentFree)%" } else { '?' })) + [void]$row.SubItems.Add([string]$item.Pressure) + $row.ForeColor = switch ($item.Pressure) { + 'Critical' { Get-ThemeColor Danger } + 'Low' { Get-ThemeColor Warning } + 'Tight' { Get-ThemeColor Warning } + default { Get-ThemeColor Text } + } + [void]$grid.Items.Add($row) + } + } + finally { + $grid.EndUpdate() + } +} + +$storageActionCard = New-Card -Title 'Reclaim space' +$storagePage.Controls.Add($storageActionCard, 0, 1) + +$storageActions = New-Object System.Windows.Forms.FlowLayoutPanel +$storageActions.Dock = 'Fill' +$storageActions.AutoScroll = $true +$storageActionCard.Controls.Add($storageActions) +$storageActions.BringToFront() + +function Start-StorageAction { + <# + One wrapper for the storage buttons, so each of them is three lines + rather than a copy of the task plumbing. + #> + param( + [Parameter(Mandatory)][string]$Label, + [Parameter(Mandatory)][scriptblock]$Script, + [hashtable]$Arguments = @{} + ) + + Start-ShellTask -Name $Label -Arguments $Arguments -Script $Script -OnSuccess { + param($results) + $items = @($results | Where-Object { $_ }) + foreach ($result in $items) { + Add-ResultRow -Grid $script:Sync.Controls.StorageGrid -Result $result + } + Show-Summary -Result $items + } +} + +$storageActions.Controls.Add((New-Button -Text 'Refresh drives' -Width 160 -Accent -OnClick { + Start-ShellTask -Name 'Read drives' -Script { Get-PCDiskSpace } -OnSuccess { + param($disks) + Update-DiskGrid -Disk @($disks) + } +})) + +$storageActions.Controls.Add((New-Button -Text 'Storage clean-up' -Width 170 -OnClick { + Start-StorageAction -Label 'Storage profile' -Script { + Invoke-PCMaintenance -ProfileName 'Storage' -Confirm:$false + } +})) + +$storageActions.Controls.Add((New-Button -Text 'Delivery Optimization' -Width 190 -OnClick { + Start-StorageAction -Label 'Clear Delivery Optimization' -Script { + Clear-PCDeliveryOptimization -Confirm:$false + } +})) + +$storageActions.Controls.Add((New-Button -Text 'Crash dumps' -Width 150 -OnClick { + Start-StorageAction -Label 'Clear crash dumps' -Script { Clear-PCCrashDump -Confirm:$false } +})) + +$storageActions.Controls.Add((New-Button -Text 'Thumbnail cache' -Width 170 -OnClick { + Start-StorageAction -Label 'Clear thumbnail cache' -Script { Clear-PCThumbnailCache -Confirm:$false } +})) + +$storageActions.Controls.Add((New-Button -Text 'Previous Windows' -Width 180 -OnClick { + # The one irreversible reclaim on this page, so it asks first and says + # exactly what is given up. + $answer = [System.Windows.Forms.MessageBox]::Show( + ("Delete the previous Windows installation (C:\Windows.old)?`r`n`r`n" + + "This usually reclaims 15-30 GB, and it permanently removes the ability to " + + "roll back to the previous Windows build.`r`n`r`n" + + "It is refused if the folder is still inside the 10-day rollback window."), + 'PC Tools', 'YesNo', 'Warning') + + if ($answer -eq 'Yes') { + Start-StorageAction -Label 'Remove Windows.old' -Script { Clear-PCWindowsOld -Confirm:$false } + } +})) + +$storageActions.Controls.Add((New-Button -Text 'Find large folders' -Width 180 -OnClick { + Start-ShellTask -Name 'Scan disk usage' -Script { + Get-PCDiskUsage -Top 25 + } -OnSuccess { + param($usage) + Update-UsageGrid -Usage $usage + } +})) + +$storageBottom = New-Object System.Windows.Forms.TableLayoutPanel +$storageBottom.Dock = 'Fill' +$storageBottom.ColumnCount = 2 +$storageBottom.RowCount = 1 +[void]$storageBottom.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) +[void]$storageBottom.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 50))) +$storagePage.Controls.Add($storageBottom, 0, 2) + +$usageCard = New-Card -Title 'Largest folders and files' +$storageBottom.Controls.Add($usageCard, 0, 0) + +$usageGrid = New-Object System.Windows.Forms.ListView +$usageGrid.Dock = 'Fill' +$usageGrid.View = 'Details' +$usageGrid.FullRowSelect = $true +$usageGrid.Font = $script:FontBody +$usageGrid.BorderStyle = 'None' +[void]$usageGrid.Columns.Add('Size', 90) +[void]$usageGrid.Columns.Add('Path', 460) +$usageCard.Controls.Add($usageGrid) +$usageGrid.BringToFront() +$script:Sync.Controls.UsageGrid = $usageGrid + +function Update-UsageGrid { + param($Usage) + + $grid = $script:Sync.Controls.UsageGrid + $grid.BeginUpdate() + try { + $grid.Items.Clear() + if (-not $Usage) { return } + + foreach ($entry in @($Usage.LargestFolders)) { + $row = New-Object System.Windows.Forms.ListViewItem([string]$entry.SizeDisplay) + [void]$row.SubItems.Add([string]$entry.Path) + $row.ForeColor = Get-ThemeColor Text + [void]$grid.Items.Add($row) + } + foreach ($entry in @($Usage.LargestFiles)) { + $row = New-Object System.Windows.Forms.ListViewItem([string]$entry.SizeDisplay) + [void]$row.SubItems.Add([string]$entry.Path) + $row.ForeColor = Get-ThemeColor Muted + [void]$grid.Items.Add($row) + } + } + finally { + $grid.EndUpdate() + } +} + +$storageResultCard = New-Card -Title 'Results' +$storageBottom.Controls.Add($storageResultCard, 1, 0) + +$storageGrid = New-ResultGrid +$storageResultCard.Controls.Add($storageGrid) +$storageGrid.BringToFront() +$script:Sync.Controls.StorageGrid = $storageGrid + +Register-Page -Name 'Storage' -Subtitle 'Where the space went, and what can be reclaimed' -Panel $storagePage + +#endregion + +#region Health page + +<# + Read-only throughout. Everything here reports; nothing on this page changes + the machine, which is what makes it safe to open on somebody else's PC. +#> + +$healthPage = New-Object System.Windows.Forms.TableLayoutPanel +$healthPage.ColumnCount = 1 +$healthPage.RowCount = 2 +[void]$healthPage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 130))) +[void]$healthPage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) + +$healthActionCard = New-Card -Title 'Reports' +$healthPage.Controls.Add($healthActionCard, 0, 0) + +$healthActions = New-Object System.Windows.Forms.FlowLayoutPanel +$healthActions.Dock = 'Fill' +$healthActions.AutoScroll = $true +$healthActionCard.Controls.Add($healthActions) +$healthActions.BringToFront() + +function Show-HealthText { + <# + The health reports are all differently shaped, so rather than a grid per + report they render into one text pane. Read-only output; nothing here is + an action result. + #> + param([string]$Text) + + $script:Sync.Controls.HealthText.Text = $Text +} + +$healthActions.Controls.Add((New-Button -Text 'System info' -Width 150 -Accent -OnClick { + Start-ShellTask -Name 'System info' -Script { Get-PCSystemInfo | Format-List | Out-String -Width 160 } -OnSuccess { + param($text) + Show-HealthText -Text ([string]$text) + } +})) + +$healthActions.Controls.Add((New-Button -Text 'Disk health' -Width 150 -OnClick { + Start-ShellTask -Name 'Disk health' -Script { + $disks = @(Get-PCDiskHealth) + if ($disks.Count -eq 0) { 'No physical disk reported reliability data. This is normal for USB enclosures and some consumer drives.' } + else { $disks | Format-List FriendlyName, MediaType, SizeDisplay, Health, Wear, PowerOnHours, Temperature, Concerns | Out-String -Width 160 } + } -OnSuccess { + param($text) + Show-HealthText -Text ([string]$text) + } +})) + +$healthActions.Controls.Add((New-Button -Text 'Security posture' -Width 170 -OnClick { + Start-ShellTask -Name 'Security status' -Script { + $status = Get-PCSecurityStatus + $lines = @("Grade: $($status.Grade)", '') + if (@($status.Findings).Count -eq 0) { $lines += 'No findings.' } + else { foreach ($finding in $status.Findings) { $lines += " - $finding" } } + $lines += '' + $lines += ($status | Format-List Defender, Firewall, BitLocker, UacEnabled, SmartScreen, RebootPending | Out-String -Width 160) + $lines -join [Environment]::NewLine + } -OnSuccess { + param($text) + Show-HealthText -Text ([string]$text) + } +})) + +$healthActions.Controls.Add((New-Button -Text 'Recent errors' -Width 150 -OnClick { + Start-ShellTask -Name 'Event summary' -Script { + $summary = Get-PCEventSummary -Days 7 + $lines = @($summary.Verdict, '') + $lines += ($summary.Groups | Format-Table Provider, EventId, Count, LastSeen, Message -AutoSize | Out-String -Width 200) + $lines -join [Environment]::NewLine + } -OnSuccess { + param($text) + Show-HealthText -Text ([string]$text) + } +})) + +$healthActions.Controls.Add((New-Button -Text 'Boot time' -Width 140 -OnClick { + Start-ShellTask -Name 'Boot performance' -Script { + $boot = Get-PCBootPerformance + $lines = @($boot.Verdict, '') + if (@($boot.Degradations).Count -gt 0) { + $lines += ($boot.Degradations | Format-Table Name, Type, PenaltyMs -AutoSize | Out-String -Width 160) + } + $lines -join [Environment]::NewLine + } -OnSuccess { + param($text) + Show-HealthText -Text ([string]$text) + } +})) + +$healthActions.Controls.Add((New-Button -Text 'Startup programs' -Width 170 -OnClick { + Start-ShellTask -Name 'Startup items' -Script { + Get-PCStartupItem | Format-Table Name, Source, Scope, Enabled, Command -AutoSize | Out-String -Width 220 + } -OnSuccess { + param($text) + Show-HealthText -Text ([string]$text) + } +})) + +$healthActions.Controls.Add((New-Button -Text 'Battery' -Width 130 -OnClick { + Start-ShellTask -Name 'Battery report' -Script { + $cells = @(Get-PCBatteryReport) + if ($cells.Count -eq 0) { 'No battery detected - this looks like a desktop.' } + else { $cells | Format-List Name, Manufacturer, HealthPercent, ChargeRemaining, Verdict | Out-String -Width 160 } + } -OnSuccess { + param($text) + Show-HealthText -Text ([string]$text) + } +})) + +$healthTextCard = New-Card -Title 'Report' +$healthPage.Controls.Add($healthTextCard, 0, 1) + +$healthText = New-Object System.Windows.Forms.TextBox +$healthText.Dock = 'Fill' +$healthText.Multiline = $true +$healthText.ReadOnly = $true +$healthText.ScrollBars = 'Both' +$healthText.WordWrap = $false +$healthText.Font = $script:FontMono +$healthText.BorderStyle = 'None' +$healthText.Text = 'Pick a report above. Everything on this page only reads; nothing here changes the machine.' +$healthTextCard.Controls.Add($healthText) +$healthText.BringToFront() +$script:Sync.Controls.HealthText = $healthText + +Register-Page -Name 'Health' -Subtitle 'Read-only reports: hardware, security, stability' -Panel $healthPage + +#endregion + +#region Software page + +$softwarePage = New-Object System.Windows.Forms.TableLayoutPanel +$softwarePage.ColumnCount = 1 +$softwarePage.RowCount = 2 +[void]$softwarePage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 150))) +[void]$softwarePage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) + +$softwareActionCard = New-Card -Title 'Updates and applications' +$softwarePage.Controls.Add($softwareActionCard, 0, 0) + +$softwareActions = New-Object System.Windows.Forms.FlowLayoutPanel +$softwareActions.Dock = 'Fill' +$softwareActions.AutoScroll = $true +$softwareActionCard.Controls.Add($softwareActions) +$softwareActions.BringToFront() + +function Start-SoftwareAction { + param( + [Parameter(Mandatory)][string]$Label, + [Parameter(Mandatory)][scriptblock]$Script + ) + + Start-ShellTask -Name $Label -Script $Script -OnSuccess { + param($results) + $items = @($results | Where-Object { $_ }) + foreach ($result in $items) { + Add-ResultRow -Grid $script:Sync.Controls.SoftwareGrid -Result $result + } + Show-Summary -Result $items + } +} + +$softwareActions.Controls.Add((New-Button -Text 'Check for updates' -Width 180 -Accent -OnClick { + Start-ShellTask -Name 'Check Windows Update' -Script { + $updates = @(Get-PCWindowsUpdate) + if ($updates.Count -eq 0) { + @{ Text = 'Windows is up to date.' } + } + else { + @{ Text = ($updates | Format-Table Title, KB, SizeDisplay, RebootRequired -AutoSize | Out-String -Width 200) } + } + } -OnSuccess { + param($outcome) + Write-ShellLog -Level INFO -Message ([string]$outcome.Text) + Show-Page -Name 'Log' + } +})) + +$softwareActions.Controls.Add((New-Button -Text 'Install updates' -Width 160 -OnClick { + $answer = [System.Windows.Forms.MessageBox]::Show( + ("Download and install all pending Windows updates?`r`n`r`n" + + "This can take a long time and may require a restart afterwards. " + + "Nothing is restarted for you."), + 'PC Tools', 'YesNo', 'Warning') + + if ($answer -eq 'Yes') { + Start-SoftwareAction -Label 'Install Windows updates' -Script { + Install-PCWindowsUpdate -Confirm:$false + } + } +})) + +$softwareActions.Controls.Add((New-Button -Text 'Out-of-date apps' -Width 170 -OnClick { + Start-SoftwareAction -Label 'List app updates' -Script { Update-PCApplication -ListOnly -Confirm:$false } +})) + +$softwareActions.Controls.Add((New-Button -Text 'Update all apps' -Width 170 -OnClick { + Start-SoftwareAction -Label 'Upgrade applications' -Script { Update-PCApplication -Confirm:$false } +})) + +$softwareActions.Controls.Add((New-Button -Text 'Device problems' -Width 170 -OnClick { + Start-ShellTask -Name 'Driver issues' -Script { + $issues = @(Get-PCDriverIssue) + if ($issues.Count -eq 0) { 'Windows reports no device problems.' } + else { $issues | Format-Table Name, ProblemCode, Problem -AutoSize | Out-String -Width 200 } + } -OnSuccess { + param($text) + Write-ShellLog -Level INFO -Message ([string]$text) + Show-Page -Name 'Log' + } +})) + +$softwareActions.Controls.Add((New-Button -Text 'Schedule maintenance' -Width 190 -OnClick { + $answer = [System.Windows.Forms.MessageBox]::Show( + ("Run the Weekly profile automatically, every Sunday at 03:00?`r`n`r`n" + + "It reclaims disk space and refreshes DNS. It performs no repairs and " + + "never restarts the machine. Remove it later with " + + "Unregister-PCScheduledMaintenance."), + 'PC Tools', 'YesNo', 'Question') + + if ($answer -eq 'Yes') { + Start-SoftwareAction -Label 'Schedule weekly maintenance' -Script { + Register-PCScheduledMaintenance -ProfileName 'Weekly' -Frequency Weekly -Confirm:$false + } + } +})) + +$softwareResultCard = New-Card -Title 'Results' +$softwarePage.Controls.Add($softwareResultCard, 0, 1) + +$softwareGrid = New-ResultGrid +$softwareResultCard.Controls.Add($softwareGrid) +$softwareGrid.BringToFront() +$script:Sync.Controls.SoftwareGrid = $softwareGrid + +Register-Page -Name 'Software' -Subtitle 'Windows updates, applications and drivers' -Panel $softwarePage + +#endregion + #region Network page $networkPage = New-Object System.Windows.Forms.TableLayoutPanel @@ -1618,7 +2310,7 @@ $taskTimer.Add_Tick({ $form.Add_Shown({ Update-ThemeColors Update-ElevationState - Show-Page -Name 'Maintenance' + Show-Page -Name $Page $logTimer.Start() $taskTimer.Start() @@ -1630,10 +2322,35 @@ $form.Add_Shown({ Write-ShellLog -Level WARN -Message 'Running without elevation. Repair and network actions will fail until you restart as administrator.' } - # Populate the adapter list without blocking the window's first paint. - Start-ShellTask -Name 'Read adapters' -Script { Get-PCNetworkAdapter } -OnSuccess { - param($adapters) - Update-AdapterGrid -Adapter @($adapters) + # Fill the dashboard without blocking the window's first paint. One task, + # not three: Start-ShellTask serialises on the busy flag, so queuing several + # here would mean all but the first were turned away. + Start-ShellTask -Name 'Read this PC' -Script { + [pscustomobject]@{ + Adapters = @(Get-PCNetworkAdapter) + Disks = @(Get-PCDiskSpace) + History = Get-PCHistory -Summary + Schedule = @(Get-PCScheduledMaintenance) + } + } -OnSuccess { + param($state) + + Update-AdapterGrid -Adapter @($state.Adapters) + Update-DiskGrid -Disk @($state.Disks) + + $parts = @() + if ($state.History) { $parts += $state.History.Text } + + $scheduled = @($state.Schedule) + if ($scheduled.Count -gt 0) { + $next = $scheduled[0] + $parts += "Scheduled: $($next.ProfileName) - next run $($next.NextRunTime), last result $($next.LastResult)." + } + else { + $parts += 'No maintenance is scheduled. The Software page can set one up.' + } + + Update-DashboardStatus -Text ($parts -join ' ') } }) diff --git a/tests/Compile.Tests.ps1 b/tests/Compile.Tests.ps1 new file mode 100644 index 0000000..4f226fa --- /dev/null +++ b/tests/Compile.Tests.ps1 @@ -0,0 +1,105 @@ +#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.5.0' } + +<# + The shipped module is generated: build/Invoke-Build.ps1 -Task Compile merges + the source files into one .psm1 so a release does not open and parse sixty + files on every import. + + A generated artifact that differs from its source is the classic way to ship + a module that behaves differently from the one the tests ran against. These + tests exist so that cannot happen quietly. +#> + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + $script:SourceManifest = Join-Path $script:RepoRoot 'src/PCTools/PCTools.psd1' + $script:CompiledRoot = Join-Path $script:RepoRoot 'obj/PCTools' + + & (Join-Path $script:RepoRoot 'build/Invoke-Build.ps1') -Task Compile | Out-Null + + $script:CompiledManifest = Join-Path $script:CompiledRoot 'PCTools.psd1' + $script:CompiledModuleText = Get-Content -LiteralPath (Join-Path $script:CompiledRoot 'PCTools.psm1') -Raw +} + +Describe 'Compiled module' { + + It 'produces a manifest and a single module file' { + Test-Path -LiteralPath $script:CompiledManifest | Should -BeTrue + Test-Path -LiteralPath (Join-Path $script:CompiledRoot 'PCTools.psm1') | Should -BeTrue + } + + It 'ships the GUI shell with the module' { + Test-Path -LiteralPath (Join-Path $script:CompiledRoot 'Shell/Start-PCToolsShell.ps1') | Should -BeTrue + } + + It 'parses' { + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseInput( + $script:CompiledModuleText, [ref]$null, [ref]$parseErrors) + $parseErrors | Should -BeNullOrEmpty + } + + It 'stamps the version instead of reading the manifest at import' { + # The whole point of the stamp: a shipped module should not parse its + # own manifest every time somebody imports it. + $script:CompiledModuleText | Should -Not -Match '\{\{MODULE_VERSION\}\}' + + $version = (Import-PowerShellDataFile -Path $script:CompiledManifest).ModuleVersion + $script:CompiledModuleText | Should -Match ([regex]::Escape("`$script:ModuleVersion = '$version'")) + } + + It 'does not dot-source at import' { + # If the merge failed to inline something, the loader loop would still + # be there and the file would be doing both. + $script:CompiledModuleText | Should -Not -Match 'Dot-source Private first' + } + + It 'exports exactly what the source module exports' { + # The test that matters. Anything the merge drops, duplicates or fails + # to export shows up here rather than in a release. + Import-Module $script:SourceManifest -Force + $fromSource = (Get-Module PCTools).ExportedFunctions.Keys | Sort-Object + $sourceAliases = (Get-Module PCTools).ExportedAliases.Keys | Sort-Object + Remove-Module PCTools -Force + + Import-Module $script:CompiledManifest -Force + $fromCompiled = (Get-Module PCTools).ExportedFunctions.Keys | Sort-Object + $compiledAliases = (Get-Module PCTools).ExportedAliases.Keys | Sort-Object + Remove-Module PCTools -Force + + Compare-Object -ReferenceObject $fromSource -DifferenceObject $fromCompiled | + Should -BeNullOrEmpty + + Compare-Object -ReferenceObject $sourceAliases -DifferenceObject $compiledAliases | + Should -BeNullOrEmpty + } + + It 'carries every private and public source file' { + # Checked by the region marker the compiler emits per file rather than + # by function name: not every private file defines a function of its own + # name - PCPreferenceDefinition.ps1 holds the preference table - and a + # test that assumed otherwise would fail on a correct build. + foreach ($folder in 'Private', 'Public') { + $names = Get-ChildItem -Path (Join-Path $script:RepoRoot "src/PCTools/$folder") -Recurse -Filter '*.ps1' -File | + ForEach-Object BaseName + + foreach ($name in $names) { + $script:CompiledModuleText | Should -Match ([regex]::Escape("#region $folder/$name")) + } + } + } + + It 'keeps the private helpers private' { + Import-Module $script:CompiledManifest -Force + $exported = (Get-Module PCTools).ExportedFunctions.Keys + + $privateNames = Get-ChildItem -Path (Join-Path $script:RepoRoot 'src/PCTools/Private') -Recurse -Filter '*.ps1' -File | + ForEach-Object BaseName + + foreach ($name in $privateNames) { + $exported | Should -Not -Contain $name + } + + Remove-Module PCTools -Force + } +} diff --git a/tests/Extension.Tests.ps1 b/tests/Extension.Tests.ps1 new file mode 100644 index 0000000..c36e83f --- /dev/null +++ b/tests/Extension.Tests.ps1 @@ -0,0 +1,196 @@ +#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.5.0' } + +<# + The extension model, which is the newest and least conventional part of the + module: an action defined in a file on disk, loaded into the module's scope, + and reachable from a maintenance profile. + + Every test here corresponds to something that was actually wrong during + development. Loading appeared to succeed while leaving the action + uncallable; a single extension file assigned as a scalar rather than an + array; and the question of whether an extension could quietly replace a + built-in command needed an answer that was checked rather than asserted. +#> + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + + # A throwaway data root, so the test never reads or writes the real + # %LOCALAPPDATA%\PCTools and never sees a developer's own extensions. + $script:SandboxRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("pctools-ext-{0}" -f [guid]::NewGuid().ToString('N')) + $script:OriginalLocalAppData = $env:LOCALAPPDATA + $env:LOCALAPPDATA = $script:SandboxRoot + + $script:ExtensionDir = Join-Path $script:SandboxRoot 'PCTools\extensions' + New-Item -ItemType Directory -Path $script:ExtensionDir -Force | Out-Null + + Import-Module (Join-Path $script:RepoRoot 'src/PCTools/PCTools.psd1') -Force + $script:Module = Get-Module PCTools +} + +AfterAll { + Remove-Module PCTools -Force -ErrorAction SilentlyContinue + $env:LOCALAPPDATA = $script:OriginalLocalAppData + Remove-Item -LiteralPath $script:SandboxRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe 'Register-PCExtension' { + + It 'loads a single extension file' { + # One file is the normal case, and the one that broke: assigning the + # result of an `if` unrolled the one-element array to a scalar, and + # .Count on a string is a strict-mode error. + @' +function Invoke-PCExtensionAlpha { + [CmdletBinding()] + param([long]$Freed = 4096) + Invoke-PCAction -Name 'Invoke-PCExtensionAlpha' -Body { + @{ Status = 'Success'; Detail = 'alpha ran'; BytesFreed = $Freed } + } +} +'@ | Set-Content -LiteralPath (Join-Path $script:ExtensionDir 'alpha.PCAction.ps1') + + $loaded = @(Register-PCExtension -PassThru -Confirm:$false) + + $loaded.Count | Should -Be 1 + $loaded[0].Name | Should -Be 'Invoke-PCExtensionAlpha' + } + + It 'gives the extension access to the module private helpers' { + # An extension action is only useful if it can call Invoke-PCAction, so + # that its result has the same shape as a built-in's. That requires the + # scriptblock to belong to the module's session state. + $result = Invoke-PCMaintenance -Action 'Invoke-PCExtensionAlpha' -SkipRestorePoint -Confirm:$false + + $result.PSObject.TypeNames | Should -Contain 'PCTools.ActionResult' + $result.Action | Should -Be 'Invoke-PCExtensionAlpha' + $result.Status | Should -Be 'Success' + $result.BytesFreed | Should -Be 4096 + $result.FreedDisplay | Should -Be '4.0 KB' + } + + It 'is usable from a JSON profile, with parameters' { + $configPath = Join-Path $script:SandboxRoot 'profile.json' + @' +{ + "profiles": [ + { + "name": "ExtensionProfile", + "description": "Runs an extension action", + "restorePoint": false, + "actions": [ { "action": "Invoke-PCExtensionAlpha", "parameters": { "Freed": 8192 } } ] + } + ] +} +'@ | Set-Content -LiteralPath $configPath + + # Import must accept the extension name: validating only against + # exported commands would reject a profile that works. + { Import-PCConfiguration -Path $configPath } | Should -Not -Throw + + $results = @(Invoke-PCMaintenance -ProfileName 'ExtensionProfile' -SkipRestorePoint -Confirm:$false) + $results.Count | Should -Be 1 + $results[0].Status | Should -Be 'Success' + $results[0].BytesFreed | Should -Be 8192 + } + + It 'reports a registered extension through Get-PCExtension' { + $listed = @(Get-PCExtension | Where-Object Name -eq 'Invoke-PCExtensionAlpha') + $listed.Count | Should -Be 1 + $listed[0].Loaded | Should -BeTrue + } + + It 'refuses an extension that would replace a built-in command' { + @' +function Clear-PCTempFile { + 'hijacked' +} +'@ | Set-Content -LiteralPath (Join-Path $script:ExtensionDir 'hijack.PCAction.ps1') + + Register-PCExtension -Confirm:$false -WarningAction SilentlyContinue | Out-Null + + # The real command must still be the module's own. + $command = Get-Command Clear-PCTempFile + $command.Source | Should -Be 'PCTools' + $command.Parameters.Keys | Should -Contain 'IncludeSystemTemp' + + Remove-Item -LiteralPath (Join-Path $script:ExtensionDir 'hijack.PCAction.ps1') -Force + } + + It 'skips a file that does not parse, without failing the rest' { + @' +function Invoke-PCExtensionBroken { + param( +'@ | Set-Content -LiteralPath (Join-Path $script:ExtensionDir 'broken.PCAction.ps1') + + @' +function Invoke-PCExtensionBeta { + [CmdletBinding()] + param() + Invoke-PCAction -Name 'Invoke-PCExtensionBeta' -Body { @{ Detail = 'beta ran' } } +} +'@ | Set-Content -LiteralPath (Join-Path $script:ExtensionDir 'beta.PCAction.ps1') + + Register-PCExtension -Confirm:$false -WarningAction SilentlyContinue | Out-Null + + # The good file next to the broken one still loaded. + @(Get-PCExtension | Where-Object { $_.Name -eq 'Invoke-PCExtensionBeta' -and $_.Loaded }).Count | + Should -Be 1 + @(Get-PCExtension | Where-Object { $_.Name -eq 'Invoke-PCExtensionBroken' -and $_.Loaded }).Count | + Should -Be 0 + + Remove-Item -LiteralPath (Join-Path $script:ExtensionDir 'broken.PCAction.ps1') -Force + } + + It 'ignores a file defining no PC action' { + @' +function Get-SomethingElse { 'not a PC action' } +'@ | Set-Content -LiteralPath (Join-Path $script:ExtensionDir 'notanaction.PCAction.ps1') + + Register-PCExtension -Confirm:$false -WarningAction SilentlyContinue | Out-Null + @(Get-PCExtension | Where-Object { $_.Name -eq 'Get-SomethingElse' }).Count | Should -Be 0 + + Remove-Item -LiteralPath (Join-Path $script:ExtensionDir 'notanaction.PCAction.ps1') -Force + } + + It 'previews under -WhatIf without loading anything' { + @' +function Invoke-PCExtensionGamma { + [CmdletBinding()] + param() + Invoke-PCAction -Name 'Invoke-PCExtensionGamma' -Body { @{ Detail = 'gamma ran' } } +} +'@ | Set-Content -LiteralPath (Join-Path $script:ExtensionDir 'gamma.PCAction.ps1') + + Register-PCExtension -WhatIf | Out-Null + @(Get-PCExtension | Where-Object { $_.Name -eq 'Invoke-PCExtensionGamma' -and $_.Loaded }).Count | + Should -Be 0 + } + + It 'throws for a named file that does not exist' { + { Register-PCExtension -Path (Join-Path $script:SandboxRoot 'nope.PCAction.ps1') -Confirm:$false } | + Should -Throw + } +} + +Describe 'Unregistered action names' { + + It 'fail with a message naming the extension list' { + $result = Invoke-PCMaintenance -Action 'Invoke-PCNeverRegistered' -SkipRestorePoint -Confirm:$false + $result.Status | Should -Be 'Failed' + $result.Detail | Should -Match 'Get-PCExtension' + } + + It 'are rejected at config import rather than partway through a run' { + $configPath = Join-Path $script:SandboxRoot 'bad-profile.json' + @' +{ + "profiles": [ + { "name": "Bad", "restorePoint": false, "actions": [ { "action": "Invoke-PCNotAThing" } ] } + ] +} +'@ | Set-Content -LiteralPath $configPath + + { Import-PCConfiguration -Path $configPath } | Should -Throw + } +} diff --git a/tests/Install.Tests.ps1 b/tests/Install.Tests.ps1 new file mode 100644 index 0000000..a5a3a64 --- /dev/null +++ b/tests/Install.Tests.ps1 @@ -0,0 +1,133 @@ +#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.5.0' } + +<# + Static checks on install.ps1. + + The installer cannot be run here - it would install a module and open a + window - but it is the one file in this repository that users are invited to + pipe straight into iex, so the properties that make that defensible need to + be asserted rather than assumed. +#> + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + $script:InstallPath = Join-Path $script:RepoRoot 'install.ps1' + $script:InstallText = Get-Content -LiteralPath $script:InstallPath -Raw + $script:InstallAst = [System.Management.Automation.Language.Parser]::ParseFile( + $script:InstallPath, [ref]$null, [ref]$null) +} + +Describe 'install.ps1' { + + It 'exists at the repository root, where the pinned URL points' { + Test-Path -LiteralPath $script:InstallPath | Should -BeTrue + } + + It 'parses' { + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile( + $script:InstallPath, [ref]$null, [ref]$parseErrors) + $parseErrors | Should -BeNullOrEmpty + } + + It 'runs on Windows PowerShell 5.1, which is what a stock machine has' { + # Requiring PowerShell 7 would mean asking somebody to install a + # prerequisite before they can install anything. + $script:InstallText | Should -Match '#requires -Version 5\.1' + } + + It 'verifies a downloaded archive against the published checksum' { + $script:InstallText | Should -Match 'Get-FileHash' + $script:InstallText | Should -Match 'SHA256SUMS' + $script:InstallText | Should -Match 'Checksum mismatch' + } + + It 'has no way to skip verification' { + # A -SkipHashCheck switch would be used by exactly the people who should + # not use it, so it does not exist. + $parameters = $script:InstallAst.ParamBlock.Parameters | + ForEach-Object { $_.Name.VariablePath.UserPath } + + foreach ($name in $parameters) { + $name | Should -Not -Match '(?i)skip.*(hash|verif|check)|insecure|noverify' + } + } + + It 'prefers the PowerShell Gallery, where packages are signed' { + $script:InstallText | Should -Match 'Install-Module' + $script:InstallText | Should -Match 'Find-Module' + } + + It 'enables TLS 1.2, which Windows PowerShell 5.1 does not do by default' { + $script:InstallText | Should -Match 'Tls12' + } + + It 'documents the trade it asks the user to make' { + # The README and this file both have to be straight about the fact that + # the first step is trusted on TLS alone. + $script:InstallText | Should -Match 'honest limitation' + } + + It 'installs the shim somewhere already on PATH rather than editing PATH' { + $script:InstallText | Should -Match 'WindowsApps' + $script:InstallText | Should -Not -Match "SetEnvironmentVariable\('PATH'" + } + + It 'points $script:Repository at this project' { + # The download URLs are built from this one variable, so it is the thing + # worth pinning down. + $script:InstallText | Should -Match "\`$script:Repository = 'likeBloodMoon/pc-powershelltools'" + } + + It 'downloads only from the project repository' { + # Every URL in the installer must point at this project, either + # literally or through $script:Repository. A fetch from anywhere else in + # a script people are invited to pipe into iex would be indefensible. + $urls = [regex]::Matches($script:InstallText, 'https?://[^\s''")]+') | + ForEach-Object { $_.Value } + + $urls.Count | Should -BeGreaterThan 0 + + foreach ($url in $urls) { + $url | Should -Match '^https://(github\.com|raw\.githubusercontent\.com)/(likeBloodMoon/pc-powershelltools|\$script:Repository)' + } + } + + It 'never uses http for a download' { + $script:InstallText | Should -Not -Match 'Invoke-WebRequest[^\r\n]*http://' + } +} + +Describe 'The pinned install command' { + + It 'is pinned to a tag in the README, never to main' { + $readme = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'README.md') -Raw + + $installLines = [regex]::Matches($readme, '(?m)^.*install\.ps1.*$') | + ForEach-Object { $_.Value } + + $installLines.Count | Should -BeGreaterThan 0 + + foreach ($line in $installLines) { + if ($line -notmatch 'raw\.githubusercontent\.com') { continue } + $line | Should -Not -Match '/main/' + $line | Should -Match '/v\d+\.\d+\.\d+/' + } + } + + It 'matches the version in the module manifest' { + # A README pointing at a tag that was never cut is a broken install + # command, and nobody notices until somebody tries it. + $manifest = Import-PowerShellDataFile -Path (Join-Path $script:RepoRoot 'src/PCTools/PCTools.psd1') + $readme = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'README.md') -Raw + + $tags = [regex]::Matches($readme, 'pc-powershelltools/v(\d+\.\d+\.\d+)/install\.ps1') | + ForEach-Object { $_.Groups[1].Value } | + Sort-Object -Unique + + $tags.Count | Should -BeGreaterThan 0 + foreach ($tag in $tags) { + $tag | Should -Be $manifest.ModuleVersion + } + } +} diff --git a/tests/PCTools.Unit.Tests.ps1 b/tests/PCTools.Unit.Tests.ps1 index 65073a6..88d8b58 100644 --- a/tests/PCTools.Unit.Tests.ps1 +++ b/tests/PCTools.Unit.Tests.ps1 @@ -170,8 +170,37 @@ Describe 'Get-PCConnectivityVerdict' { Describe 'Maintenance profiles' { - It 'defines the four built-in profiles' { - (Get-PCMaintenanceProfile).Name | Should -Be @('Quick', 'Recommended', 'Full', 'NetworkRepair') + It 'defines the built-in profiles, in order' { + # Order is part of the contract: the GUI's profile list and the README + # table both present them least to most invasive. + (Get-PCMaintenanceProfile).Name | + Should -Be @('Quick', 'Recommended', 'Full', 'Storage', 'Health', 'Weekly', 'NetworkRepair') + } + + It 'keeps the irreversible actions out of every built-in profile' { + # Removing the previous Windows installation, clearing the event log and + # uninstalling apps all give up something that cannot be got back. Each + # has to be asked for by name; none may arrive as part of a preset. + $irreversible = @('Clear-PCWindowsOld', 'Clear-PCEventLog', 'Remove-PCAppxPackage', 'Set-PCServiceStartup') + + foreach ($profileEntry in (Get-PCMaintenanceProfile)) { + foreach ($step in $profileEntry.Actions) { + $irreversible | Should -Not -Contain $step.Action ` + -Because "profile $($profileEntry.Name) must not include an irreversible action" + } + } + } + + It 'offers a read-only Health profile that changes nothing' { + $health = Get-PCMaintenanceProfile -Name 'Health' + $health.RestorePoint | Should -BeFalse + + foreach ($step in $health.Actions) { + # Every action in Health either only reads, or is explicitly told to + # scan rather than repair. + $isScanOnly = $step.ContainsKey('Parameters') -and $step.Parameters.ContainsKey('ScanOnly') + $isScanOnly | Should -BeTrue -Because "$($step.Action) must be scan-only in the Health profile" + } } It 'throws a helpful error for an unknown profile' { diff --git a/tests/Parallel.Tests.ps1 b/tests/Parallel.Tests.ps1 new file mode 100644 index 0000000..7044b81 --- /dev/null +++ b/tests/Parallel.Tests.ps1 @@ -0,0 +1,175 @@ +#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.5.0' } + +<# + Invoke-PCParallel is the piece the whole v0.5 performance story rests on, + and it is the piece that can go wrong silently: a race that only shows up + under load, results returned out of order so a traceroute reports hop 7 as + hop 2, or an error swallowed so a failed probe reads as a passed one. + + It is also platform-independent, so these run everywhere including Linux CI. +#> + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:RepoRoot 'src/PCTools/PCTools.psd1') -Force + $script:Module = Get-Module PCTools +} + +AfterAll { + Remove-Module PCTools -Force -ErrorAction SilentlyContinue +} + +Describe 'Invoke-PCParallel' { + + It 'returns one result per input, in input order' { + # Execution is concurrent; output order is not allowed to be. Every + # caller in the module depends on this - Test-PCRoute maps results back + # to hop numbers by index. + $result = & $script:Module { + Invoke-PCParallel -InputObject (1..12) -ScriptBlock { + param($n) + # Reverse the sleep so later items finish first if ordering is + # accidental rather than enforced. + Start-Sleep -Milliseconds (130 - ($n * 10)) + $n * 2 + } -ThrottleLimit 12 + } + + @($result).Count | Should -Be 12 + @($result | ForEach-Object { $_.Input }) | Should -Be (1..12) + @($result | ForEach-Object { $_.Output }) | Should -Be (1..12 | ForEach-Object { $_ * 2 }) + @($result | ForEach-Object { $_.Index }) | Should -Be (0..11) + } + + It 'actually runs concurrently' { + $watch = [System.Diagnostics.Stopwatch]::StartNew() + $null = & $script:Module { + Invoke-PCParallel -InputObject (1..6) -ScriptBlock { + param($n) Start-Sleep -Milliseconds 300 + } -ThrottleLimit 6 + } + $watch.Stop() + + # Serially this is 1800 ms. The bound is loose because runspace startup + # on a loaded CI machine is not free, but it cannot reach serial time. + $watch.Elapsed.TotalMilliseconds | Should -BeLessThan 1500 + } + + It 'passes the item as an argument and as $_' { + $viaParam = & $script:Module { + Invoke-PCParallel -InputObject @('a', 'b') -ScriptBlock { param($x) "p:$x" } + } + $viaUnderscore = & $script:Module { + Invoke-PCParallel -InputObject @('a', 'b') -ScriptBlock { "u:$_" } + } + + @($viaParam | ForEach-Object { $_.Output }) | Should -Be @('p:a', 'p:b') + @($viaUnderscore | ForEach-Object { $_.Output }) | Should -Be @('u:a', 'u:b') + } + + It 'captures a failure per item without losing the others' { + $result = & $script:Module { + Invoke-PCParallel -InputObject (1..4) -ScriptBlock { + param($n) + if ($n -eq 3) { throw 'deliberate' } + "ok$n" + } + } + + @($result).Count | Should -Be 4 + $result[2].Error | Should -Not -BeNullOrEmpty + $result[2].Output | Should -BeNullOrEmpty + $result[0].Output | Should -Be 'ok1' + $result[3].Output | Should -Be 'ok4' + } + + It 'returns nothing for an empty collection' { + $result = & $script:Module { Invoke-PCParallel -InputObject @() -ScriptBlock { 1 } } + $result | Should -BeNullOrEmpty + } + + It 'keeps the same output shape for a single item' { + # One item skips the pool entirely. That fast path must not produce a + # different shape, or every caller needs two code paths. + $result = & $script:Module { + Invoke-PCParallel -InputObject @(21) -ScriptBlock { param($n) $n * 2 } + } + + @($result).Count | Should -Be 1 + $result.Index | Should -Be 0 + $result.Input | Should -Be 21 + $result.Output | Should -Be 42 + } + + It 'passes extra arguments after the item' { + $result = & $script:Module { + Invoke-PCParallel -InputObject @('x', 'y') -ScriptBlock { + param($Item, $Suffix, $Count) "$Item-$Suffix-$Count" + } -ArgumentList @('s', 7) + } + + @($result | ForEach-Object { $_.Output }) | Should -Be @('x-s-7', 'y-s-7') + } + + It 'respects the throttle limit' { + $result = & $script:Module { + Invoke-PCParallel -InputObject (1..8) -ScriptBlock { + param($n) + Start-Sleep -Milliseconds 150 + [System.Threading.Thread]::CurrentThread.ManagedThreadId + } -ThrottleLimit 2 + } + + # Two runspaces reusing threads cannot produce eight distinct thread ids. + $distinct = @($result | ForEach-Object { $_.Output } | Sort-Object -Unique) + $distinct.Count | Should -BeLessOrEqual 2 + } + + It 'times out rather than hanging the caller' { + $watch = [System.Diagnostics.Stopwatch]::StartNew() + $result = & $script:Module { + Invoke-PCParallel -InputObject (1..2) -ScriptBlock { + param($n) Start-Sleep -Seconds 30 + } -TimeoutSeconds 2 + } + $watch.Stop() + + $watch.Elapsed.TotalSeconds | Should -BeLessThan 15 + @($result | Where-Object { $_.Error }).Count | Should -Be 2 + } + + It 'runs an init script in every runspace' { + $result = & $script:Module { + $init = [scriptblock]::Create('function Get-Answer { 42 }') + Invoke-PCParallel -InputObject (1..3) -ScriptBlock { + param($n) (Get-Answer) + $n + } -InitScript $init + } + + @($result | ForEach-Object { $_.Output }) | Should -Be @(43, 44, 45) + } +} + +Describe 'Get-PCFunctionSource' { + + It 'reproduces a module function so a parallel body can call it' { + # This is what keeps one implementation of a ping rather than two. + $result = & $script:Module { + $init = Get-PCFunctionSource -Name 'Format-PCByteSize' + Invoke-PCParallel -InputObject @(1024, 1048576) -ScriptBlock { + param($bytes) Format-PCByteSize -Bytes $bytes + } -InitScript $init + } + + @($result | ForEach-Object { $_.Output }) | Should -Be @('1.0 KB', '1.0 MB') + } + + It 'can supply a no-op log stub for helpers that log' { + $text = & $script:Module { (Get-PCFunctionSource -Name 'Format-PCByteSize' -IncludeLogStub).ToString() } + $text | Should -Match 'function Write-PCLog' + } + + It 'throws for a function that does not exist' { + { & $script:Module { Get-PCFunctionSource -Name 'No-SuchFunction' } } | Should -Throw + } +} diff --git a/tests/Safety.Tests.ps1 b/tests/Safety.Tests.ps1 new file mode 100644 index 0000000..369f8d8 --- /dev/null +++ b/tests/Safety.Tests.ps1 @@ -0,0 +1,176 @@ +#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.5.0' } + +<# + The v0.5 commands that can do real damage, and the guards that stop them. + + Remove-PCAppxPackage and Set-PCServiceStartup are the sharpest edges added in + this release: one uninstalls software, the other can leave a machine without + a firewall or unable to boot. Both are allowlist-driven, and an allowlist is + only worth anything if something checks what is on it. +#> + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:RepoRoot 'src/PCTools/PCTools.psd1') -Force + $script:Module = Get-Module PCTools +} + +AfterAll { + Remove-Module PCTools -Force -ErrorAction SilentlyContinue +} + +Describe 'Critical service list' { + + It 'protects the services that break a machine outright' { + $critical = & $script:Module { Get-PCCriticalServiceName } + + # Each of these has a specific, well-known failure: no firewall, no + # boot, no updates, no WMI, no restore points. + foreach ($name in 'BFE', 'DcomLaunch', 'RpcSs', 'Winmgmt', 'CryptSvc', + 'MpsSvc', 'TrustedInstaller', 'wuauserv', 'Schedule', + 'ProfSvc', 'Volsnap', 'gpsvc') { + $critical | Should -Contain $name + } + } + + It 'has no duplicates' { + $critical = @(& $script:Module { Get-PCCriticalServiceName }) + ($critical | Sort-Object -Unique).Count | Should -Be $critical.Count + } +} + +Describe 'Removable app allowlist' { + + It 'never lists something the system or this module depends on' { + $removable = & $script:Module { Get-PCRemovableAppxName } + + # The Store is how anything gets reinstalled, including itself. + # DesktopAppInstaller carries winget, which two commands here need. + # Photos and Calculator leave a user without a viewer or a calculator. + # Terminal is often the shell this is running in. + foreach ($name in 'Microsoft.WindowsStore', 'Microsoft.DesktopAppInstaller', + 'Microsoft.Windows.Photos', 'Microsoft.WindowsCalculator', + 'Microsoft.WindowsTerminal', 'Microsoft.SecHealthUI') { + $removable | Should -Not -Contain $name + } + } + + It 'lists no framework or runtime package' { + $removable = & $script:Module { Get-PCRemovableAppxName } + + foreach ($name in $removable) { + $name | Should -Not -Match 'VCLibs|Framework|\.NET|Runtime' + } + } + + It 'has no duplicates' { + $removable = @(& $script:Module { Get-PCRemovableAppxName }) + ($removable | Sort-Object -Unique).Count | Should -Be $removable.Count + } +} + +Describe 'New mutating commands are previewable' { + + BeforeDiscovery { + Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'src/PCTools/PCTools.psd1') -Force + + # Everything added in v0.5 that changes machine state. The contract is + # the same one the module has had since v0.4: if it changes something, + # you can see what it would change first. + $script:NewMutating = @( + 'Clear-PCCrashDump', 'Clear-PCDeliveryOptimization', 'Clear-PCEventLog' + 'Clear-PCThumbnailCache', 'Clear-PCWindowsOld', 'Optimize-PCVolume' + 'Disable-PCStartupItem', 'Enable-PCStartupItem', 'Set-PCServiceStartup' + 'Install-PCWindowsUpdate', 'Update-PCApplication', 'Remove-PCAppxPackage' + 'Register-PCScheduledMaintenance', 'Unregister-PCScheduledMaintenance' + 'Register-PCExtension', 'Save-PCHistory', 'Invoke-PCTools' + ) | ForEach-Object { @{ Name = $_ } } + } + + It ' supports ShouldProcess' -ForEach $script:NewMutating { + $command = Get-Command -Name $Name -Module PCTools -ErrorAction Stop + $command.Parameters.Keys | Should -Contain 'WhatIf' + $command.Parameters.Keys | Should -Contain 'Confirm' + } +} + +Describe 'High-impact commands prompt by default' { + + BeforeDiscovery { + # ConfirmImpact High means PowerShell prompts unless the caller says + # otherwise. These are the ones where a silent run would be wrong. + $script:HighImpact = @( + 'Clear-PCWindowsOld' # gives up the rollback to the previous build + 'Clear-PCEventLog' # discards an audit trail + 'Set-PCServiceStartup' # can disable something load-bearing + 'Remove-PCAppxPackage' # uninstalls software + 'Install-PCWindowsUpdate' # can demand a restart + 'Register-PCExtension' # runs code from disk + ) | ForEach-Object { @{ Name = $_ } } + } + + It ' declares ConfirmImpact High' -ForEach $script:HighImpact { + $command = Get-Command -Name $Name -Module PCTools + $metadata = [System.Management.Automation.CommandMetadata]::new($command) + $metadata.ConfirmImpact | Should -Be 'High' + } +} + +Describe 'Read-only reporting commands change nothing' { + + BeforeDiscovery { + # These exist to report. A ShouldProcess parameter on any of them would + # mean somebody had given a reporting command the ability to act. + $script:ReadOnly = @( + 'Get-PCSecurityStatus', 'Get-PCHealthReport', 'Get-PCSystemInfo' + 'Get-PCDiskHealth', 'Get-PCDiskSpace', 'Get-PCDiskUsage' + 'Get-PCEventSummary', 'Get-PCBootPerformance', 'Get-PCBatteryReport' + 'Get-PCStartupItem', 'Get-PCService', 'Get-PCAppxPackage' + 'Get-PCDriverIssue', 'Get-PCWindowsUpdate', 'Get-PCHistory' + 'Get-PCScheduledMaintenance', 'Get-PCExtension', 'Get-PCResultSummary' + ) | ForEach-Object { @{ Name = $_ } } + } + + It ' takes no WhatIf, because it has nothing to preview' -ForEach $script:ReadOnly { + $command = Get-Command -Name $Name -Module PCTools -ErrorAction Stop + $command.Parameters.Keys | Should -Not -Contain 'WhatIf' + } +} + +Describe 'Get-PCResultSummary' { + + It 'handles an empty batch without throwing' { + # Measure-Object emits nothing for an empty collection, and reading .Sum + # off nothing is a terminating error under strict mode. A network-only + # report reaches this path, so it is a normal case, not a corner one. + $summary = Get-PCResultSummary -Result @() + $summary.Total | Should -Be 0 + $summary.BytesFreed | Should -Be 0 + $summary.Text | Should -Be 'Nothing ran' + } + + It 'totals and grades a mixed batch' { + $results = & $script:Module { + @( + New-PCActionResult -Action 'A' -Status Success -BytesFreed 1073741824 -Duration ([timespan]::FromSeconds(2)) + New-PCActionResult -Action 'B' -Status Warning -BytesFreed 1048576 + New-PCActionResult -Action 'C' -Status Failed + New-PCActionResult -Action 'D' -Status Success -RebootRequired + ) + } + + $summary = Get-PCResultSummary -Result $results + $summary.Total | Should -Be 4 + $summary.Succeeded | Should -Be 2 + $summary.Warnings | Should -Be 1 + $summary.Failed | Should -Be 1 + $summary.RebootRequired | Should -BeTrue + $summary.BytesFreed | Should -Be 1074790400 + $summary.Text | Should -Match 'restart required' + } + + It 'ignores objects that are not action results' { + $summary = Get-PCResultSummary -Result @('a string', [pscustomobject]@{ Foo = 1 }) + $summary.Total | Should -Be 0 + } +} diff --git a/tests/Shell.Tests.ps1 b/tests/Shell.Tests.ps1 index fcf669e..558356e 100644 --- a/tests/Shell.Tests.ps1 +++ b/tests/Shell.Tests.ps1 @@ -10,7 +10,7 @@ BeforeAll { $script:RepoRoot = Split-Path -Parent $PSScriptRoot - $script:ShellPath = Join-Path $script:RepoRoot 'src/Shell/Start-PCToolsShell.ps1' + $script:ShellPath = Join-Path $script:RepoRoot 'src/PCTools/Shell/Start-PCToolsShell.ps1' $script:ShellAst = [System.Management.Automation.Language.Parser]::ParseFile( $script:ShellPath, [ref]$null, [ref]$null) @@ -31,6 +31,20 @@ Describe 'Shell structure' { $script:ShellText | Should -Match '#requires -Version 5\.1' } + It 'ships inside the module, so a Gallery install carries the GUI' { + # The whole point of moving it: Install-Module PCTools then Start-PCTools, + # with no separate archive download. + $script:ShellPath | Should -Match '[\\/]src[\\/]PCTools[\\/]Shell[\\/]' + $script:ExportedCommand | Should -Contain 'Start-PCTools' + } + + It 'does not re-import the module it is running inside' { + # Start-PCTools invokes this script from within PCTools. An + # Import-Module -Force here would tear down and rebuild the module + # mid-call. + $script:ShellText | Should -Match 'if \(Get-Module -Name PCTools\)' + } + It 'declares every control key it later reads' { # Under Set-StrictMode -Version Latest, reading an undeclared hashtable # key is a terminating error, so an undeclared key is a crash waiting From c5f9a8f8a9fcffeb6286537764da799a0b6d165a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:38:45 +0000 Subject: [PATCH 2/4] Fix two shipped-layout bugs, a parallel result loss, and CI Codex review and the first CI run between them found four real defects and three stale assertions. All of them are in v0.5.0, which has not shipped. Two bugs came from the same root cause: the source tree and the compiled module nest functions at different depths, so any path built by walking up from $PSScriptRoot is correct in one and wrong in the other. - Start-PCTools resolved the GUI shell as "$PSScriptRoot\..\Shell". In the source tree the function lives in Public/ and that is right; in the compiled module $PSScriptRoot is already the module root, so it pointed at a sibling folder that does not exist. Every Gallery and archive install would have thrown "the shell is missing" - on the one command this release is named after. Verified against a simulated install: the old expression resolved a path that is not there, the new one resolves the shell. - Register-PCScheduledMaintenance built its manifest path two parents up, which landed outside the versioned module folder and fell back to Import-Module by name. A scheduled task runs as SYSTEM, whose module path does not include a CurrentUser install, so every task registered from a normal install would have failed before running its profile. Both now read $ExecutionContext.SessionState.Module.ModuleBase, which is the module root in either layout, and a test asserts neither file goes back to depending on its own nesting depth. - Invoke-PCParallel discarded results that had already completed once the batch deadline passed. The tasks run concurrently, so a slow item early in the list could burn the whole budget while every later item had finished - and those finished results were then reported as timeouts. Test-PCConnectivity is where this would have bitten: the gateway probe is both first and the likeliest to hang. Completion is now checked before the budget. The new test fails against the old logic and passes against the fix. - The release notes named pc-tools-v0.5.0.zip; the build stages the archive without the tag's leading v, so the documented verification step named a file that is not attached. CI: - docs/COMMANDS.md was written with Set-Content -Encoding UTF8, which emits a BOM on Windows PowerShell 5.1 and none on PowerShell 7. The generated file therefore differed by three bytes depending on who ran the task, which is what the freshness check reported as stale. It is now written as UTF-8 without a BOM, with LF endings, identically on every host. - Three assertions had gone stale against my own changes: two expected error text I had deliberately improved or profile lists I had added to, and the third - in Install.Tests - had the very array-unwrapping bug this release fixes in the module code, where a single regex match assigns a scalar and .Count on it throws under strict mode. Local verification: 718 assertions across ten suites, 0 failures; analyzer clean at 197 warnings against a 210 budget; the Docs task is now idempotent, the release stages, and the compiled module is still byte-reproducible. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KkdwqfkUYsNSkp9f3AVDqg --- .github/workflows/ci.yml | 6 ++- .github/workflows/release.yml | 2 +- CHANGELOG.md | 25 +++++++++ build/Invoke-Build.ps1 | 9 +++- src/PCTools/Private/Invoke-PCParallel.ps1 | 12 ++++- .../Register-PCScheduledMaintenance.ps1 | 11 +++- src/PCTools/Public/Start-PCTools.ps1 | 13 ++++- tests/Compile.Tests.ps1 | 53 +++++++++++++++++++ tests/Configuration.Tests.ps1 | 2 +- tests/Install.Tests.ps1 | 16 +++--- tests/PCTools.Unit.Tests.ps1 | 2 +- tests/Parallel.Tests.ps1 | 27 ++++++++++ 12 files changed, 161 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b454d6..55ebfed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,9 +88,11 @@ jobs: shell: powershell run: | ./build/Invoke-Build.ps1 -Task Docs - $diff = git diff --name-only -- docs/COMMANDS.md + # --ignore-cr-at-eol so a checkout that materialised CRLF does not + # read as a stale reference; the task itself writes LF. + $diff = git diff --ignore-cr-at-eol --name-only -- docs/COMMANDS.md if ($diff) { - git --no-pager diff -- docs/COMMANDS.md + git --no-pager diff --ignore-cr-at-eol -- docs/COMMANDS.md throw 'docs/COMMANDS.md is out of date. Run ./build/Invoke-Build.ps1 -Task Docs and commit the result.' } Write-Host 'Command reference is up to date.' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97503fd..4dd2200 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -139,7 +139,7 @@ jobs: Every file below is listed in `SHA256SUMS`: ```powershell - (Get-FileHash pc-tools-${{ github.ref_name }}.zip -Algorithm SHA256).Hash.ToLower() + (Get-FileHash pc-tools-${{ steps.version.outputs.version }}.zip -Algorithm SHA256).Hash.ToLower() # compare against the matching line in SHA256SUMS ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c45945..ae78aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,31 @@ roughly double the commands. - `Test-PCMtu` no longer uses `GetNewClosure()`. It rebinds a scriptblock to a scope detached from the module's session state, so the probe could not see the private helper it needed and every call failed. +- `Start-PCTools` could not find the GUI in the module that actually ships. It + resolved the shell as `$PSScriptRoot\..\Shell`, which is correct in the source + tree - the function lives in `Public/` - and wrong in the compiled module, + where `$PSScriptRoot` is already the module root. Every Gallery and archive + install would have thrown "the shell is missing" on the command this release + is named after. +- `Register-PCScheduledMaintenance` registered a task that imported PCTools by + name rather than by path, for the same reason: two parents up from + `$PSScriptRoot` landed outside the versioned module folder. A scheduled task + runs as SYSTEM, whose module path does not include a CurrentUser install, so + every task registered from a normal install would have failed before running + its profile. Both now resolve from + `$ExecutionContext.SessionState.Module.ModuleBase`, correct in either layout. +- `Invoke-PCParallel` discarded results that had already completed once the + batch deadline passed. The tasks run concurrently, so one slow item early in + the list could exhaust the budget while every later item had finished - and + those finished results were then reported as timeouts. Completion is now + checked before the budget. +- `docs/COMMANDS.md` was generated with a BOM on Windows PowerShell 5.1 and + without one on PowerShell 7, so the CI freshness check called it stale + depending only on which host generated it. It is now written as UTF-8 without + a BOM, with LF endings, on every host. +- The release notes named `pc-tools-v0.5.0.zip`; the build stages the archive + without the tag's leading `v`, so the documented verification step named a + file that is not attached. ## [0.4.0] - 2026-01-15 diff --git a/build/Invoke-Build.ps1 b/build/Invoke-Build.ps1 index 22ad6bf..eeffe13 100644 --- a/build/Invoke-Build.ps1 +++ b/build/Invoke-Build.ps1 @@ -381,7 +381,14 @@ function Invoke-DocsTask { if (-not (Test-Path $docsDir)) { New-Item -ItemType Directory -Path $docsDir -Force | Out-Null } $path = Join-Path $docsDir 'COMMANDS.md' - $lines | Set-Content -LiteralPath $path -Encoding UTF8 + + # UTF-8 without a BOM, and LF endings, written the same way on every host. + # Set-Content -Encoding UTF8 writes a BOM on Windows PowerShell 5.1 and none + # on PowerShell 7, so the generated file differed by three bytes depending + # on who ran the task - which is exactly what the CI freshness check then + # reported as "out of date". + $text = ($lines -join "`n") + "`n" + [System.IO.File]::WriteAllText($path, $text, [System.Text.UTF8Encoding]::new($false)) Write-Host "Wrote $path ($($commands.Count) commands)" -ForegroundColor Green } diff --git a/src/PCTools/Private/Invoke-PCParallel.ps1 b/src/PCTools/Private/Invoke-PCParallel.ps1 index d7dca67..226d7f1 100644 --- a/src/PCTools/Private/Invoke-PCParallel.ps1 +++ b/src/PCTools/Private/Invoke-PCParallel.ps1 @@ -161,9 +161,19 @@ function Invoke-PCParallel { $output = $null $failure = $null + # Completion is checked before the budget. The tasks run + # concurrently, so one slow item early in the list can exhaust the + # deadline while every later item has already finished; treating a + # spent budget as a timeout without looking would throw away those + # finished results and report them as failures. $remaining = [int]([math]::Max(0, ($deadline - (Get-Date)).TotalMilliseconds)) + $finished = $task.Handle.IsCompleted - if ($remaining -le 0 -or -not $task.Handle.AsyncWaitHandle.WaitOne($remaining)) { + if (-not $finished -and $remaining -gt 0) { + $finished = $task.Handle.AsyncWaitHandle.WaitOne($remaining) + } + + if (-not $finished) { try { $task.Shell.Stop() } catch { } $failure = "Timed out after $TimeoutSeconds seconds." } diff --git a/src/PCTools/Public/Automation/Register-PCScheduledMaintenance.ps1 b/src/PCTools/Public/Automation/Register-PCScheduledMaintenance.ps1 index 81b48d9..f300103 100644 --- a/src/PCTools/Public/Automation/Register-PCScheduledMaintenance.ps1 +++ b/src/PCTools/Public/Automation/Register-PCScheduledMaintenance.ps1 @@ -91,8 +91,15 @@ function Register-PCScheduledMaintenance { if (-not $ReportPath) { $ReportPath = Join-Path $script:PCDataRoot 'reports' } - $moduleBase = Split-Path -Parent $PSScriptRoot - $moduleBase = Split-Path -Parent $moduleBase + # From the module's own base, not by walking up from $PSScriptRoot: in + # the compiled module that this function ships in, $PSScriptRoot is + # already the module root, so two parents pointed outside the versioned + # module folder entirely. The task then fell back to Import-Module by + # name and failed, because it runs as SYSTEM and a CurrentUser install + # is not on SYSTEM's module path. + $moduleBase = $ExecutionContext.SessionState.Module.ModuleBase + if (-not $moduleBase) { $moduleBase = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) } + $manifest = Join-Path $moduleBase 'PCTools.psd1' # Import by path rather than by name: the task runs as SYSTEM or as a diff --git a/src/PCTools/Public/Start-PCTools.ps1 b/src/PCTools/Public/Start-PCTools.ps1 index 28fb48f..757adfb 100644 --- a/src/PCTools/Public/Start-PCTools.ps1 +++ b/src/PCTools/Public/Start-PCTools.ps1 @@ -49,8 +49,17 @@ function Start-PCTools { [switch]$Elevate ) - $shellPath = Join-Path $PSScriptRoot '..\Shell\Start-PCToolsShell.ps1' - $shellPath = [System.IO.Path]::GetFullPath($shellPath) + # Resolved from the module's own base, never by walking up from + # $PSScriptRoot. The two layouts do not agree: in the source tree this + # function lives in Public/ and the shell is one level up, but the shipped + # module is compiled into a single PCTools.psm1 at the module root, where + # '..\Shell' points at a sibling of the module folder that does not exist. + # That version of this line meant Start-PCTools threw "shell is missing" on + # every Gallery and archive install - which is to say, on every install. + $moduleBase = $ExecutionContext.SessionState.Module.ModuleBase + if (-not $moduleBase) { $moduleBase = Split-Path -Parent $PSScriptRoot } + + $shellPath = [System.IO.Path]::GetFullPath((Join-Path $moduleBase 'Shell\Start-PCToolsShell.ps1')) if (-not (Test-Path -LiteralPath $shellPath)) { throw "The PC Tools shell is missing from the module. Expected it at: $shellPath" diff --git a/tests/Compile.Tests.ps1 b/tests/Compile.Tests.ps1 index 4f226fa..caf3698 100644 --- a/tests/Compile.Tests.ps1 +++ b/tests/Compile.Tests.ps1 @@ -89,6 +89,59 @@ Describe 'Compiled module' { } } + It 'lets the shipped module find its own GUI shell' { + # The layout regression that made v0.5's headline feature unusable. + # Start-PCTools resolved the shell as "$PSScriptRoot\..\Shell", which is + # right in the source tree - the function lives in Public/ - and wrong + # in the compiled module, where $PSScriptRoot is already the module root + # and '..' points at a sibling folder that does not exist. Every Gallery + # and archive install threw "the shell is missing". + Import-Module $script:CompiledManifest -Force + + $resolved = & (Get-Module PCTools) { + $base = $ExecutionContext.SessionState.Module.ModuleBase + Join-Path $base 'Shell/Start-PCToolsShell.ps1' + } + + Test-Path -LiteralPath $resolved | Should -BeTrue -Because 'Start-PCTools resolves the shell from exactly this path' + Remove-Module PCTools -Force + } + + It 'resolves its own manifest for a scheduled task to import' { + # Same root cause, different symptom: a scheduled task runs as SYSTEM, + # whose module path does not include a CurrentUser install, so the + # registered command must import the manifest by full path. + Import-Module $script:CompiledManifest -Force + + $manifest = & (Get-Module PCTools) { + Join-Path $ExecutionContext.SessionState.Module.ModuleBase 'PCTools.psd1' + } + + Test-Path -LiteralPath $manifest | Should -BeTrue + Remove-Module PCTools -Force + } + + It 'resolves paths from the module base, not by walking up from $PSScriptRoot' { + # The two layouts nest these functions at different depths, so any + # parent-walking arithmetic is correct in one and wrong in the other. + foreach ($file in 'Public/Start-PCTools.ps1', 'Public/Automation/Register-PCScheduledMaintenance.ps1') { + $text = Get-Content -LiteralPath (Join-Path $script:RepoRoot "src/PCTools/$file") -Raw + $text | Should -Match 'SessionState\.Module\.ModuleBase' -Because "$file must not depend on its own nesting depth" + } + } + + It 'generates docs/COMMANDS.md identically on every host' { + # It was written with Set-Content -Encoding UTF8, which emits a BOM on + # Windows PowerShell 5.1 and none on PowerShell 7 - so the CI freshness + # check reported the file stale purely because of who generated it. + $docs = Join-Path $script:RepoRoot 'docs/COMMANDS.md' + if (-not (Test-Path -LiteralPath $docs)) { return } + + $bytes = [System.IO.File]::ReadAllBytes($docs) | Select-Object -First 3 + $hasBom = $bytes.Count -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + $hasBom | Should -BeFalse -Because 'a BOM makes the generated file host-dependent' + } + It 'keeps the private helpers private' { Import-Module $script:CompiledManifest -Force $exported = (Get-Module PCTools).ExportedFunctions.Keys diff --git a/tests/Configuration.Tests.ps1 b/tests/Configuration.Tests.ps1 index a6f75c0..9e55dc7 100644 --- a/tests/Configuration.Tests.ps1 +++ b/tests/Configuration.Tests.ps1 @@ -62,7 +62,7 @@ Describe 'Import-PCConfiguration' { It 'rejects an action the module does not export' { $path = New-ConfigFile 'typo.json' '{ "profiles": [ { "name": "Bad", "actions": [ { "action": "Clear-PCTypo" } ] } ] }' - { Import-PCConfiguration -Path $path } | Should -Throw -ExpectedMessage '*Clear-PCTypo*does not export*' + { Import-PCConfiguration -Path $path } | Should -Throw -ExpectedMessage '*Clear-PCTypo*neither a PCTools command nor a registered extension*' } It 'rejects a profile with no actions' { diff --git a/tests/Install.Tests.ps1 b/tests/Install.Tests.ps1 index a5a3a64..26fb6d7 100644 --- a/tests/Install.Tests.ps1 +++ b/tests/Install.Tests.ps1 @@ -83,8 +83,8 @@ Describe 'install.ps1' { # Every URL in the installer must point at this project, either # literally or through $script:Repository. A fetch from anywhere else in # a script people are invited to pipe into iex would be indefensible. - $urls = [regex]::Matches($script:InstallText, 'https?://[^\s''")]+') | - ForEach-Object { $_.Value } + $urls = @([regex]::Matches($script:InstallText, 'https?://[^\s''")]+') | + ForEach-Object { $_.Value }) $urls.Count | Should -BeGreaterThan 0 @@ -103,8 +103,10 @@ Describe 'The pinned install command' { It 'is pinned to a tag in the README, never to main' { $readme = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'README.md') -Raw - $installLines = [regex]::Matches($readme, '(?m)^.*install\.ps1.*$') | - ForEach-Object { $_.Value } + # @() for the same reason the module code needs it: a single match + # assigns a scalar, and .Count on a string throws under strict mode. + $installLines = @([regex]::Matches($readme, '(?m)^.*install\.ps1.*$') | + ForEach-Object { $_.Value }) $installLines.Count | Should -BeGreaterThan 0 @@ -121,9 +123,11 @@ Describe 'The pinned install command' { $manifest = Import-PowerShellDataFile -Path (Join-Path $script:RepoRoot 'src/PCTools/PCTools.psd1') $readme = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'README.md') -Raw - $tags = [regex]::Matches($readme, 'pc-powershelltools/v(\d+\.\d+\.\d+)/install\.ps1') | + # One distinct tag is the expected case, and Sort-Object -Unique then + # returns a bare string rather than an array - so this needs @() too. + $tags = @([regex]::Matches($readme, 'pc-powershelltools/v(\d+\.\d+\.\d+)/install\.ps1') | ForEach-Object { $_.Groups[1].Value } | - Sort-Object -Unique + Sort-Object -Unique) $tags.Count | Should -BeGreaterThan 0 foreach ($tag in $tags) { diff --git a/tests/PCTools.Unit.Tests.ps1 b/tests/PCTools.Unit.Tests.ps1 index 88d8b58..1f238dd 100644 --- a/tests/PCTools.Unit.Tests.ps1 +++ b/tests/PCTools.Unit.Tests.ps1 @@ -205,7 +205,7 @@ Describe 'Maintenance profiles' { It 'throws a helpful error for an unknown profile' { { Get-PCMaintenanceProfile -Name 'Nonexistent' } | - Should -Throw -ExpectedMessage '*Available: Quick, Recommended, Full, NetworkRepair*' + Should -Throw -ExpectedMessage '*Available: Quick, Recommended, Full, Storage, Health, Weekly, NetworkRepair*' } It 'keeps Prefetch cleanup out of every built-in profile' { diff --git a/tests/Parallel.Tests.ps1 b/tests/Parallel.Tests.ps1 index 7044b81..86f6ffd 100644 --- a/tests/Parallel.Tests.ps1 +++ b/tests/Parallel.Tests.ps1 @@ -138,6 +138,33 @@ Describe 'Invoke-PCParallel' { @($result | Where-Object { $_.Error }).Count | Should -Be 2 } + It 'keeps results that finished before the deadline expired' { + # Tasks run concurrently, so a slow first item can burn the whole batch + # budget while every later item has already completed. Treating an + # exhausted budget as a timeout without checking completion discarded + # those finished results and reported them as failures - silently + # corrupting the output of callers like Test-PCConnectivity, where the + # gateway probe is both first and the one most likely to hang. + $result = & $script:Module { + Invoke-PCParallel -InputObject (1..4) -ScriptBlock { + param($n) + if ($n -eq 1) { Start-Sleep -Seconds 30 } + "done$n" + } -ThrottleLimit 4 -TimeoutSeconds 3 + } + + @($result).Count | Should -Be 4 + + # The slow one times out; the three fast ones keep their results. + $result[0].Output | Should -BeNullOrEmpty + $result[0].Error | Should -Not -BeNullOrEmpty + + foreach ($index in 1, 2, 3) { + $result[$index].Output | Should -Be "done$($index + 1)" + $result[$index].Error | Should -BeNullOrEmpty + } + } + It 'runs an init script in every runspace' { $result = & $script:Module { $init = [scriptblock]::Create('function Get-Answer { 42 }') From 91798b455ac26931b8c490e07e95a41c08369045 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:43:23 +0000 Subject: [PATCH 3/4] Substitute the placeholders in the unknown-action error CI on c5f9a8f was down to one failure, and it was a real bug rather than a stale assertion: Import-PCConfiguration's error for an action that is neither a command nor a registered extension printed literal {0} and {1} where the profile and action names belong. The format operator binds tighter than string concatenation, so "Profile '{0}' references '{1}'. " + "Run Get-Command ..." -f $a, $b formats only the second string - which has no placeholders - and leaves the first exactly as written. The concatenation needs its own parentheses. This is the same precedence trap that produced the earlier HTML-report bug, where a -f inside a method call had its arguments taken as method arguments instead. Audited every other site in the repository where a concatenation is followed by -f: three exist and all three already parenthesise the concatenation correctly, so this was the only one. The local test runner in the scratchpad was ignoring Should -Throw's -ExpectedMessage, which is precisely why this passed locally and failed in CI - something threw, so it counted as a pass regardless of what it said. It now compares the message, and with that change it reproduces the CI failure against the old code and passes against the fix. 718 assertions, 0 failures; analyzer clean at 197 warnings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KkdwqfkUYsNSkp9f3AVDqg --- CHANGELOG.md | 4 ++++ src/PCTools/Public/Import-PCConfiguration.ps1 | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae78aac..d5bf251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,6 +170,10 @@ roughly double the commands. - The release notes named `pc-tools-v0.5.0.zip`; the build stages the archive without the tag's leading `v`, so the documented verification step named a file that is not attached. +- `Import-PCConfiguration`'s error for an unknown action printed literal `{0}` + and `{1}` instead of the profile and action names. The format operator binds + tighter than string concatenation, so `"a {0} " + "b {1}" -f $x, $y` formats + only the second string; the concatenation needs its own parentheses. ## [0.4.0] - 2026-01-15 diff --git a/src/PCTools/Public/Import-PCConfiguration.ps1 b/src/PCTools/Public/Import-PCConfiguration.ps1 index 03bf717..c40d353 100644 --- a/src/PCTools/Public/Import-PCConfiguration.ps1 +++ b/src/PCTools/Public/Import-PCConfiguration.ps1 @@ -91,8 +91,12 @@ function Import-PCConfiguration { throw "Profile '$($entry.name)' has an action with no 'action' name." } if ($exported -notcontains $step.action) { - throw ("Profile '{0}' references '{1}', which is neither a PCTools command nor a registered extension. " + - "Run Get-Command -Module PCTools for the commands, and Get-PCExtension for the extensions." -f + # The parentheses around the concatenation are load-bearing: -f + # binds tighter than +, so without them the format operator + # applies only to the second string and the first keeps its + # literal {0} and {1} - which is exactly what this message did. + throw (("Profile '{0}' references '{1}', which is neither a PCTools command nor a registered extension. " + + "Run Get-Command -Module PCTools for the commands, and Get-PCExtension for the extensions.") -f $entry.name, $step.action) } From affb41c87637676bccb0f38bddafb763cc7f116f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:48:16 +0000 Subject: [PATCH 4/4] Do not analyze the module we generate All 714 tests pass on Windows PowerShell 5.1 now. The job failed on the analyzer instead: 275 warnings against a budget of 210, on a tree whose sources had not changed. The cause is that Analyze scanned obj/, which holds the compiled module - the same functions, merged into one file. Compile.Tests builds it as part of the suite, so by the time the Analyze step ran, obj/ existed and every finding in module code was counted twice. The filter already excluded out/ and dist/ but not obj/. Locally I had excluded obj/ in my own analyzer invocation, which is why I measured 197 and CI measured 275 for identical code. Excluding it is right on its own merits: obj/ is generated, its findings are duplicates of ones already reported against the file a person would actually edit, and a budget that moves depending on whether a build has run is not a budget. With it excluded the count is 197, inside the existing 210 - so the budget itself did not need raising. tests/Syntax.Tests.ps1 had the same gap in its discovery filter, which is why it found 232 files in a working tree and 228 in CI, where obj/ does not exist at discovery time. Both now agree at 714 assertions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KkdwqfkUYsNSkp9f3AVDqg --- CHANGELOG.md | 5 +++++ build/Invoke-Build.ps1 | 14 ++++++++++++-- tests/Syntax.Tests.ps1 | 5 ++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5bf251..1819a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,11 @@ roughly double the commands. and `{1}` instead of the profile and action names. The format operator binds tighter than string concatenation, so `"a {0} " + "b {1}" -f $x, $y` formats only the second string; the concatenation needs its own parentheses. +- The build's Analyze task and the syntax test both scanned `obj/`, which holds + the compiled module generated from the same sources. Every finding in module + code was therefore reported twice, and the number of syntax tests discovered + depended on whether a build had run. Generated output is now excluded from + both. ## [0.4.0] - 2026-01-15 diff --git a/build/Invoke-Build.ps1 b/build/Invoke-Build.ps1 index eeffe13..4684f63 100644 --- a/build/Invoke-Build.ps1 +++ b/build/Invoke-Build.ps1 @@ -52,8 +52,11 @@ function Write-Step { } function Get-SourceFile { + # obj/ holds the compiled module, which is generated from these same files. + # Including it would mean every finding in module code was reported twice, + # the second time against a file nobody edits. Get-ChildItem -Path $RepoRoot -Recurse -Include '*.ps1', '*.psm1', '*.psd1' -File | - Where-Object { $_.FullName -notmatch '[\\/](out|dist|\.git)[\\/]' } + Where-Object { $_.FullName -notmatch '[\\/](out|obj|dist|\.git)[\\/]' } } function Invoke-TestTask { @@ -89,8 +92,15 @@ function Invoke-AnalyzeTask { Import-Module PSScriptAnalyzer -Force $settings = Join-Path $RepoRoot 'PSScriptAnalyzerSettings.psd1' + + # obj/ is excluded for the same reason as in Get-SourceFile: it is the + # compiled module, generated from the sources already being analyzed. The + # test suite builds it - Compile.Tests does - so on a machine that has run + # the tests it exists and silently doubled the count of every finding in + # module code. That is what pushed CI from 197 warnings to 275 and over + # budget, against a tree whose sources had not changed. $results = Invoke-ScriptAnalyzer -Path $RepoRoot -Recurse -Settings $settings | - Where-Object { $_.ScriptPath -notmatch '[\\/](out|dist)[\\/]' } + Where-Object { $_.ScriptPath -notmatch '[\\/](out|obj|dist)[\\/]' } if (-not $results) { Write-Host 'PSScriptAnalyzer: clean.' -ForegroundColor Green diff --git a/tests/Syntax.Tests.ps1 b/tests/Syntax.Tests.ps1 index a584652..0ece3c5 100644 --- a/tests/Syntax.Tests.ps1 +++ b/tests/Syntax.Tests.ps1 @@ -11,8 +11,11 @@ BeforeDiscovery { $repoRoot = Split-Path -Parent $PSScriptRoot + # obj/ excluded too: it is the generated module, and whether it exists + # depends on whether a build has run - which made the number of tests + # discovered differ between a fresh checkout and a working tree. $script:SourceFiles = Get-ChildItem -Path $repoRoot -Recurse -Include '*.ps1', '*.psm1' -File | - Where-Object { $_.FullName -notmatch '[\\/](out|dist|\.git)[\\/]' } | + Where-Object { $_.FullName -notmatch '[\\/](out|obj|dist|\.git)[\\/]' } | ForEach-Object { @{ Path = $_.FullName; Name = $_.Name } } }