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(@' +
+'@) + + [void]$html.AppendLine('

PC Tools report

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

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

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

Machine

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

Actions

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

Network

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

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

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

$(& $escape $property.Name)

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