diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9241a8e..55ebfed 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,37 @@ 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
+ # --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 --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.'
+
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..4dd2200 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-${{ steps.version.outputs.version }}.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..1819a04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,181 @@ 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.
+- `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.
+- `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.
+- 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
+
### Added
- `PCTools` module (`src/PCTools`): 29 public functions covering cleanup,
repair, network, preferences and software installation. Every action returns a
@@ -116,6 +291,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..4684f63 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'
@@ -43,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 {
@@ -80,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
@@ -138,27 +157,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 +227,195 @@ 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'
+
+ # 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
+}
+
+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..226d7f1
--- /dev/null
+++ b/src/PCTools/Private/Invoke-PCParallel.ps1
@@ -0,0 +1,210 @@
+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
+
+ # 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 (-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."
+ }
+ 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(@'
+
")
+
+ 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('
Status
Action
Reclaimed
Took
Detail
')
+
+ 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 = (
+ ('