diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1cdcddd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,124 @@ +name: Release + +on: + push: + tags: [ 'v*' ] + workflow_dispatch: + inputs: + version: + description: 'Version to build (without the leading v)' + required: true + +permissions: + contents: write + +jobs: + release: + name: Build and publish + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Resolve version + id: version + shell: powershell + run: | + $version = if ($env:GITHUB_REF -like 'refs/tags/v*') { + $env:GITHUB_REF -replace '^refs/tags/v', '' + } else { + '${{ github.event.inputs.version }}' + } + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "Building version $version" + + - name: Verify the manifest matches the tag + shell: powershell + run: | + $manifest = Import-PowerShellDataFile ./src/PCTools/PCTools.psd1 + $tagged = '${{ steps.version.outputs.version }}' + if ($manifest.ModuleVersion -ne $tagged) { + throw "Tag v$tagged does not match ModuleVersion $($manifest.ModuleVersion). Bump the manifest before tagging." + } + + - name: Install test dependencies + shell: powershell + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser + Install-Module PSScriptAnalyzer -RequiredVersion 1.22.0 -Force -Scope CurrentUser + + # Never publish something that has not passed its own suite. + - name: Test and analyze + shell: powershell + run: ./build/Invoke-Build.ps1 -Task All + + - name: Stage artifacts and checksums + shell: powershell + run: ./build/Invoke-Build.ps1 -Task Release -Version ${{ steps.version.outputs.version }} + + # 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 + if: ${{ secrets.SIGNING_CERTIFICATE != '' }} + env: + SIGNING_CERTIFICATE: ${{ secrets.SIGNING_CERTIFICATE }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + shell: powershell + run: | + $pfxPath = Join-Path $env:RUNNER_TEMP 'signing.pfx' + [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:SIGNING_CERTIFICATE)) + $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 ` + -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 + + # 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 + # after the suite has passed and only for a tagged build. + - name: Publish to the PowerShell Gallery + if: ${{ secrets.PSGALLERY_API_KEY != '' && startsWith(github.ref, 'refs/tags/v') }} + shell: powershell + env: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + run: | + $module = './src/PCTools' + + # Refuse to republish a version that already exists - the Gallery + # rejects it anyway, and failing here says why. + $version = (Import-PowerShellDataFile "$module/PCTools.psd1").ModuleVersion + $existing = Find-Module PCTools -RequiredVersion $version -ErrorAction SilentlyContinue + if ($existing) { + throw "PCTools $version is already on the PowerShell Gallery. Bump ModuleVersion before tagging." + } + + Publish-Module -Path $module -NuGetApiKey $env:PSGALLERY_API_KEY -Verbose + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + files: | + out/* + generate_release_notes: true + body: | + ## Verifying this release + + Every file below is listed in `SHA256SUMS`. Verify before running: + + ```powershell + $file = 'pc-tools.ps1' + (Get-FileHash $file -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). diff --git a/.gitignore b/.gitignore index c74a5bf..a1e41b1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ desktop.ini # PowerShell *.psproj testResults*.xml +obj/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cafce75..98c0995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `PCTools` module (`src/PCTools`): 29 public functions covering cleanup, + repair, network, preferences and software installation. Every action returns a + structured `PCTools.ActionResult` and every mutating action supports `-WhatIf` + and `-Confirm`. +- `Invoke-PCMaintenance` and built-in maintenance profiles (Quick, Recommended, + Full, NetworkRepair), replacing the "Run selected"/"Run all" buttons. +- `Export-PCReport`: JSON and text export for any results, generalised from Net + Diag's network-only `Save-Report`. +- `Get-PCPreference`: reads the current state of every managed Windows + preference. `Set-PCPreference` applies **and reverts** them. +- `Clear-PCBrowserCache` for Chrome, Edge, Brave, Firefox and Vivaldi. +- PC Tools shell (`src/Shell`), replacing both bespoke GUIs. Runs every action + on a background runspace, adds a `-WhatIf` preview, a per-run results summary + and a network verdict banner naming the failing layer. +- `pc-tools.ps1` entry point, with `-NoGui` to load the module into a console + session. +- `MIGRATION.md` mapping every old function to its replacement. +- Release workflow: builds from a tag, verifies the manifest version matches, + runs the full suite, publishes checksummed artifacts, and Authenticode-signs + them when a signing certificate is configured. +- `Format-PCByteSize` is public, so hosts can format a total without reaching + into module internals. +- Network profiles: `Export-PCNetworkProfile`, `Import-PCNetworkProfile` and + `Get-PCNetworkProfile` save and restore an adapter's full configuration + (DHCP or static address, prefix, gateway, DNS) as JSON, giving the Home/Work + presets the roadmap asked for. +- `Import-PCConfiguration`: user-defined maintenance profiles from a JSON file. + Imported profiles appear in `Get-PCMaintenanceProfile` and run through + `Invoke-PCMaintenance` unchanged; one with the same name as a built-in + overrides it. Every action name is validated at import, so a typo fails there + rather than partway through a run. +- `Test-PCRoute`: per-hop traceroute, answering where connectivity stops rather + than only that it has. +- `Test-PCMtu`: path MTU probe, the diagnostic for the case where DNS resolves + and small requests work but large transfers and TLS handshakes stall. +- `Get-PCWirelessStatus`: parses and grades the Wi-Fi association instead of + dumping `netsh wlan show interfaces` as raw text. +- The shell's Network page gained Trace route, Check path MTU, Wi-Fi signal, and + save/apply adapter profile. + +### Changed +- PowerShell Gallery publishing wired into the release workflow, gated on a + `PSGALLERY_API_KEY` secret and refusing to republish an existing version. +- Module manifest carries Gallery metadata: `CompatiblePSEditions`, edition + tags and inline release notes. +- Install instructions are pinned to a release tag and hash-verified. The + previous `irm .../main/... | iex` commands are documented with their trade-off + rather than recommended. +- `Repair-PCSystemImage` and `Repair-PCSystemFile` are separate commands; the + original `Run-SystemHealthChecks` ran DISM and SFC together and reported + neither. +- `Test-PCDisk` takes a drive letter instead of always scanning `C:`. +- Prefetch cleanup and the network stack reset are excluded from every + general-purpose profile. + +### Fixed +- Every external command runs through a timeout wrapper. Previously only Net + Diag's full scan was protected; a stuck DISM could hang the Cleanup Tool + indefinitely. +- DISM, SFC, CHKDSK and winget exit codes are interpreted instead of discarded, + so a failed repair no longer looks identical to a successful one. +- `Set-PCNetworkAddress` removes the existing address and route first. The + original failed with an "instance already exists" error on any adapter that + already had an address. +- `Set-PCDhcp` also resets DNS servers, which the original left static. +- `Clear-PCWindowsUpdateCache` restarts `wuauserv` in a `finally` block, so a + mid-run failure no longer leaves Windows Update stopped, and waits for the + service to stop before deleting. +- `New-PCRestorePoint` detects Windows silently throttling the 24-hour limit + instead of reporting success when no checkpoint was created. +- Folder cleanup enumerates once instead of recursing twice, uses `-LiteralPath` + so paths containing brackets are not skipped, and reports bytes reclaimed. +- Subnet mask conversion rejects non-contiguous masks and handles `/0`. +- `Restart-PCExplorer` waits for the shell to return instead of sleeping a fixed + interval, which could leave the user with no taskbar. + - `ROADMAP.md` describing the phased plan for the project. - MIT `LICENSE`. - `.gitignore` for logs, reports and release output. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..ac9a1ca --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,93 @@ +# Migrating from the single-file tools + +`pc-cleanuptool.ps1` and `pc-netdiag.ps1` still work and are still shipped. They +are frozen: bug fixes only, no new features. Everything they do now lives in the +`PCTools` module, usually with more control and always with a way to preview it +first. + +## Why bother + +- `-WhatIf` on every action, so you can see the plan before committing to it. +- Actions return objects, so you can filter, total and export them. +- Failures are reported. The originals piped DISM, SFC, CHKDSK and winget to + `Out-Null`, so a failed repair looked exactly like a successful one. +- Preferences can be read and reverted, not just applied. +- Nothing blocks the UI thread. + +## Command mapping + +### Cleanup + +| Was | Now | +|---|---| +| `Clear-TempFiles` | `Clear-PCTempFile` | +| `Clear-RecycleBinSafe` | `Clear-PCRecycleBin` | +| `Clear-WindowsUpdateCache` | `Clear-PCWindowsUpdateCache` | +| `Clear-PrefetchCache` | `Clear-PCPrefetchCache` | +| — | `Clear-PCBrowserCache` (new) | + +### Repair + +| Was | Now | +|---|---| +| `Run-SystemHealthChecks` | `Repair-PCSystemImage` **and** `Repair-PCSystemFile` (split; DISM and SFC report separately) | +| `Run-ChkDskScan` | `Test-PCDisk` (takes a `-DriveLetter`; was hard-coded to `C:`) | +| `Create-SystemRestorePoint` | `New-PCRestorePoint` | + +### Network + +| Was | Now | +|---|---| +| `Flush-DnsCache` | `Clear-PCDnsCache` | +| `Reset-NetworkStack` | `Reset-PCNetworkStack` (add `-SkipWinsock` to keep VPN LSPs) | +| `Get-ActiveAdapters` | `Get-PCNetworkAdapter -ConnectedOnly` | +| `Set-StaticIP` | `Set-PCNetworkAddress` (takes `-PrefixLength` or `-SubnetMask`) | +| `Set-DnsServers` | `Set-PCDnsServer` (or `-Preset Cloudflare\|Google\|Quad9`) | +| `Set-DhcpMode` | `Set-PCDhcp` (now also resets DNS) | +| `Run-QuickDiagnostics` | `Get-PCNetworkReport` | +| `Start-FullDiagnosticsWorker` | `Get-PCNetworkReport -Full` | +| `Save-Report` | `Export-PCReport` (works for any results, not just network) | + +### Preferences + +The six one-way setters (`Set-DarkTheme`, `Disable-BingSearch`, +`Show-HiddenFiles`, `Show-FileExtensions`, `Disable-MouseAcceleration`, +`Enable-NumLock`) are replaced by two commands over a table of definitions: + +```powershell +Get-PCPreference # what is set right now +Set-PCPreference -Name DarkMode -Enabled $true # apply +Set-PCPreference -Name DarkMode -Enabled $false # restore the Windows default +``` + +Names: `DarkMode`, `DisableBingSearch`, `ShowHiddenFiles`, `ShowFileExtensions`, +`DisableMouseAcceleration`, `NumLockOnStartup`, `DisableStartMenuSuggestions`, +`ShowFullPathInTitleBar`, `LaunchExplorerToThisPC`. + +### Software + +| Was | Now | +|---|---| +| `Install-BasicApps` | `Install-PCApplication -Preset Essentials` (or `-Id `) | + +## Behaviour changes worth knowing + +- **`Run all` is now a profile.** `Invoke-PCMaintenance -ProfileName Full`. It + takes a restore point first and stops if it cannot. +- **Prefetch is not in any profile.** Clearing it costs launch performance for a + small disk saving. Call `Clear-PCPrefetchCache` explicitly if you want it. +- **The network stack reset is not in any general profile** and prompts by + default (`ConfirmImpact = 'High'`). +- **A failed action no longer stops the batch.** Every action reports its own + result; pass `-ContinueOnFailure $false` for the old behaviour. +- **Subnet masks are converted, not passed through.** `255.0.255.0` is rejected + as invalid rather than handed to `netsh`. + +## Running the old tools + +Nothing was removed: + +```powershell +.\pc-cleanuptool.ps1 +.\pc-netdiag.ps1 +``` diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index 856b8e9..62146f7 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -1,33 +1,40 @@ @{ - # Target the lowest runtime the GUI supports. WinForms requires Windows - # PowerShell 5.1, so compatibility is checked against that. Severity = @('Error', 'Warning') ExcludeRules = @( - # The GUI shell intentionally holds module-scoped state ($sync, $App) - # that the analyzer reads as "assigned but never used". - 'PSUseDeclaredVarsMoreThanAssignments', + # The GUI shell deliberately holds module-scoped state ($sync, $App, + # control variables) that the analyzer reads as "assigned but never + # used" because the reads happen inside event handlers. + 'PSUseDeclaredVarsMoreThanAssignments' - # Write-Host is a deliberate choice in the console entry points, where - # coloured progress output is the whole point. + # Write-Host is a deliberate choice in the console entry points and the + # build script, where coloured progress output is the point. 'PSAvoidUsingWriteHost' + + # False-positive here by construction. Nearly every public action wraps + # its work in `Invoke-PCAction -Body { ... }`, and the analyzer does not + # follow parameter use into that scriptblock, so it flags parameters + # that are plainly used two lines further down. + 'PSReviewUnusedParameter' + + # Purely cosmetic and extremely noisy against this codebase's existing + # style, including the original scripts. Enabling them buried the rules + # that catch real problems under a thousand indentation notes. Revisit + # as a one-off reformat, not as a gate. + 'PSUseConsistentIndentation' + 'PSUseConsistentWhitespace' + 'PSAlignAssignmentStatement' + 'PSPlaceOpenBrace' + 'PSPlaceCloseBrace' ) Rules = @{ + # The one formatting-adjacent rule worth keeping: it catches syntax that + # parses in PowerShell 7 but not in 5.1, which is the runtime the GUI + # actually requires. PSUseCompatibleSyntax = @{ Enable = $true TargetVersions = @('5.1', '7.0') } - PSPlaceOpenBrace = @{ - Enable = $true - OnSameLine = $true - NewLineAfter = $true - IgnoreOneLineBlock = $true - } - PSUseConsistentIndentation = @{ - Enable = $true - Kind = 'space' - IndentationSize = 4 - } } } diff --git a/README.md b/README.md index 037f7ee..586e82c 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,282 @@ # pc-powershelltools -A collection of PowerShell GUI tools for Windows maintenance, optimization, and network diagnostics. Includes system cleanup, repair utilities, network troubleshooting, Windows tweaks, and more. +Windows maintenance, repair and network diagnostics: a PowerShell module with a +GUI on top. -> **Disclaimer** -> These tools are provided **as-is**. I am not liable for any system issues or damages resulting from their use. +> **Disclaimer** +> These tools are provided **as-is**. I am not liable for any system issues or +> damages resulting from their use. --- -## Tools Included +## What is here -### PC Cleanup Tool (v0.1) -A one-click GUI for cleaning, optimizing, and configuring Windows. +| 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). | -#### Getting Started -1. Run this command in **PowerShell as Administrator**: -## Cleanuptool: -`irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/main/pc-cleanuptool.ps1 | iex` -## NetDiag: -`irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/main/pc-netdiag.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. +--- + +## Install + +Download the release archive, **verify it**, and run it from disk: + +```powershell +# 1. Download the current release and its checksums +$version = 'v0.4.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 + +Once a release is published there, this is the least ceremony: +```powershell +Install-Module PCTools -Scope CurrentUser +Import-Module PCTools +Invoke-PCMaintenance -ProfileName Quick -WhatIf +``` -#### Alternative -1. Download or clone the repository -2. Open **PowerShell as Administrator** -3. Run the script: .\pc-cleanuptool.ps1 +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` + +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. + +If you use them, pin to a release tag rather than `main`: + +```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 +``` + +The verified download above is the recommended path. + +--- -#### Features -- **System Cleanup**: Clean temporary files, empty Recycle Bin, clear Windows Update cache, clear Prefetch cache, create automatic system restore points. -- **System Repair Tools**: Run DISM (`/restorehealth`), SFC (`/scannow`), CHKDSK (online scan). -- **Network Utilities**: Flush DNS cache, full network stack reset, set static IP/DNS/subnet mask/gateway, revert to DHCP, auto-detect active adapters. -- **Windows Preferences**: Enable dark mode, disable Bing search integration, show hidden files and extensions, disable mouse acceleration, enable NumLock on startup. -- **Optional Software Installer**: Installs common apps via **winget** (e.g., Google Chrome, 7-Zip, VLC, VS Code). +## Using the GUI -### Net Diag GUI (v0.2 - New Release!) -A GUI tool for network diagnostics and fixes, with a refreshed dark UI, improved layout, and controls. +```powershell +.\pc-tools.ps1 # dark theme +.\pc-tools.ps1 -Theme Light +``` -#### Getting Started -1. Download or clone the repository -2. Open **PowerShell as Administrator** -3. Run the script: .\pc-netdiag.ps1 (or net-diag_v3.ps1 based on internal naming) +- **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. +- **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. -#### Features -- Run quick or full network diagnostics. -- Apply common network fixes: Flush DNS, renew IP. -- Manage adapter settings: Set static IP/DNS, revert to DHCP. -- Asynchronous runs to keep UI responsive, with timeouts. -- Log rotation, results summary, and elevation button. -- Improved UI: Refreshed dark theme, better layout and controls. +Long-running work happens on a background runspace, so the window stays +responsive during a DISM or SFC run. --- -## Releases -- **v0.2** (December 12, 2025): First release of Net Diag GUI. Introduces network diagnostics GUI with fixes and management tools. The PC Cleanup Tool remains unchanged. -- **v0.1** (December 12, 2025): Initial release of PC Cleanup Tool. +## Using the module + +```powershell +Import-Module .\src\PCTools\PCTools.psd1 + +Get-Command -Module PCTools +Get-Help Invoke-PCMaintenance -Full +``` + +Preview before committing to anything: + +```powershell +Invoke-PCMaintenance -ProfileName Recommended -WhatIf +``` + +Run it for real: + +```powershell +Invoke-PCMaintenance -ProfileName Quick | + Format-Table Action, Status, FreedDisplay, Detail +``` + +Diagnose a network problem: + +```powershell +$report = Get-PCNetworkReport +$report.Verdict.Verdict # e.g. 'DNS is not resolving' +$report.Verdict.Advice +$report | Export-PCReport +``` + +Save and restore adapter configurations: + +```powershell +Export-PCNetworkProfile -Name Ethernet -ProfileName Office +Get-PCNetworkProfile | Format-Table ProfileName, Adapter, Dhcp, IPv4Address +Import-PCNetworkProfile -ProfileName Office -WhatIf +``` + +Define your own maintenance profiles in JSON: + +```json +{ + "profiles": [ + { + "name": "Weekly", + "description": "What I actually run on Fridays", + "restorePoint": false, + "actions": [ + { "action": "Clear-PCTempFile" }, + { "action": "Clear-PCBrowserCache", "parameters": { "Browser": ["Chrome"] } } + ] + } + ] +} +``` + +```powershell +Import-PCConfiguration -Path .\my-profiles.json +Invoke-PCMaintenance -ProfileName Weekly -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. + +Individual actions: + +```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 +``` + +### 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. | +| `NetworkRepair` | Diagnose, then apply the standard network fixes. Requires a restart. | + +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. + +--- + +## Safety + +- **Every mutating action supports `-WhatIf`.** The GUI's Preview button is the + 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`. +- **`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. +- **Everything is logged** to `%LOCALAPPDATA%\PCTools\logs`, rotated at 2 MB. + +--- + +## Requirements + +- Windows 10 or 11 +- Windows PowerShell 5.1 (the GUI uses WinForms). The module itself 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 + errors. + +--- + +## Development + +```powershell +./build/Invoke-Build.ps1 -Task All # Pester suite, then PSScriptAnalyzer +./build/Invoke-Build.ps1 -Task Test +./build/Invoke-Build.ps1 -Task Release # stage out/ and write SHA256SUMS +``` + +CI runs the suite on both Windows PowerShell 5.1 and PowerShell 7. Releases are +built from a tag, checksummed, and Authenticode-signed when a signing +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 +``` + +--- + +## About `quickspeedboost.ps1` + +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. + +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. + +--- ## Roadmap -### Future Updates -- More Windows preference toggles (e.g., Taskbar/Start Menu tweaks, disable background features). -- Network profiles (Home/Work presets). -- Export logs to file and copy from GUI. -- Safer confirmations for destructive actions. -- Performance and stability improvements. -- Preset profiles (Quick cleanup, Full maintenance). -- Config file support (JSON). -- Portable executable build. -- Additional system diagnostics. +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. Suggestions and contributions are welcome. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index 1685ed2..49c271c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -51,7 +51,7 @@ A plan for turning four standalone scripts into one maintained toolkit. --- -## Phase 0 — Foundation (small, do first) +## Phase 0 — Foundation ✅ done Nothing here changes behaviour; everything after it gets easier. @@ -67,7 +67,7 @@ Nothing here changes behaviour; everything after it gets easier. - Correct the README's async claim; add `quickspeedboost.ps1` to the docs or remove it (see Phase 6). -## Phase 1 — Extract a `PCTools` module +## Phase 1 — Extract a `PCTools` module ✅ done Move the logic out of the GUIs. This is the change everything else depends on. @@ -102,7 +102,7 @@ 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 +## 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. @@ -118,18 +118,28 @@ wired to anything. Two things in the framework to fix during the port: - `Complete-AsyncTask` calls `Invoke-UI { & $Task.OnSuccess $result }`. The - scriptblock is queued with `BeginInvoke` and runs later, resolving `$Task` and - `$result` from the enclosing scope at *execution* time. When two tasks complete - in the same 120 ms timer tick, both queued callbacks are likely to see the last - task's values. Pass them as explicit `BeginInvoke` arguments and add a - regression test that completes two async tasks simultaneously. + 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 +## Phase 3 — Safety and trust ✅ done This is what decides whether anyone but you runs these tools. @@ -147,7 +157,7 @@ This is what decides whether anyone but you runs these tools. recommended", with per-action detail — the Cleanup Tool currently tells the user nothing quantitative. -## Phase 4 — The features already on the README roadmap +## Phase 4 — The features already on the README roadmap ✅ done Each becomes small once Phases 1–2 land: @@ -164,7 +174,7 @@ Each becomes small once Phases 1–2 land: - **More diagnostics** — traceroute with per-hop latency, MTU/path-MTU probe, Wi-Fi signal sampling over time, throughput test. -## Phase 5 — Distribution +## Phase 5 — Distribution ✅ done - **PowerShell Gallery**: `Install-Module PCTools` — the credible alternative to `irm | iex`, and it gets you versioning and updates for free. @@ -189,6 +199,20 @@ performance rather than adding it. --- +## Status + +Phases 0-3 are implemented. What actually shipped, against what was planned: + +| 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. | + ## Suggested order 1. Phase 0 — a weekend, and it stops silent regressions reaching `iex` users. diff --git a/build/Invoke-Build.ps1 b/build/Invoke-Build.ps1 index 42c6909..75ba2be 100644 --- a/build/Invoke-Build.ps1 +++ b/build/Invoke-Build.ps1 @@ -17,10 +17,18 @@ #> [CmdletBinding()] param( - [ValidateSet('Test', 'Analyze', 'Release', 'All')] + [ValidateSet('Test', 'Analyze', 'Release', 'Checksum', 'All')] [string]$Task = 'All', - [string]$Version + [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 + # 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; + # raising it should be a deliberate, reviewed decision. + [int]$MaxWarning = 146 ) $ErrorActionPreference = 'Stop' @@ -75,19 +83,41 @@ function Invoke-AnalyzeTask { $results = Invoke-ScriptAnalyzer -Path $RepoRoot -Recurse -Settings $settings | Where-Object { $_.ScriptPath -notmatch '[\\/](out|dist)[\\/]' } - if ($results) { - $results | Format-Table -AutoSize -Property Severity, ScriptName, Line, RuleName, Message | Out-String | Write-Host + if (-not $results) { + Write-Host 'PSScriptAnalyzer: clean.' -ForegroundColor Green + return + } - $errors = @($results | Where-Object Severity -eq 'Error') - if ($errors.Count -gt 0) { - throw "PSScriptAnalyzer reported $($errors.Count) error(s)." - } + # Summarise by rule first. A flat list of a thousand findings is unreadable, + # and the useful question is always "which rule, and how often". + Write-Host '' + Write-Host 'Findings by rule:' + $results | Group-Object RuleName | Sort-Object Count -Descending | ForEach-Object { + Write-Host (' {0,5} {1}' -f $_.Count, $_.Name) + } + + $errors = @($results | Where-Object Severity -eq 'Error') + $warnings = @($results | Where-Object Severity -eq 'Warning') - Write-Host "PSScriptAnalyzer reported $($results.Count) warning(s), no errors." -ForegroundColor Yellow + if ($errors.Count -gt 0) { + Write-Host '' + $errors | Format-Table -AutoSize -Property ScriptName, Line, RuleName, Message | Out-String | Write-Host + throw "PSScriptAnalyzer reported $($errors.Count) error(s)." } - else { - Write-Host 'PSScriptAnalyzer: clean.' -ForegroundColor Green + + if ($warnings.Count -gt 0) { + Write-Host '' + $warnings | Format-Table -AutoSize -Property ScriptName, Line, RuleName, Message | Out-String | Write-Host + } + + # A warning budget keeps the analyzer honest. Errors always fail; warnings + # fail once they exceed what the repository has agreed to carry, so the + # count can only go down. + if ($warnings.Count -gt $MaxWarning) { + throw "PSScriptAnalyzer reported $($warnings.Count) warning(s), over the budget of $MaxWarning. Fix them, or raise -MaxWarning deliberately." } + + Write-Host ("PSScriptAnalyzer: {0} warning(s), no errors (budget {1})." -f $warnings.Count, $MaxWarning) -ForegroundColor Yellow } function Invoke-ReleaseTask { @@ -103,34 +133,72 @@ function Invoke-ReleaseTask { if (Test-Path $outDir) { Remove-Item $outDir -Recurse -Force } New-Item -ItemType Directory -Path $outDir -Force | Out-Null - # Standalone entry points people download or pipe into iex. + # The legacy single-file tools, which existing README links point at and + # which people still pipe into iex. Shipped loose so those URLs keep working. Get-ChildItem -Path $RepoRoot -Filter '*.ps1' -File | Copy-Item -Destination $outDir - # The module, zipped for manual install. + # The current toolkit. The GUI needs the module beside it, so this ships as + # a layout rather than as loose scripts. + $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 + + Copy-Item (Join-Path $RepoRoot 'pc-tools.ps1') -Destination $stage + Copy-Item (Join-Path $RepoRoot 'src') -Destination $stage -Recurse + foreach ($doc in 'README.md', 'CHANGELOG.md', 'LICENSE') { + $path = Join-Path $RepoRoot $doc + if (Test-Path $path) { Copy-Item $path -Destination $stage } + } + + 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 } + Remove-Item $stage -Recurse -Force + + Invoke-ChecksumTask + + Write-Host "Artifacts staged in $outDir" -ForegroundColor Green +} + +function Invoke-ChecksumTask { + <# + Kept separate from Release because Authenticode signing rewrites the + files it signs. Checksums generated before signing would not match what + the user downloads, which is worse than publishing none at all. + #> Write-Step 'Writing SHA256SUMS' + + $outDir = Join-Path $RepoRoot 'out' + if (-not (Test-Path $outDir)) { + throw "No staged artifacts found in $outDir. Run -Task Release first." + } + + $sumsPath = Join-Path $outDir 'SHA256SUMS' + if (Test-Path $sumsPath) { Remove-Item $sumsPath -Force } + $sums = Get-ChildItem -Path $outDir -File | Sort-Object Name | ForEach-Object { '{0} {1}' -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower(), $_.Name } - $sumsPath = Join-Path $outDir 'SHA256SUMS' $sums | Set-Content -Path $sumsPath -Encoding UTF8 Write-Host '' $sums | ForEach-Object { Write-Host " $_" } Write-Host '' - Write-Host "Artifacts staged in $outDir" -ForegroundColor Green } switch ($Task) { - 'Test' { Invoke-TestTask } - 'Analyze' { Invoke-AnalyzeTask } - 'Release' { Invoke-ReleaseTask } - 'All' { Invoke-TestTask; Invoke-AnalyzeTask } + 'Test' { Invoke-TestTask } + 'Analyze' { Invoke-AnalyzeTask } + 'Release' { Invoke-ReleaseTask } + 'Checksum' { Invoke-ChecksumTask } + 'All' { Invoke-TestTask; Invoke-AnalyzeTask } } Write-Host '' diff --git a/pc-tools.ps1 b/pc-tools.ps1 new file mode 100644 index 0000000..31a407d --- /dev/null +++ b/pc-tools.ps1 @@ -0,0 +1,83 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Launches PC Tools. + +.DESCRIPTION + The entry point for the current toolkit. Finds the PCTools module and the + GUI shell next to this script, then starts the shell. + + 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. + +.PARAMETER Theme + Dark or Light. Defaults to Dark. + +.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. + +.EXAMPLE + .\pc-tools.ps1 + +.EXAMPLE + .\pc-tools.ps1 -NoGui + Invoke-PCMaintenance -ProfileName Quick -WhatIf + +.LINK + https://github.com/likeBloodMoon/pc-powershelltools +#> +[CmdletBinding()] +param( + [ValidateSet('Dark', 'Light')] + [string]$Theme = 'Dark', + + [switch]$NoGui +) + +$ErrorActionPreference = 'Stop' + +$root = $PSScriptRoot +if (-not $root) { $root = Split-Path -Parent $MyInvocation.MyCommand.Path } + +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: + + https://github.com/likeBloodMoon/pc-powershelltools/releases/latest +'@ +} + +$modulePath = Join-Path $root 'src\PCTools\PCTools.psd1' +if (-not (Test-Path -LiteralPath $modulePath)) { + throw "The PCTools module is missing. Expected it at: $modulePath" +} + +Import-Module $modulePath -Force + +if ($NoGui) { + Write-Host '' + Write-Host "PCTools $((Get-Module PCTools).Version) loaded." -ForegroundColor Green + Write-Host '' + 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-PCNetworkReport | Format-List diagnose the network' + Write-Host '' + if (-not (Test-PCAdmin)) { + Write-Warning 'This session is not elevated. Repair and network actions will fail.' + } + 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 diff --git a/src/PCTools/PCTools.psd1 b/src/PCTools/PCTools.psd1 index 17f584c..43bf317 100644 --- a/src/PCTools/PCTools.psd1 +++ b/src/PCTools/PCTools.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PCTools.psm1' - ModuleVersion = '0.3.0' + ModuleVersion = '0.4.0' GUID = '6f3c0a58-1d2e-4a7b-9c34-8e5f2b7d41a9' Author = 'likeBloodMoon' CompanyName = 'likeBloodMoon' @@ -26,13 +26,19 @@ # Network 'Clear-PCDnsCache' + 'Export-PCNetworkProfile' 'Get-PCNetworkAdapter' + 'Get-PCNetworkProfile' 'Get-PCNetworkReport' + 'Get-PCWirelessStatus' + 'Import-PCNetworkProfile' 'Reset-PCNetworkStack' 'Set-PCDhcp' 'Set-PCDnsServer' 'Set-PCNetworkAddress' 'Test-PCConnectivity' + 'Test-PCMtu' + 'Test-PCRoute' # Preferences 'Get-PCPreference' @@ -45,9 +51,11 @@ # Orchestration and reporting 'Export-PCReport' 'Get-PCMaintenanceProfile' + 'Import-PCConfiguration' 'Invoke-PCMaintenance' # Host integration + 'Format-PCByteSize' 'Get-PCLogPath' 'Register-PCLogSink' 'Test-PCAdmin' @@ -58,12 +66,27 @@ VariablesToExport = @() AliasesToExport = @() + # Windows-only: the GUI needs WinForms, and the actions call Windows-only + # cmdlets. Declaring it keeps the module out of Linux and macOS search + # results on the Gallery rather than failing at import for those users. + CompatiblePSEditions = @('Desktop', 'Core') + PrivateData = @{ PSData = @{ - Tags = @('Windows', 'Maintenance', 'Cleanup', 'Network', 'Diagnostics', 'Optimization') + Tags = @( + 'Windows', 'Maintenance', 'Cleanup', 'Network', 'Diagnostics', + 'Optimization', 'Repair', 'DISM', 'SFC', 'GUI', + 'PSEdition_Desktop', 'PSEdition_Core' + ) LicenseUri = 'https://github.com/likeBloodMoon/pc-powershelltools/blob/main/LICENSE' ProjectUri = 'https://github.com/likeBloodMoon/pc-powershelltools' - ReleaseNotes = 'https://github.com/likeBloodMoon/pc-powershelltools/blob/main/CHANGELOG.md' + ReleaseNotes = @' +Every action supports -WhatIf and returns a structured result. + +See the full changelog: +https://github.com/likeBloodMoon/pc-powershelltools/blob/main/CHANGELOG.md +'@ + RequireLicenseAcceptance = $false } } } diff --git a/src/PCTools/PCTools.psm1 b/src/PCTools/PCTools.psm1 index aa21b64..a22ff73 100644 --- a/src/PCTools/PCTools.psm1 +++ b/src/PCTools/PCTools.psm1 @@ -34,6 +34,9 @@ $script:PCLogPath = $null $script:PCLogSinkTable = @{} $script:PCLogSinks = @() +# 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 diff --git a/src/PCTools/Private/Get-PCProfileStore.ps1 b/src/PCTools/Private/Get-PCProfileStore.ps1 new file mode 100644 index 0000000..abaa949 --- /dev/null +++ b/src/PCTools/Private/Get-PCProfileStore.ps1 @@ -0,0 +1,11 @@ +function Get-PCProfileStore { + <# + .SYNOPSIS + Returns the folder where saved profiles live. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + Join-Path $script:PCDataRoot 'profiles' +} diff --git a/src/PCTools/Private/New-PCActionResult.ps1 b/src/PCTools/Private/New-PCActionResult.ps1 index 17ca981..f8da81d 100644 --- a/src/PCTools/Private/New-PCActionResult.ps1 +++ b/src/PCTools/Private/New-PCActionResult.ps1 @@ -53,22 +53,22 @@ function New-PCActionResult { [hashtable]$Data ) - $result = [pscustomobject]@{ + # FreedDisplay is computed here rather than added as a ScriptProperty. + # A ScriptProperty's scriptblock stays bound to the session state it was + # created in, and these objects are returned across a runspace boundary to + # the GUI - so a lazily evaluated property would be reaching into a + # runspace that has since gone back to the pool. + [pscustomobject]@{ PSTypeName = 'PCTools.ActionResult' Action = $Action Status = $Status Detail = $Detail BytesFreed = $BytesFreed + FreedDisplay = Format-PCByteSize -Bytes $BytesFreed Duration = $Duration RebootRequired = [bool]$RebootRequired Timestamp = Get-Date ErrorRecord = $ErrorRecord Data = $Data } - - Add-Member -InputObject $result -MemberType ScriptProperty -Name FreedDisplay -Value { - Format-PCByteSize -Bytes $this.BytesFreed - } - - $result } diff --git a/src/PCTools/Private/Format-PCByteSize.ps1 b/src/PCTools/Public/Format-PCByteSize.ps1 similarity index 76% rename from src/PCTools/Private/Format-PCByteSize.ps1 rename to src/PCTools/Public/Format-PCByteSize.ps1 index 0c9db2a..94846d5 100644 --- a/src/PCTools/Private/Format-PCByteSize.ps1 +++ b/src/PCTools/Public/Format-PCByteSize.ps1 @@ -3,6 +3,12 @@ function Format-PCByteSize { .SYNOPSIS Formats a byte count for display. + .DESCRIPTION + Public because hosts need it. Each ActionResult carries its own + FreedDisplay, but a caller totalling several of them - the GUI's summary + line, for one - needs to format the sum, and reaching into the module's + private scope to do that is not an interface. + .EXAMPLE Format-PCByteSize -Bytes 4509715660 4.2 GB diff --git a/src/PCTools/Public/Get-PCMaintenanceProfile.ps1 b/src/PCTools/Public/Get-PCMaintenanceProfile.ps1 index d162d48..ad81f89 100644 --- a/src/PCTools/Public/Get-PCMaintenanceProfile.ps1 +++ b/src/PCTools/Public/Get-PCMaintenanceProfile.ps1 @@ -84,6 +84,15 @@ function Get-PCMaintenanceProfile { } ) + # Profiles loaded from a user's JSON config are first-class: they appear in + # the list and run through the same orchestrator. A user profile with the + # same name as a built-in wins, so a config file can override a preset. + $imported = @($script:PCImportedProfile) + if ($imported.Count -gt 0) { + $overridden = $imported.Name + $profiles = @($profiles | Where-Object { $overridden -notcontains $_.Name }) + $imported + } + if ($Name) { $match = @($profiles | Where-Object Name -eq $Name) if ($match.Count -eq 0) { diff --git a/src/PCTools/Public/Import-PCConfiguration.ps1 b/src/PCTools/Public/Import-PCConfiguration.ps1 new file mode 100644 index 0000000..e3fdd76 --- /dev/null +++ b/src/PCTools/Public/Import-PCConfiguration.ps1 @@ -0,0 +1,124 @@ +function Import-PCConfiguration { + <# + .SYNOPSIS + Loads user-defined maintenance profiles from a JSON file. + + .DESCRIPTION + The "config file support (JSON)" item from the roadmap. + + A profile is already just data - a name, a description, whether to take a + restore point, and an ordered list of actions - so a user-supplied config + is the same data loaded from disk. Imported profiles are returned + alongside the built-in ones by Get-PCMaintenanceProfile and can be run by + Invoke-PCMaintenance without any further plumbing. + + Every action name is validated against the module's exported commands at + import time, so a typo is caught here rather than halfway through a run. + + .PARAMETER Path + The JSON file to load. Defaults to profiles.json in the profile store. + + .EXAMPLE + Import-PCConfiguration -Path .\my-profiles.json + Invoke-PCMaintenance -ProfileName 'Weekly' -WhatIf + + .EXAMPLE + Format of the file: + + { + "profiles": [ + { + "name": "Weekly", + "description": "What I actually run on Fridays", + "restorePoint": false, + "actions": [ + { "action": "Clear-PCTempFile" }, + { "action": "Clear-PCBrowserCache", "parameters": { "Browser": ["Chrome"] } } + ] + } + ] + } + + .OUTPUTS + pscustomobject - the profiles that were loaded. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string]$Path + ) + + if (-not $Path) { $Path = Join-Path (Get-PCProfileStore) 'profiles.json' } + + if (-not (Test-Path -LiteralPath $Path)) { + throw "No configuration file at '$Path'." + } + + $raw = Get-Content -LiteralPath $Path -Raw + try { + $config = $raw | ConvertFrom-Json + } + catch { + throw "'$Path' is not valid JSON: $($_.Exception.Message)" + } + + if (-not $config.PSObject.Properties['profiles']) { + throw "'$Path' has no 'profiles' array." + } + + $exported = (Get-Module PCTools).ExportedFunctions.Keys + $loaded = [System.Collections.Generic.List[object]]::new() + + foreach ($entry in @($config.profiles)) { + # Reading an absent property is a terminating error under StrictMode, + # so every field from user JSON is probed before it is read. + if (-not $entry.PSObject.Properties['name'] -or -not $entry.name) { + throw "A profile in '$Path' has no name." + } + + # Guard before touching the property: under StrictMode, reading an + # absent property is a terminating error, and the raw error is far less + # useful than saying which profile is malformed. + if (-not $entry.PSObject.Properties['actions'] -or -not @($entry.actions).Count) { + throw "Profile '$($entry.name)' has no actions." + } + + $actions = foreach ($step in @($entry.actions)) { + if (-not $step.PSObject.Properties['action'] -or -not $step.action) { + 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." + } + + $parameters = @{} + if ($step.PSObject.Properties['parameters'] -and $step.parameters) { + foreach ($property in $step.parameters.PSObject.Properties) { + $parameters[$property.Name] = $property.Value + } + } + + @{ Action = $step.action; Parameters = $parameters } + } + + if (-not $actions) { + throw "Profile '$($entry.name)' produced no usable actions." + } + + $loaded.Add([pscustomobject]@{ + PSTypeName = 'PCTools.MaintenanceProfile' + Name = $entry.name + Description = if ($entry.PSObject.Properties['description']) { $entry.description } else { 'User-defined profile' } + RestorePoint = if ($entry.PSObject.Properties['restorePoint']) { [bool]$entry.restorePoint } else { $true } + Actions = @($actions) + Source = $Path + }) + } + + # Replace rather than append, so re-importing an edited file does not leave + # the previous version shadowing it. + $script:PCImportedProfile = $loaded + Write-PCLog -Level INFO -Message "Loaded $($loaded.Count) profile(s) from $Path" + + $loaded +} diff --git a/src/PCTools/Public/Network/Export-PCNetworkProfile.ps1 b/src/PCTools/Public/Network/Export-PCNetworkProfile.ps1 new file mode 100644 index 0000000..5c62efa --- /dev/null +++ b/src/PCTools/Public/Network/Export-PCNetworkProfile.ps1 @@ -0,0 +1,85 @@ +function Export-PCNetworkProfile { + <# + .SYNOPSIS + Saves an adapter's full IP configuration as a named profile. + + .DESCRIPTION + The "network profiles (Home/Work presets)" item from the roadmap. + + Captures everything needed to put the adapter back exactly as it is: + DHCP or static, address, prefix, gateway and DNS servers. Paired with + Import-PCNetworkProfile, this is a two-command switch between a static + office configuration and DHCP at home. + + Profiles are plain JSON under %LOCALAPPDATA%\PCTools\profiles, so they + can be copied between machines. + + .PARAMETER Name + The adapter to capture. + + .PARAMETER ProfileName + What to call the saved profile. Defaults to the adapter name. + + .PARAMETER Path + Where to write it. Defaults to the profile store. + + .EXAMPLE + Export-PCNetworkProfile -Name Ethernet -ProfileName Office + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory, Position = 0)] + [string]$Name, + + [Parameter(Position = 1)] + [string]$ProfileName, + + [string]$Path + ) + + Invoke-PCAction -Name 'Export-PCNetworkProfile' -Body { + if (-not $ProfileName) { $ProfileName = $Name } + + $adapter = Get-PCNetworkAdapter | Where-Object Name -eq $Name + if (-not $adapter) { + throw "No adapter named '$Name'. Run Get-PCNetworkAdapter to list them." + } + + $store = if ($Path) { $Path } else { Get-PCProfileStore } + if (-not (Test-Path -LiteralPath $store)) { + New-Item -ItemType Directory -Path $store -Force | Out-Null + } + + $safeName = $ProfileName -replace '[^\w\-. ]', '_' + $file = Join-Path $store "$safeName.network.json" + + $captured = [pscustomobject]@{ + ProfileName = $ProfileName + Adapter = $adapter.Name + Dhcp = $adapter.Dhcp + IPv4Address = ($adapter.IPv4Address -split ',')[0].Trim() + PrefixLength = $adapter.PrefixLength + Gateway = ($adapter.Gateway -split ',')[0].Trim() + DnsServers = @($adapter.DnsServers -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + Captured = Get-Date + Version = 1 + } + + if (-not $PSCmdlet.ShouldProcess($file, "Save profile '$ProfileName'")) { + return @{ Status = 'Skipped'; Detail = "Profile not saved" } + } + + $captured | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $file -Encoding UTF8 + + $description = if ($captured.Dhcp) { 'DHCP' } else { "$($captured.IPv4Address)/$($captured.PrefixLength)" } + + @{ + Detail = "Saved '$ProfileName' from $Name ($description) to $file" + Data = @{ ProfileName = $ProfileName; Path = $file; Profile = $captured } + } + } +} diff --git a/src/PCTools/Public/Network/Get-PCNetworkProfile.ps1 b/src/PCTools/Public/Network/Get-PCNetworkProfile.ps1 new file mode 100644 index 0000000..13d1a30 --- /dev/null +++ b/src/PCTools/Public/Network/Get-PCNetworkProfile.ps1 @@ -0,0 +1,34 @@ +function Get-PCNetworkProfile { + <# + .SYNOPSIS + Lists saved network profiles. + + .PARAMETER Path + Where to look. Defaults to the profile store. + + .EXAMPLE + Get-PCNetworkProfile | Format-Table ProfileName, Adapter, Dhcp, IPv4Address + + .OUTPUTS + pscustomobject + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string]$Path + ) + + $store = if ($Path) { $Path } else { Get-PCProfileStore } + if (-not (Test-Path -LiteralPath $store)) { return } + + foreach ($file in Get-ChildItem -LiteralPath $store -Filter '*.network.json' -File) { + try { + $saved = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + Add-Member -InputObject $saved -MemberType NoteProperty -Name Path -Value $file.FullName -Force + $saved + } + catch { + Write-PCLog -Level WARN -Message "Skipping unreadable profile $($file.Name): $($_.Exception.Message)" + } + } +} diff --git a/src/PCTools/Public/Network/Get-PCWirelessStatus.ps1 b/src/PCTools/Public/Network/Get-PCWirelessStatus.ps1 new file mode 100644 index 0000000..3053117 --- /dev/null +++ b/src/PCTools/Public/Network/Get-PCWirelessStatus.ps1 @@ -0,0 +1,77 @@ +function Get-PCWirelessStatus { + <# + .SYNOPSIS + Reports the current Wi-Fi association: SSID, signal, band and rate. + + .DESCRIPTION + New in the module. Net Diag captured `netsh wlan show interfaces` into + the full report as raw text; this parses it into an object and grades + the signal, so "the internet is slow" can be answered with a number. + + .EXAMPLE + Get-PCWirelessStatus + + .OUTPUTS + pscustomobject + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + $run = Invoke-PCProcess -FilePath 'netsh.exe' -ArgumentList @('wlan', 'show', 'interfaces') -TimeoutSeconds 20 + + if ($run.TimedOut -or $run.Output -match 'no wireless interface') { + return [pscustomobject]@{ + PSTypeName = 'PCTools.WirelessStatus' + Connected = $false + Detail = 'No wireless interface is present.' + } + } + + $field = { + param($Label) + $match = [regex]::Match($run.Output, "(?m)^\s*$Label\s*:\s*(.+?)\s*$") + if ($match.Success) { $match.Groups[1].Value } else { $null } + } + + $state = & $field 'State' + if ($state -ne 'connected') { + return [pscustomobject]@{ + PSTypeName = 'PCTools.WirelessStatus' + Connected = $false + Detail = "Wireless adapter is present but not connected (state: $state)." + } + } + + $signalText = & $field 'Signal' + $signal = if ($signalText -match '(\d+)') { [int]$Matches[1] } else { $null } + + $grade, $advice = if ($null -eq $signal) { + 'Unknown', '' + } + elseif ($signal -ge 75) { + 'Good', '' + } + elseif ($signal -ge 50) { + 'Fair', 'Usable, but throughput will drop under load.' + } + else { + 'Poor', 'Move closer to the access point or switch band; a weak signal looks exactly like a slow connection.' + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.WirelessStatus' + Connected = $true + Ssid = & $field 'SSID' + Bssid = & $field 'BSSID' + Radio = & $field 'Radio type' + Band = & $field 'Band' + Channel = & $field 'Channel' + SignalPercent = $signal + SignalGrade = $grade + ReceiveRate = & $field 'Receive rate \(Mbps\)' + TransmitRate = & $field 'Transmit rate \(Mbps\)' + Authentication = & $field 'Authentication' + Detail = if ($advice) { "Signal $signal% ($grade). $advice" } else { "Signal $signal% ($grade)." } + } +} diff --git a/src/PCTools/Public/Network/Import-PCNetworkProfile.ps1 b/src/PCTools/Public/Network/Import-PCNetworkProfile.ps1 new file mode 100644 index 0000000..49749f2 --- /dev/null +++ b/src/PCTools/Public/Network/Import-PCNetworkProfile.ps1 @@ -0,0 +1,77 @@ +function Import-PCNetworkProfile { + <# + .SYNOPSIS + Applies a saved network profile to an adapter. + + .DESCRIPTION + Restores what Export-PCNetworkProfile captured: DHCP, or the static + address, prefix, gateway and DNS servers. + + The adapter is a parameter rather than being read from the profile, so a + configuration captured on one machine can be applied to a differently + named adapter on another. + + .PARAMETER ProfileName + The saved profile to apply. + + .PARAMETER Name + The adapter to apply it to. Defaults to the adapter the profile was + captured from. + + .PARAMETER Path + Where to look for profiles. Defaults to the profile store. + + .EXAMPLE + Import-PCNetworkProfile -ProfileName Office + + .EXAMPLE + Import-PCNetworkProfile -ProfileName Office -Name 'Wi-Fi' -WhatIf + + .OUTPUTS + PCTools.ActionResult + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory, Position = 0)] + [string]$ProfileName, + + [Parameter(Position = 1)] + [string]$Name, + + [string]$Path + ) + + Assert-PCAdmin -Action 'Applying a network profile' + + $store = if ($Path) { $Path } else { Get-PCProfileStore } + $safeName = $ProfileName -replace '[^\w\-. ]', '_' + $file = Join-Path $store "$safeName.network.json" + + if (-not (Test-Path -LiteralPath $file)) { + $available = @(Get-PCNetworkProfile | ForEach-Object ProfileName) + $hint = if ($available.Count) { " Available: $($available -join ', ')." } else { ' No profiles have been saved yet.' } + throw "No network profile named '$ProfileName'.$hint" + } + + $saved = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json + if (-not $Name) { $Name = $saved.Adapter } + + Write-PCLog -Level INFO -Message "Applying profile '$ProfileName' to $Name" + + # Each step is a full action in its own right, so the caller gets one + # result per change rather than a single opaque success. + if ($saved.Dhcp) { + Set-PCDhcp -Name $Name + return + } + + Set-PCNetworkAddress -Name $Name ` + -IPAddress $saved.IPv4Address ` + -PrefixLength $saved.PrefixLength ` + -Gateway $saved.Gateway + + if (@($saved.DnsServers).Count -gt 0) { + Set-PCDnsServer -Name $Name -ServerAddress @($saved.DnsServers) + } +} diff --git a/src/PCTools/Public/Network/Test-PCMtu.ps1 b/src/PCTools/Public/Network/Test-PCMtu.ps1 new file mode 100644 index 0000000..3cd6b66 --- /dev/null +++ b/src/PCTools/Public/Network/Test-PCMtu.ps1 @@ -0,0 +1,100 @@ +function Test-PCMtu { + <# + .SYNOPSIS + Finds the largest packet that reaches a host without fragmenting. + + .DESCRIPTION + New in the module, and the diagnostic for a specific and confusing + failure: a connection where DNS resolves, small requests succeed, and + large transfers or HTTPS handshakes hang. That is a path MTU problem, + usually a PPPoE or VPN link, and no amount of DNS flushing fixes it. + + Binary-searches the payload size with the don't-fragment flag set. The + reported MTU adds the 28-byte IPv4 and ICMP header overhead back on. + + .PARAMETER Target + The host to probe. + + .PARAMETER TimeoutSeconds + Per-probe timeout. + + .EXAMPLE + Test-PCMtu -Target 1.1.1.1 + + .OUTPUTS + pscustomobject + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Position = 0)] + [string]$Target = '1.1.1.1', + + [ValidateRange(1, 30)] + [int]$TimeoutSeconds = 3 + ) + + Write-PCLog -Level INFO -Message "Probing path MTU to $Target" + + $ping = [System.Net.NetworkInformation.Ping]::new() + $dontFragment = [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' + } + catch { + return $false + } + } + + 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.' + } + } + + 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." + } + } + + while ($high - $low -gt 1) { + $middle = [int](($low + $high) / 2) + if (& $probe $middle) { $low = $middle } else { $high = $middle } + } + + $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." + } + } + finally { + $ping.Dispose() + } +} diff --git a/src/PCTools/Public/Network/Test-PCRoute.ps1 b/src/PCTools/Public/Network/Test-PCRoute.ps1 new file mode 100644 index 0000000..47002ce --- /dev/null +++ b/src/PCTools/Public/Network/Test-PCRoute.ps1 @@ -0,0 +1,84 @@ +function Test-PCRoute { + <# + .SYNOPSIS + Traces the path to a host, reporting per-hop latency. + + .DESCRIPTION + New in the module. Net Diag could tell you the internet was unreachable + but not where it stopped being reachable, which is the question that + distinguishes "my router" from "my ISP". + + Implemented over System.Net.NetworkInformation.Ping with an increasing + TTL rather than by shelling out to tracert.exe, so each hop honours the + timeout and the results come back as objects. + + .PARAMETER Target + The host or address to trace to. + + .PARAMETER MaxHops + Stop after this many hops. + + .PARAMETER TimeoutSeconds + Per-hop timeout. + + .EXAMPLE + Test-PCRoute -Target 1.1.1.1 | Format-Table Hop, Address, LatencyMs, Status + + .OUTPUTS + pscustomobject + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Position = 0)] + [string]$Target = '1.1.1.1', + + [ValidateRange(1, 64)] + [int]$MaxHops = 20, + + [ValidateRange(1, 30)] + [int]$TimeoutSeconds = 3 + ) + + Write-PCLog -Level INFO -Message "Tracing route to $Target" + + $ping = [System.Net.NetworkInformation.Ping]::new() + $buffer = [byte[]]::new(32) + + try { + for ($ttl = 1; $ttl -le $MaxHops; $ttl++) { + $options = [System.Net.NetworkInformation.PingOptions]::new($ttl, $true) + + $reply = $null + $status = 'Unknown' + $address = '*' + $latency = $null + + try { + $reply = $ping.Send($Target, $TimeoutSeconds * 1000, $buffer, $options) + $status = $reply.Status.ToString() + + if ($reply.Address) { $address = $reply.Address.ToString() } + if ($reply.Status -in 'Success', 'TtlExpired') { $latency = $reply.RoundtripTime } + } + catch { + $status = 'Error' + } + + [pscustomobject]@{ + PSTypeName = 'PCTools.RouteHop' + Hop = $ttl + Address = $address + LatencyMs = $latency + Status = $status + } + + # Reaching the destination ends the trace; TtlExpired is an + # intermediate hop and means keep going. + if ($reply -and $reply.Status -eq 'Success') { break } + } + } + finally { + $ping.Dispose() + } +} diff --git a/src/Shell/Start-PCToolsShell.ps1 b/src/Shell/Start-PCToolsShell.ps1 new file mode 100644 index 0000000..4fad825 --- /dev/null +++ b/src/Shell/Start-PCToolsShell.ps1 @@ -0,0 +1,1671 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + PC Tools - the unified GUI shell for pc-powershelltools. + +.DESCRIPTION + Promotes gui-framework.ps1 from a demo into the application shell, and + replaces the two bespoke WinForms UIs in pc-cleanuptool.ps1 and + pc-netdiag.ps1. + + The shell owns presentation only. Every action it runs comes from the + PCTools module, on a background runspace, so the window stays responsive + during a DISM run that the original tools would have frozen for half an + hour. + +.PARAMETER Theme + Dark or Light. Defaults to Dark. + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File .\Start-PCToolsShell.ps1 +#> +[CmdletBinding()] +param( + [ValidateSet('Dark', 'Light')] + [string]$Theme = 'Dark' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +#region Bootstrap + +# WinForms needs a single-threaded apartment. Windows PowerShell 5.1 gives us +# one by default; pwsh.exe does not, and the failure mode without this check is +# a window that opens and immediately deadlocks. +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 + ) + Start-Process -FilePath 'powershell.exe' -ArgumentList $relaunch + return +} + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +# Microsoft.VisualBasic supplies InputBox, which WinForms itself has no +# equivalent for and which beats hand-rolling a modal prompt form. +Add-Type -AssemblyName Microsoft.VisualBasic + +# Per-monitor DPI awareness. Without it the fixed-pixel layouts in the original +# tools render blurry and clip their text at 150% scaling, which is the default +# on most laptops sold in the last five years. +if (-not ('PCTools.Native' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +namespace PCTools { + public static class Native { + public const int WM_NCLBUTTONDOWN = 0xA1; + public const int HTCAPTION = 0x2; + + [DllImport("user32.dll")] + public static extern bool ReleaseCapture(); + + [DllImport("user32.dll")] + public static extern IntPtr SendMessage(IntPtr hWnd, int Msg, int wParam, int lParam); + + [DllImport("user32.dll")] + public static extern bool SetProcessDPIAware(); + + [DllImport("shcore.dll")] + public static extern int SetProcessDpiAwareness(int value); + + [DllImport("dwmapi.dll")] + public static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size); + } +} +'@ +} + +try { + # PROCESS_PER_MONITOR_DPI_AWARE = 2. Falls back on Windows 7/8. + [void][PCTools.Native]::SetProcessDpiAwareness(2) +} +catch { + try { [void][PCTools.Native]::SetProcessDPIAware() } catch { } +} + +[System.Windows.Forms.Application]::EnableVisualStyles() +[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false) + +# Surface UI-thread exceptions instead of closing the window silently, which is +# what the original Cleanup Tool did on any unhandled error. +[System.Windows.Forms.Application]::SetUnhandledExceptionMode( + [System.Windows.Forms.UnhandledExceptionMode]::CatchException) + +#endregion + +#region Module import + +$script:ShellRoot = $PSScriptRoot +$modulePath = Join-Path (Split-Path -Parent $script:ShellRoot) 'PCTools\PCTools.psd1' + +if (Test-Path -LiteralPath $modulePath) { + Import-Module $modulePath -Force -ErrorAction Stop +} +elseif (Get-Module -ListAvailable -Name PCTools) { + Import-Module PCTools -Force -ErrorAction Stop + $modulePath = (Get-Module PCTools).Path +} +else { + [System.Windows.Forms.MessageBox]::Show( + "The PCTools module could not be found.`r`n`r`nExpected at:`r`n$modulePath", + 'PC Tools', 'OK', 'Error') | Out-Null + return +} + +$script:ModulePath = $modulePath + +#endregion + +#region Application state + +$script:App = [ordered]@{ + Name = 'PC Tools' + Version = (Get-Module PCTools).Version.ToString() + Window = @{ Width = 1120; Height = 760; MinWidth = 940; MinHeight = 620 } + Async = @{ MaxRunspaces = 3 } +} + +# Everything the UI thread and the runspaces both touch lives here. +# Every control key is declared up front and set later during construction. +# Under Set-StrictMode -Version Latest, reading a key that does not yet exist +# is a terminating error - so a guard like `if ($sync.Controls.StatusLabel)` +# would throw instead of returning false. Declaring them removes that whole +# class of failure. +$controls = @{ + SubtitleLabel = $null + ElevateButton = $null + ContentHost = $null + StatusLabel = $null + SummaryLabel = $null + Progress = $null + RunButtons = $null + MaintenanceGrid = $null + AdapterGrid = $null + VerdictLabel = $null + AdviceLabel = $null + NetworkGrid = $null + PreferenceGrid = $null + LogGrid = $null +} + +$sync = [hashtable]::Synchronized(@{ + Form = $null + Controls = $controls + Theme = $Theme + LogQueue = [System.Collections.Concurrent.ConcurrentQueue[object]]::new() + Tasks = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new()) + Busy = $false +}) + +$script:Sync = $sync + +#endregion + +#region Theme + +$script:Themes = @{ + Dark = @{ + Back = [System.Drawing.Color]::FromArgb(18, 18, 20) + Panel = [System.Drawing.Color]::FromArgb(24, 24, 28) + Card = [System.Drawing.Color]::FromArgb(30, 30, 36) + Text = [System.Drawing.Color]::FromArgb(230, 230, 235) + Muted = [System.Drawing.Color]::FromArgb(158, 158, 170) + Border = [System.Drawing.Color]::FromArgb(52, 52, 62) + Button = [System.Drawing.Color]::FromArgb(38, 38, 46) + Accent = [System.Drawing.Color]::FromArgb(64, 132, 214) + AccentText = [System.Drawing.Color]::White + Success = [System.Drawing.Color]::FromArgb(88, 190, 120) + Warning = [System.Drawing.Color]::FromArgb(226, 178, 78) + Danger = [System.Drawing.Color]::FromArgb(224, 108, 108) + } + Light = @{ + Back = [System.Drawing.Color]::FromArgb(244, 245, 248) + Panel = [System.Drawing.Color]::FromArgb(255, 255, 255) + Card = [System.Drawing.Color]::FromArgb(255, 255, 255) + Text = [System.Drawing.Color]::FromArgb(28, 28, 34) + Muted = [System.Drawing.Color]::FromArgb(96, 96, 108) + Border = [System.Drawing.Color]::FromArgb(212, 214, 222) + Button = [System.Drawing.Color]::FromArgb(238, 239, 243) + Accent = [System.Drawing.Color]::FromArgb(28, 106, 200) + AccentText = [System.Drawing.Color]::White + Success = [System.Drawing.Color]::FromArgb(28, 138, 68) + Warning = [System.Drawing.Color]::FromArgb(168, 118, 16) + Danger = [System.Drawing.Color]::FromArgb(190, 58, 58) + } +} + +function Get-ThemeColor { + param([Parameter(Mandatory)][string]$Name) + $script:Themes[$script:Sync.Theme][$Name] +} + +#endregion + +#region Fonts + +function New-ShellFont { + param( + [double]$Size = 9.5, + [System.Drawing.FontStyle]$Style = [System.Drawing.FontStyle]::Regular + ) + + $family = 'Segoe UI' + try { + $available = [System.Drawing.FontFamily]::Families | ForEach-Object Name + if ($available -notcontains $family) { $family = 'Microsoft Sans Serif' } + } + catch { $family = 'Microsoft Sans Serif' } + + New-Object System.Drawing.Font($family, [single]$Size, $Style) +} + +$script:FontBody = New-ShellFont 9.5 +$script:FontBold = New-ShellFont 9.5 ([System.Drawing.FontStyle]::Bold) +$script:FontHeader = New-ShellFont 13 ([System.Drawing.FontStyle]::Bold) +$script:FontTitle = New-ShellFont 11 ([System.Drawing.FontStyle]::Bold) +$script:FontMono = New-Object System.Drawing.Font('Consolas', 9) + +#endregion + +#region UI marshalling + +function Invoke-UI { + <# + Runs a scriptblock on the UI thread. + + Arguments are passed explicitly rather than captured from the calling + scope. That distinction is not cosmetic: when the block is queued with + BeginInvoke it executes after the calling function has returned, and a + captured local is no longer resolvable by then - the block fails with + "the expression after '&' produced an object that was not valid" rather + than doing anything useful. The original framework got away with it only + because its callers happened to already be on the UI thread, where + InvokeRequired is false and the block runs synchronously. + #> + param( + [Parameter(Mandatory)][scriptblock]$Script, + [object[]]$Arguments = @() + ) + + $form = $script:Sync.Form + if (-not $form -or $form.IsDisposed) { return } + + # Bind the arguments into a closure now, while the caller's values are + # still live. GetNewClosure() copies them into a fresh scope that lives as + # long as the scriptblock, so the block is self-contained by the time the + # UI thread runs it - and this does not depend on how WinForms marshals a + # delegate's parameter array. + $bound = if ($Arguments.Count -gt 0) { + $boundScript = $Script + $boundArguments = $Arguments + { & $boundScript @boundArguments }.GetNewClosure() + } + else { + $Script + } + + if ($form.InvokeRequired) { + try { $null = $form.BeginInvoke($bound) } catch { } + } + else { + & $bound + } +} + +function Write-ShellLog { + param( + [Parameter(Mandatory)][string]$Message, + [ValidateSet('DEBUG', 'INFO', 'WARN', 'ERROR')][string]$Level = 'INFO' + ) + + $script:Sync.LogQueue.Enqueue([pscustomobject]@{ + Timestamp = Get-Date -Format 'HH:mm:ss' + Level = $Level + Message = $Message + }) +} + +#endregion + +#region Async runspace pool + +$script:Pool = [runspacefactory]::CreateRunspacePool(1, $script:App.Async.MaxRunspaces) +$script:Pool.ApartmentState = 'STA' +$script:Pool.ThreadOptions = 'ReuseThread' +$script:Pool.Open() + +function Start-ShellTask { + <# + Runs work on a background runspace and calls back on the UI thread. + + The runspace imports PCTools itself - a runspace does not inherit the + caller's modules - and registers a log sink that pushes the module's own + log lines onto the shared queue, so the log pane shows progress from + inside a long DISM run instead of nothing until it finishes. + #> + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][scriptblock]$Script, + [hashtable]$Arguments = @{}, + [scriptblock]$OnSuccess, + [scriptblock]$OnError, + [scriptblock]$OnFinally + ) + + if ($script:Sync.Busy) { + Write-ShellLog -Level WARN -Message "Busy: '$Name' was not started." + return + } + + $script:Sync.Busy = $true + Set-ShellStatus -Text $Name -Busy $true + Write-ShellLog -Level INFO -Message "Started: $Name" + + $wrapper = { + param($ModulePath, $Body, $BodyArguments, $SharedState) + + Import-Module $ModulePath -Force -ErrorAction Stop + + # Bridge module logging into the shell's log pane. + $sinkId = Register-PCLogSink { + param($entry) + $SharedState.LogQueue.Enqueue([pscustomobject]@{ + Timestamp = ($entry.Timestamp -split ' ')[-1] + Level = $entry.Level + Message = $entry.Message + }) + }.GetNewClosure() + + try { + & $Body @BodyArguments + } + finally { + Unregister-PCLogSink -Id $sinkId + } + } + + $shell = [powershell]::Create() + $shell.RunspacePool = $script:Pool + $null = $shell.AddScript($wrapper). + AddArgument($script:ModulePath). + AddArgument($Script). + AddArgument($Arguments). + AddArgument($script:Sync) + + $handle = $shell.BeginInvoke() + + [void]$script:Sync.Tasks.Add([pscustomobject]@{ + Name = $Name + Shell = $shell + Handle = $handle + OnSuccess = $OnSuccess + OnError = $OnError + OnFinally = $OnFinally + }) +} + +function Complete-ShellTask { + param([Parameter(Mandatory)]$Task) + + try { + $result = $Task.Shell.EndInvoke($Task.Handle) + + $errors = @($Task.Shell.Streams.Error) + foreach ($record in $errors) { + Write-ShellLog -Level WARN -Message $record.Exception.Message + } + + Write-ShellLog -Level INFO -Message "Finished: $($Task.Name)" + + if ($Task.OnSuccess) { + Invoke-UI -Script { param($Callback, $Value) & $Callback $Value } ` + -Arguments @($Task.OnSuccess, $result) + } + } + catch { + $message = "$($Task.Name) failed: $($_.Exception.Message)" + Write-ShellLog -Level ERROR -Message $message + + if ($Task.OnError) { + Invoke-UI -Script { param($Callback, $Value) & $Callback $Value } ` + -Arguments @($Task.OnError, $_) + } + } + finally { + try { $Task.Shell.Dispose() } catch { } + + if ($Task.OnFinally) { + Invoke-UI -Script { param($Callback) & $Callback } -Arguments @($Task.OnFinally) + } + + $script:Sync.Busy = $false + Set-ShellStatus -Text 'Ready' -Busy $false + } +} + +#endregion + +#region UI factory + +function New-Card { + param([string]$Title, [int]$Padding = 16) + + $card = New-Object System.Windows.Forms.Panel + $card.Dock = 'Fill' + $card.Padding = New-Object System.Windows.Forms.Padding($Padding) + $card.BackColor = Get-ThemeColor Card + $card.Tag = 'Card' + + if ($Title) { + $label = New-Object System.Windows.Forms.Label + $label.Text = $Title + $label.Dock = 'Top' + $label.Height = 30 + $label.Font = $script:FontTitle + $label.ForeColor = Get-ThemeColor Text + $label.Tag = 'Heading' + $card.Controls.Add($label) + } + + $card +} + +function New-Button { + param( + [Parameter(Mandatory)][string]$Text, + [int]$Width = 170, + [int]$Height = 34, + [switch]$Accent, + [scriptblock]$OnClick + ) + + $button = New-Object System.Windows.Forms.Button + $button.Text = $Text + $button.Width = $Width + $button.Height = $Height + $button.Font = $script:FontBody + $button.FlatStyle = 'Flat' + $button.FlatAppearance.BorderSize = 1 + $button.Margin = New-Object System.Windows.Forms.Padding(0, 0, 8, 8) + $button.Cursor = 'Hand' + $button.Tag = if ($Accent) { 'AccentButton' } else { 'Button' } + + if ($OnClick) { $button.Add_Click($OnClick) } + + $button +} + +function New-CheckBox { + param([Parameter(Mandatory)][string]$Text, [string]$Name, [bool]$Checked = $false) + + $box = New-Object System.Windows.Forms.CheckBox + $box.Text = $Text + $box.AutoSize = $true + $box.Checked = $Checked + $box.Font = $script:FontBody + $box.ForeColor = Get-ThemeColor Text + $box.Margin = New-Object System.Windows.Forms.Padding(0, 4, 0, 4) + $box.Tag = $Name + $box +} + +function New-ResultGrid { + $grid = New-Object System.Windows.Forms.ListView + $grid.Dock = 'Fill' + $grid.View = 'Details' + $grid.FullRowSelect = $true + $grid.GridLines = $false + $grid.HideSelection = $false + $grid.Font = $script:FontBody + $grid.BorderStyle = 'None' + $grid.OwnerDraw = $false + [void]$grid.Columns.Add('Status', 90) + [void]$grid.Columns.Add('Action', 200) + [void]$grid.Columns.Add('Freed', 90) + [void]$grid.Columns.Add('Detail', 640) + $grid +} + +function Add-ResultRow { + <# + Renders one PCTools.ActionResult into a grid. The colour comes from the + status, so a failure is visible without reading every row - the original + log box printed everything in the same grey. + #> + param( + [Parameter(Mandatory)]$Grid, + [Parameter(Mandatory)]$Result + ) + + $item = New-Object System.Windows.Forms.ListViewItem($Result.Status) + [void]$item.SubItems.Add([string]$Result.Action) + [void]$item.SubItems.Add($(if ($Result.BytesFreed -gt 0) { $Result.FreedDisplay } else { '' })) + [void]$item.SubItems.Add([string]$Result.Detail) + + $item.ForeColor = switch ($Result.Status) { + 'Success' { Get-ThemeColor Success } + 'Warning' { Get-ThemeColor Warning } + 'Failed' { Get-ThemeColor Danger } + default { Get-ThemeColor Muted } + } + + [void]$Grid.Items.Add($item) + $Grid.EnsureVisible($Grid.Items.Count - 1) +} + +function Show-Summary { + <# + The results summary the roadmap asked for: one line the user can read + instead of interpreting a log. + #> + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Result) + + $label = $script:Sync.Controls.SummaryLabel + if (-not $label) { return } + + if ($Result.Count -eq 0) { + $label.Text = '' + return + } + + $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 + $reboot = @($Result | Where-Object RebootRequired).Count -gt 0 + + $parts = @("$($Result.Count) action(s)", "$succeeded ok") + if ($warned) { $parts += "$warned warning" } + if ($failed) { $parts += "$failed failed" } + if ($freed -gt 0) { $parts += "$(Format-PCByteSize -Bytes $freed) reclaimed" } + if ($reboot) { $parts += 'restart required' } + + $label.Text = $parts -join ' - ' + $label.ForeColor = if ($failed) { Get-ThemeColor Danger } + elseif ($warned -or $reboot) { Get-ThemeColor Warning } + else { Get-ThemeColor Success } +} + +function Set-ShellStatus { + param([string]$Text = 'Ready', [bool]$Busy = $false) + + Invoke-UI -Script { + param($StatusText, $IsBusy) + $controls = $script:Sync.Controls + if ($controls.StatusLabel) { $controls.StatusLabel.Text = $StatusText } + if ($controls.Progress) { $controls.Progress.Visible = $IsBusy } + foreach ($button in @($controls.RunButtons)) { + if ($button -and -not $button.IsDisposed) { $button.Enabled = -not $IsBusy } + } + } -Arguments @($Text, $Busy) +} + +#endregion + +#region Window chrome + +$form = New-Object System.Windows.Forms.Form +$script:Sync.Form = $form +$form.Text = "$($script:App.Name) $($script:App.Version)" +$form.FormBorderStyle = 'Sizable' +$form.StartPosition = 'CenterScreen' +$form.ClientSize = New-Object System.Drawing.Size($script:App.Window.Width, $script:App.Window.Height) +$form.MinimumSize = New-Object System.Drawing.Size($script:App.Window.MinWidth, $script:App.Window.MinHeight) +$form.BackColor = Get-ThemeColor Back +$form.Font = $script:FontBody + +$form.SuspendLayout() + +# Root: a 2x2 grid - header spans the top, nav on the left, content on the +# right, status bar across the bottom. Everything docks, so the window resizes +# properly. The original Cleanup Tool used absolute Point/Size coordinates and +# had to disable its own maximize button as a result. +$root = New-Object System.Windows.Forms.TableLayoutPanel +$root.Dock = 'Fill' +$root.ColumnCount = 2 +$root.RowCount = 3 +$root.Padding = New-Object System.Windows.Forms.Padding(12) +$root.BackColor = Get-ThemeColor Back +[void]$root.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 210))) +[void]$root.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) +[void]$root.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 56))) +[void]$root.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) +[void]$root.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 40))) +$form.Controls.Add($root) + +# Header +$header = New-Object System.Windows.Forms.Panel +$header.Dock = 'Fill' +$header.BackColor = Get-ThemeColor Back +$root.Controls.Add($header, 0, 0) +$root.SetColumnSpan($header, 2) + +$titleLabel = New-Object System.Windows.Forms.Label +$titleLabel.Text = $script:App.Name +$titleLabel.Font = $script:FontHeader +$titleLabel.ForeColor = Get-ThemeColor Text +$titleLabel.AutoSize = $true +$titleLabel.Location = New-Object System.Drawing.Point(2, 2) +$header.Controls.Add($titleLabel) + +$subtitleLabel = New-Object System.Windows.Forms.Label +$subtitleLabel.Font = $script:FontBody +$subtitleLabel.ForeColor = Get-ThemeColor Muted +$subtitleLabel.AutoSize = $true +$subtitleLabel.Location = New-Object System.Drawing.Point(3, 28) +$header.Controls.Add($subtitleLabel) +$script:Sync.Controls.SubtitleLabel = $subtitleLabel + +$headerButtons = New-Object System.Windows.Forms.FlowLayoutPanel +$headerButtons.Dock = 'Right' +$headerButtons.FlowDirection = 'RightToLeft' +$headerButtons.WrapContents = $false +$headerButtons.AutoSize = $true +$header.Controls.Add($headerButtons) + +$themeButton = New-Button -Text 'Theme' -Width 80 -Height 28 +$headerButtons.Controls.Add($themeButton) + +$elevateButton = New-Button -Text 'Restart as admin' -Width 140 -Height 28 +$headerButtons.Controls.Add($elevateButton) +$script:Sync.Controls.ElevateButton = $elevateButton + +# Left navigation +$nav = New-Object System.Windows.Forms.FlowLayoutPanel +$nav.Dock = 'Fill' +$nav.FlowDirection = 'TopDown' +$nav.WrapContents = $false +$nav.Padding = New-Object System.Windows.Forms.Padding(0, 4, 12, 0) +$nav.BackColor = Get-ThemeColor Back +$root.Controls.Add($nav, 0, 1) + +# Content host - one panel per page, only one visible at a time +$contentHost = New-Object System.Windows.Forms.Panel +$contentHost.Dock = 'Fill' +$contentHost.BackColor = Get-ThemeColor Back +$root.Controls.Add($contentHost, 1, 1) +$script:Sync.Controls.ContentHost = $contentHost + +# Status bar +$statusBar = New-Object System.Windows.Forms.TableLayoutPanel +$statusBar.Dock = 'Fill' +$statusBar.ColumnCount = 3 +$statusBar.BackColor = Get-ThemeColor Panel +$statusBar.Padding = New-Object System.Windows.Forms.Padding(10, 8, 10, 8) +[void]$statusBar.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 260))) +[void]$statusBar.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) +[void]$statusBar.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 170))) +$root.Controls.Add($statusBar, 0, 2) +$root.SetColumnSpan($statusBar, 2) + +$statusLabel = New-Object System.Windows.Forms.Label +$statusLabel.Text = 'Ready' +$statusLabel.AutoSize = $false +$statusLabel.Dock = 'Fill' +$statusLabel.TextAlign = 'MiddleLeft' +$statusLabel.ForeColor = Get-ThemeColor Muted +$statusBar.Controls.Add($statusLabel, 0, 0) +$script:Sync.Controls.StatusLabel = $statusLabel + +$summaryLabel = New-Object System.Windows.Forms.Label +$summaryLabel.AutoSize = $false +$summaryLabel.Dock = 'Fill' +$summaryLabel.TextAlign = 'MiddleLeft' +$summaryLabel.Font = $script:FontBold +$summaryLabel.ForeColor = Get-ThemeColor Muted +$statusBar.Controls.Add($summaryLabel, 1, 0) +$script:Sync.Controls.SummaryLabel = $summaryLabel + +$progress = New-Object System.Windows.Forms.ProgressBar +$progress.Dock = 'Fill' +$progress.Style = 'Marquee' +$progress.MarqueeAnimationSpeed = 28 +$progress.Visible = $false +$statusBar.Controls.Add($progress, 2, 0) +$script:Sync.Controls.Progress = $progress + +#endregion + +#region Page infrastructure + +$script:LastResults = @() +$script:LastReport = $null +$script:Pages = [ordered]@{} +$script:NavButtons = @{} +$script:Sync.Controls.RunButtons = [System.Collections.ArrayList]::new() + +function Register-Page { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Subtitle, + [Parameter(Mandatory)][System.Windows.Forms.Control]$Panel + ) + + $Panel.Dock = 'Fill' + $Panel.Visible = $false + $Panel.Tag = $Subtitle + $script:Sync.Controls.ContentHost.Controls.Add($Panel) + $script:Pages[$Name] = $Panel + + $navButton = New-Button -Text $Name -Width 186 -Height 38 + $navButton.TextAlign = 'MiddleLeft' + $navButton.Padding = New-Object System.Windows.Forms.Padding(12, 0, 0, 0) + # No GetNewClosure() here: it would capture $this as it is now (null) and + # shadow the sender WinForms supplies when the click actually happens. + $navButton.Add_Click({ Show-Page -Name $this.Text }) + $nav.Controls.Add($navButton) + $script:NavButtons[$Name] = $navButton +} + +function Show-Page { + param([Parameter(Mandatory)][string]$Name) + + foreach ($key in $script:Pages.Keys) { + $script:Pages[$key].Visible = ($key -eq $Name) + } + + foreach ($key in $script:NavButtons.Keys) { + $button = $script:NavButtons[$key] + $button.Tag = if ($key -eq $Name) { 'AccentButton' } else { 'Button' } + } + + $script:Sync.Controls.SubtitleLabel.Text = $script:Pages[$Name].Tag + Update-ThemeColors +} + +#endregion + +#region Maintenance page + +$maintenancePage = New-Object System.Windows.Forms.TableLayoutPanel +$maintenancePage.ColumnCount = 1 +$maintenancePage.RowCount = 2 +[void]$maintenancePage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 250))) +[void]$maintenancePage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) + +$maintenanceCard = New-Card -Title 'Maintenance' +$maintenancePage.Controls.Add($maintenanceCard, 0, 0) + +$maintenanceBody = New-Object System.Windows.Forms.TableLayoutPanel +$maintenanceBody.Dock = 'Fill' +$maintenanceBody.ColumnCount = 2 +$maintenanceBody.RowCount = 1 +[void]$maintenanceBody.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 55))) +[void]$maintenanceBody.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 45))) +$maintenanceCard.Controls.Add($maintenanceBody) +$maintenanceBody.BringToFront() + +# Profile picker +$profilePanel = New-Object System.Windows.Forms.FlowLayoutPanel +$profilePanel.Dock = 'Fill' +$profilePanel.FlowDirection = 'TopDown' +$profilePanel.WrapContents = $false +$profilePanel.AutoScroll = $true +$maintenanceBody.Controls.Add($profilePanel, 0, 0) + +$profileHint = New-Object System.Windows.Forms.Label +$profileHint.Text = 'Choose a profile, then preview it before running.' +$profileHint.AutoSize = $true +$profileHint.ForeColor = Get-ThemeColor Muted +$profileHint.Margin = New-Object System.Windows.Forms.Padding(0, 4, 0, 8) +$profilePanel.Controls.Add($profileHint) + +$script:ProfileRadios = @{} +foreach ($item in Get-PCMaintenanceProfile) { + $radio = New-Object System.Windows.Forms.RadioButton + $radio.Text = "$($item.Name) - $($item.Description)" + $radio.AutoSize = $false + $radio.Width = 520 + $radio.Height = 38 + $radio.Font = $script:FontBody + $radio.ForeColor = Get-ThemeColor Text + $radio.Checked = ($item.Name -eq 'Quick') + $radio.Tag = $item.Name + $profilePanel.Controls.Add($radio) + $script:ProfileRadios[$item.Name] = $radio +} + +$skipRestoreBox = New-CheckBox -Text 'Skip the restore point (not recommended)' -Name 'SkipRestore' +$skipRestoreBox.Margin = New-Object System.Windows.Forms.Padding(0, 12, 0, 4) +$profilePanel.Controls.Add($skipRestoreBox) + +# Action buttons +$maintenanceActions = New-Object System.Windows.Forms.FlowLayoutPanel +$maintenanceActions.Dock = 'Fill' +$maintenanceActions.FlowDirection = 'TopDown' +$maintenanceActions.WrapContents = $false +$maintenanceBody.Controls.Add($maintenanceActions, 1, 0) + +function Get-SelectedProfileName { + foreach ($name in $script:ProfileRadios.Keys) { + if ($script:ProfileRadios[$name].Checked) { return $name } + } + 'Quick' +} + +function Start-Maintenance { + <# + Preview mode passes -WhatIf all the way down, so the plan the user sees + is produced by the same code path that would perform the work. That is + the point of building the module on ShouldProcess rather than writing a + separate "what would happen" description that can drift. + #> + param([switch]$Preview) + + $profileName = Get-SelectedProfileName + $skipRestore = $skipRestoreBox.Checked + $grid = $script:Sync.Controls.MaintenanceGrid + $grid.Items.Clear() + Show-Summary -Result @() + + $label = if ($Preview) { "Preview: $profileName" } else { "Maintenance: $profileName" } + + Start-ShellTask -Name $label -Arguments @{ + ProfileName = $profileName + SkipRestore = [bool]$skipRestore + Preview = [bool]$Preview + } -Script { + param($ProfileName, $SkipRestore, $Preview) + + $parameters = @{ + ProfileName = $ProfileName + SkipRestorePoint = $SkipRestore + Confirm = $false + } + if ($Preview) { $parameters['WhatIf'] = $true } + + Invoke-PCMaintenance @parameters + } -OnSuccess { + param($results) + + $items = @($results | Where-Object { $_ }) + foreach ($result in $items) { + Add-ResultRow -Grid $script:Sync.Controls.MaintenanceGrid -Result $result + } + Show-Summary -Result $items + + # Kept so Export-PCReport has the real objects, not the grid's strings. + $script:LastResults = $items + + if ($items.Count -eq 0) { + Write-ShellLog -Level INFO -Message 'Preview produced no actions to perform.' + } + } +} + +$previewButton = New-Button -Text 'Preview (no changes)' -Width 210 -OnClick { Start-Maintenance -Preview } +$maintenanceActions.Controls.Add($previewButton) +[void]$script:Sync.Controls.RunButtons.Add($previewButton) + +$runButton = New-Button -Text 'Run maintenance' -Width 210 -Accent -OnClick { + $profileName = Get-SelectedProfileName + $item = Get-PCMaintenanceProfile -Name $profileName + + $message = "Run the '$profileName' profile?`r`n`r`n$($item.Description)" + if (-not $skipRestoreBox.Checked -and $item.RestorePoint) { + $message += "`r`n`r`nA system restore point will be created first." + } + + $answer = [System.Windows.Forms.MessageBox]::Show( + $message, 'PC Tools', 'YesNo', 'Question') + + if ($answer -eq 'Yes') { Start-Maintenance } +} +$maintenanceActions.Controls.Add($runButton) +[void]$script:Sync.Controls.RunButtons.Add($runButton) + +$exportButton = New-Button -Text 'Export report' -Width 210 -OnClick { + $grid = $script:Sync.Controls.MaintenanceGrid + if ($grid.Items.Count -eq 0) { + [System.Windows.Forms.MessageBox]::Show('Run something first.', 'PC Tools', 'OK', 'Information') | Out-Null + return + } + if ($script:LastResults) { + $paths = $script:LastResults | Export-PCReport + if ($paths) { + Write-ShellLog -Level INFO -Message "Report written to $($paths.TextPath)" + Start-Process -FilePath $paths.TextPath + } + } +} +$maintenanceActions.Controls.Add($exportButton) + +$openLogButton = New-Button -Text 'Open log file' -Width 210 -OnClick { + $path = Get-PCLogPath + if ($path -and (Test-Path -LiteralPath $path)) { Start-Process -FilePath $path } + else { Write-ShellLog -Level WARN -Message 'No log file is available.' } +} +$maintenanceActions.Controls.Add($openLogButton) + +# Results +$resultsCard = New-Card -Title 'Results' +$maintenancePage.Controls.Add($resultsCard, 0, 1) + +$maintenanceGrid = New-ResultGrid +$resultsCard.Controls.Add($maintenanceGrid) +$maintenanceGrid.BringToFront() +$script:Sync.Controls.MaintenanceGrid = $maintenanceGrid + +Register-Page -Name 'Maintenance' -Subtitle 'Clean up, repair and verify Windows' -Panel $maintenancePage + +#endregion + +#region Network page + +$networkPage = New-Object System.Windows.Forms.TableLayoutPanel +$networkPage.ColumnCount = 1 +$networkPage.RowCount = 3 +[void]$networkPage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 92))) +[void]$networkPage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 210))) +[void]$networkPage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) + +# Verdict banner - the diagnosis, not a table of pass/fail rows +$verdictCard = New-Card -Title '' -Padding 14 +$networkPage.Controls.Add($verdictCard, 0, 0) + +$verdictLabel = New-Object System.Windows.Forms.Label +$verdictLabel.Text = 'Not tested yet' +$verdictLabel.Font = $script:FontTitle +$verdictLabel.Dock = 'Top' +$verdictLabel.Height = 26 +$verdictLabel.ForeColor = Get-ThemeColor Muted +$verdictCard.Controls.Add($verdictLabel) +$script:Sync.Controls.VerdictLabel = $verdictLabel + +$adviceLabel = New-Object System.Windows.Forms.Label +$adviceLabel.Text = 'Run a diagnostic to see where the problem is.' +$adviceLabel.Dock = 'Fill' +$adviceLabel.ForeColor = Get-ThemeColor Muted +$verdictCard.Controls.Add($adviceLabel) +$adviceLabel.BringToFront() +$script:Sync.Controls.AdviceLabel = $adviceLabel + +# Adapters and actions +$networkMiddle = New-Object System.Windows.Forms.TableLayoutPanel +$networkMiddle.Dock = 'Fill' +$networkMiddle.ColumnCount = 2 +[void]$networkMiddle.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 62))) +[void]$networkMiddle.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 38))) +$networkPage.Controls.Add($networkMiddle, 0, 1) + +$adapterCard = New-Card -Title 'Adapters' +$networkMiddle.Controls.Add($adapterCard, 0, 0) + +$adapterGrid = New-Object System.Windows.Forms.ListView +$adapterGrid.Dock = 'Fill' +$adapterGrid.View = 'Details' +$adapterGrid.FullRowSelect = $true +$adapterGrid.HideSelection = $false +$adapterGrid.BorderStyle = 'None' +$adapterGrid.Font = $script:FontBody +[void]$adapterGrid.Columns.Add('Adapter', 150) +[void]$adapterGrid.Columns.Add('Status', 70) +[void]$adapterGrid.Columns.Add('IPv4', 130) +[void]$adapterGrid.Columns.Add('Gateway', 130) +[void]$adapterGrid.Columns.Add('DNS', 180) +[void]$adapterGrid.Columns.Add('DHCP', 60) +$adapterCard.Controls.Add($adapterGrid) +$adapterGrid.BringToFront() +$script:Sync.Controls.AdapterGrid = $adapterGrid + +$networkActions = New-Object System.Windows.Forms.FlowLayoutPanel +$networkActions.Dock = 'Fill' +$networkActions.FlowDirection = 'TopDown' +$networkActions.WrapContents = $false +$networkActions.AutoScroll = $true +$networkMiddle.Controls.Add($networkActions, 1, 0) + +function Update-AdapterGrid { + param([AllowEmptyCollection()][object[]]$Adapter) + + $grid = $script:Sync.Controls.AdapterGrid + $grid.BeginUpdate() + try { + $grid.Items.Clear() + foreach ($item in $Adapter) { + $row = New-Object System.Windows.Forms.ListViewItem([string]$item.Name) + [void]$row.SubItems.Add([string]$item.Status) + [void]$row.SubItems.Add([string]$item.IPv4Address) + [void]$row.SubItems.Add([string]$item.Gateway) + [void]$row.SubItems.Add([string]$item.DnsServers) + [void]$row.SubItems.Add($(if ($item.Dhcp) { 'Yes' } elseif ($null -eq $item.Dhcp) { '?' } else { 'No' })) + $row.ForeColor = if ($item.Status -eq 'Up') { Get-ThemeColor Text } else { Get-ThemeColor Muted } + $row.Tag = $item.Name + [void]$grid.Items.Add($row) + } + } + finally { + $grid.EndUpdate() + } +} + +function Show-Verdict { + param($Report) + + $verdict = $Report.Verdict + $script:Sync.Controls.VerdictLabel.Text = $verdict.Verdict + $script:Sync.Controls.AdviceLabel.Text = $verdict.Advice + $script:Sync.Controls.VerdictLabel.ForeColor = if ($verdict.Healthy) { + Get-ThemeColor Success + } + else { + Get-ThemeColor Danger + } + + $grid = $script:Sync.Controls.NetworkGrid + $grid.Items.Clear() + foreach ($test in $Report.Tests) { + $row = New-Object System.Windows.Forms.ListViewItem($(if ($test.Success) { 'Pass' } else { 'Fail' })) + [void]$row.SubItems.Add([string]$test.Layer) + [void]$row.SubItems.Add($(if ($null -ne $test.LatencyMs) { "$($test.LatencyMs) ms" } else { '' })) + [void]$row.SubItems.Add("$($test.Target) - $($test.Detail)") + $row.ForeColor = if ($test.Success) { Get-ThemeColor Success } else { Get-ThemeColor Danger } + [void]$grid.Items.Add($row) + } + + $script:LastReport = $Report +} + +function Start-NetworkDiagnostic { + param([switch]$Full) + + Start-ShellTask -Name $(if ($Full) { 'Full network diagnostic' } else { 'Quick network diagnostic' }) ` + -Arguments @{ Full = [bool]$Full } -Script { + param($Full) + Get-PCNetworkReport -Full:$Full + } -OnSuccess { + param($report) + if (-not $report) { return } + Update-AdapterGrid -Adapter @($report.Adapters) + Show-Verdict -Report $report + } +} + +function Start-NetworkAction { + param( + [Parameter(Mandatory)][string]$Label, + [Parameter(Mandatory)][scriptblock]$Script, + [hashtable]$Arguments = @{} + ) + + Start-ShellTask -Name $Label -Script $Script -Arguments $Arguments -OnSuccess { + param($results) + $items = @($results | Where-Object { $_ }) + foreach ($result in $items) { + Add-ResultRow -Grid $script:Sync.Controls.NetworkGrid -Result $result + } + Show-Summary -Result $items + $script:LastResults = $items + } +} + +$quickDiagButton = New-Button -Text 'Quick diagnostic' -Width 200 -Accent -OnClick { Start-NetworkDiagnostic } +$networkActions.Controls.Add($quickDiagButton) +[void]$script:Sync.Controls.RunButtons.Add($quickDiagButton) + +$fullDiagButton = New-Button -Text 'Full diagnostic' -Width 200 -OnClick { Start-NetworkDiagnostic -Full } +$networkActions.Controls.Add($fullDiagButton) +[void]$script:Sync.Controls.RunButtons.Add($fullDiagButton) + +$flushDnsButton = New-Button -Text 'Flush DNS cache' -Width 200 -OnClick { + Start-NetworkAction -Label 'Flush DNS cache' -Script { Clear-PCDnsCache -Confirm:$false } +} +$networkActions.Controls.Add($flushDnsButton) +[void]$script:Sync.Controls.RunButtons.Add($flushDnsButton) + +$refreshAdaptersButton = New-Button -Text 'Refresh adapters' -Width 200 -OnClick { + Start-ShellTask -Name 'Refresh adapters' -Script { Get-PCNetworkAdapter } -OnSuccess { + param($adapters) + Update-AdapterGrid -Adapter @($adapters) + } +} +$networkActions.Controls.Add($refreshAdaptersButton) +[void]$script:Sync.Controls.RunButtons.Add($refreshAdaptersButton) + +$dnsPresetLabel = New-Object System.Windows.Forms.Label +$dnsPresetLabel.Text = 'Set DNS on the selected adapter:' +$dnsPresetLabel.AutoSize = $true +$dnsPresetLabel.ForeColor = Get-ThemeColor Muted +$dnsPresetLabel.Margin = New-Object System.Windows.Forms.Padding(0, 10, 0, 4) +$networkActions.Controls.Add($dnsPresetLabel) + +function Get-SelectedAdapterName { + $grid = $script:Sync.Controls.AdapterGrid + if ($grid.SelectedItems.Count -eq 0) { + [System.Windows.Forms.MessageBox]::Show( + 'Select an adapter in the list first.', 'PC Tools', 'OK', 'Information') | Out-Null + return $null + } + [string]$grid.SelectedItems[0].Tag +} + +foreach ($preset in @('Cloudflare', 'Google', 'Quad9')) { + $presetButton = New-Button -Text "DNS: $preset" -Width 200 + $presetButton.Tag = $preset + $presetButton.Add_Click({ + $adapterName = Get-SelectedAdapterName + if (-not $adapterName) { return } + $chosen = [string]$this.Tag + + Start-NetworkAction -Label "Set DNS ($chosen)" -Arguments @{ + AdapterName = $adapterName + Preset = $chosen + } -Script { + param($AdapterName, $Preset) + Set-PCDnsServer -Name $AdapterName -Preset $Preset -Confirm:$false + } + }) + $networkActions.Controls.Add($presetButton) + [void]$script:Sync.Controls.RunButtons.Add($presetButton) +} + +$dhcpButton = New-Button -Text 'Revert to DHCP' -Width 200 -OnClick { + $adapterName = Get-SelectedAdapterName + if (-not $adapterName) { return } + + Start-NetworkAction -Label 'Revert to DHCP' -Arguments @{ AdapterName = $adapterName } -Script { + param($AdapterName) + Set-PCDhcp -Name $AdapterName -Confirm:$false + } +} +$networkActions.Controls.Add($dhcpButton) +[void]$script:Sync.Controls.RunButtons.Add($dhcpButton) + +$resetStackButton = New-Button -Text 'Reset network stack' -Width 200 -OnClick { + $answer = [System.Windows.Forms.MessageBox]::Show( + ("Reset the TCP/IP and Winsock stack?`r`n`r`n" + + "This requires a restart, and resetting Winsock removes third-party " + + "layered service providers - some VPN clients stop working until they " + + "are reinstalled.`r`n`r`nTry the other fixes first."), + 'PC Tools', 'YesNo', 'Warning') + + if ($answer -ne 'Yes') { return } + + Start-NetworkAction -Label 'Reset network stack' -Script { + Reset-PCNetworkStack -Confirm:$false + } +} +$networkActions.Controls.Add($resetStackButton) +[void]$script:Sync.Controls.RunButtons.Add($resetStackButton) + +$traceButton = New-Button -Text 'Trace route' -Width 200 -OnClick { + $script:Sync.Controls.NetworkGrid.Items.Clear() + + Start-ShellTask -Name 'Trace route' -Script { + Test-PCRoute -Target '1.1.1.1' -MaxHops 20 -TimeoutSeconds 2 + } -OnSuccess { + param($hops) + $grid = $script:Sync.Controls.NetworkGrid + foreach ($hop in @($hops)) { + $row = New-Object System.Windows.Forms.ListViewItem("Hop $($hop.Hop)") + [void]$row.SubItems.Add('Route') + [void]$row.SubItems.Add($(if ($null -ne $hop.LatencyMs) { "$($hop.LatencyMs) ms" } else { '' })) + [void]$row.SubItems.Add("$($hop.Address) - $($hop.Status)") + $row.ForeColor = if ($hop.Status -eq 'TimedOut') { Get-ThemeColor Muted } else { Get-ThemeColor Text } + [void]$grid.Items.Add($row) + } + } +} +$networkActions.Controls.Add($traceButton) +[void]$script:Sync.Controls.RunButtons.Add($traceButton) + +$mtuButton = New-Button -Text 'Check path MTU' -Width 200 -OnClick { + Start-ShellTask -Name 'Check path MTU' -Script { + Test-PCMtu -Target '1.1.1.1' -TimeoutSeconds 3 + } -OnSuccess { + param($probe) + if (-not $probe) { return } + $grid = $script:Sync.Controls.NetworkGrid + $row = New-Object System.Windows.Forms.ListViewItem($(if ($probe.Standard) { 'Pass' } else { 'Check' })) + [void]$row.SubItems.Add('MTU') + [void]$row.SubItems.Add($(if ($probe.Mtu) { "$($probe.Mtu)" } else { '' })) + [void]$row.SubItems.Add($probe.Detail) + $row.ForeColor = if ($probe.Standard) { Get-ThemeColor Success } else { Get-ThemeColor Warning } + [void]$grid.Items.Add($row) + } +} +$networkActions.Controls.Add($mtuButton) +[void]$script:Sync.Controls.RunButtons.Add($mtuButton) + +$wirelessButton = New-Button -Text 'Wi-Fi signal' -Width 200 -OnClick { + Start-ShellTask -Name 'Wi-Fi signal' -Script { Get-PCWirelessStatus } -OnSuccess { + param($status) + if (-not $status) { return } + $grid = $script:Sync.Controls.NetworkGrid + $row = New-Object System.Windows.Forms.ListViewItem($(if ($status.Connected) { 'Info' } else { 'Fail' })) + [void]$row.SubItems.Add('Wi-Fi') + [void]$row.SubItems.Add($(if ($status.Connected) { "$($status.SignalPercent)%" } else { '' })) + [void]$row.SubItems.Add($(if ($status.Connected) { "$($status.Ssid) - $($status.Detail)" } else { $status.Detail })) + $row.ForeColor = if (-not $status.Connected) { Get-ThemeColor Muted } + elseif ($status.SignalGrade -eq 'Poor') { Get-ThemeColor Danger } + elseif ($status.SignalGrade -eq 'Fair') { Get-ThemeColor Warning } + else { Get-ThemeColor Success } + [void]$grid.Items.Add($row) + } +} +$networkActions.Controls.Add($wirelessButton) +[void]$script:Sync.Controls.RunButtons.Add($wirelessButton) + +$saveProfileButton = New-Button -Text 'Save adapter profile' -Width 200 -OnClick { + $adapterName = Get-SelectedAdapterName + if (-not $adapterName) { return } + + $profileName = [Microsoft.VisualBasic.Interaction]::InputBox( + 'Name for this configuration (for example: Office, Home)', 'Save network profile', $adapterName) + if (-not $profileName) { return } + + Start-NetworkAction -Label "Save profile '$profileName'" -Arguments @{ + AdapterName = $adapterName + ProfileName = $profileName + } -Script { + param($AdapterName, $ProfileName) + Export-PCNetworkProfile -Name $AdapterName -ProfileName $ProfileName -Confirm:$false + } +} +$networkActions.Controls.Add($saveProfileButton) +[void]$script:Sync.Controls.RunButtons.Add($saveProfileButton) + +$applyProfileButton = New-Button -Text 'Apply adapter profile' -Width 200 -OnClick { + $saved = @(Get-PCNetworkProfile) + if ($saved.Count -eq 0) { + [System.Windows.Forms.MessageBox]::Show( + 'No network profiles have been saved yet. Select an adapter and use "Save adapter profile" first.', + 'PC Tools', 'OK', 'Information') | Out-Null + return + } + + $choice = [Microsoft.VisualBasic.Interaction]::InputBox( + "Which profile?`r`n`r`nSaved: $(($saved.ProfileName) -join ', ')", + 'Apply network profile', $saved[0].ProfileName) + if (-not $choice) { return } + + $adapterName = Get-SelectedAdapterName + if (-not $adapterName) { return } + + Start-NetworkAction -Label "Apply profile '$choice'" -Arguments @{ + ProfileName = $choice + AdapterName = $adapterName + } -Script { + param($ProfileName, $AdapterName) + Import-PCNetworkProfile -ProfileName $ProfileName -Name $AdapterName -Confirm:$false + } +} +$networkActions.Controls.Add($applyProfileButton) +[void]$script:Sync.Controls.RunButtons.Add($applyProfileButton) + +$exportNetworkButton = New-Button -Text 'Export report' -Width 200 -OnClick { + if (-not $script:LastReport) { + [System.Windows.Forms.MessageBox]::Show('Run a diagnostic first.', 'PC Tools', 'OK', 'Information') | Out-Null + return + } + $paths = $script:LastReport | Export-PCReport -BaseName 'pctools-network' + if ($paths) { + Write-ShellLog -Level INFO -Message "Report written to $($paths.TextPath)" + Start-Process -FilePath $paths.TextPath + } +} +$networkActions.Controls.Add($exportNetworkButton) + +# Test results +$networkResultsCard = New-Card -Title 'Test results' +$networkPage.Controls.Add($networkResultsCard, 0, 2) + +$networkGrid = New-ResultGrid +$networkGrid.Columns[0].Text = 'Result' +$networkGrid.Columns[1].Text = 'Layer' +$networkGrid.Columns[2].Text = 'Latency' +$networkResultsCard.Controls.Add($networkGrid) +$networkGrid.BringToFront() +$script:Sync.Controls.NetworkGrid = $networkGrid + +Register-Page -Name 'Network' -Subtitle 'Diagnose and repair network problems' -Panel $networkPage + +#endregion + +#region Preferences page + +$preferencesPage = New-Object System.Windows.Forms.TableLayoutPanel +$preferencesPage.ColumnCount = 1 +$preferencesPage.RowCount = 2 +[void]$preferencesPage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) +[void]$preferencesPage.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 190))) + +$preferencesCard = New-Card -Title 'Windows preferences' +$preferencesPage.Controls.Add($preferencesCard, 0, 0) + +$preferencesBody = New-Object System.Windows.Forms.TableLayoutPanel +$preferencesBody.Dock = 'Fill' +$preferencesBody.ColumnCount = 2 +[void]$preferencesBody.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 70))) +[void]$preferencesBody.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 30))) +$preferencesCard.Controls.Add($preferencesBody) +$preferencesBody.BringToFront() + +$preferencesList = New-Object System.Windows.Forms.FlowLayoutPanel +$preferencesList.Dock = 'Fill' +$preferencesList.FlowDirection = 'TopDown' +$preferencesList.WrapContents = $false +$preferencesList.AutoScroll = $true +$preferencesList.Padding = New-Object System.Windows.Forms.Padding(0, 6, 0, 0) +$preferencesBody.Controls.Add($preferencesList, 0, 0) + +$script:PreferenceBoxes = @{} + +function Update-PreferenceState { + <# + Reflects what Windows actually has set right now. + + The original tool could not do this: it had no way to read a preference, + so every checkbox rendered unticked regardless of the machine's state, + and the user had no way to know what was already applied. + #> + param([AllowEmptyCollection()][object[]]$Preference) + + foreach ($item in $Preference) { + $box = $script:PreferenceBoxes[$item.Name] + if (-not $box) { continue } + + $box.Checked = $item.Enabled + $box.Text = if ($item.State -eq 'Partial') { + "$($item.Description) (partially applied)" + } + else { + $item.Description + } + $box.ForeColor = if ($item.State -eq 'Partial') { Get-ThemeColor Warning } else { Get-ThemeColor Text } + $box.Tag = $item.Name + } +} + +function Start-PreferenceRefresh { + Start-ShellTask -Name 'Read Windows preferences' -Script { Get-PCPreference } -OnSuccess { + param($preferences) + Update-PreferenceState -Preference @($preferences) + } +} + +foreach ($definition in Get-PCPreference) { + $box = New-CheckBox -Text $definition.Description -Name $definition.Name -Checked $definition.Enabled + $box.Width = 560 + $box.AutoSize = $false + $box.Height = 26 + $preferencesList.Controls.Add($box) + $script:PreferenceBoxes[$definition.Name] = $box +} + +$preferenceActions = New-Object System.Windows.Forms.FlowLayoutPanel +$preferenceActions.Dock = 'Fill' +$preferenceActions.FlowDirection = 'TopDown' +$preferenceActions.WrapContents = $false +$preferencesBody.Controls.Add($preferenceActions, 1, 0) + +$applyPreferencesButton = New-Button -Text 'Apply changes' -Width 190 -Accent -OnClick { + $enable = @() + $disable = @() + + foreach ($name in $script:PreferenceBoxes.Keys) { + if ($script:PreferenceBoxes[$name].Checked) { $enable += $name } else { $disable += $name } + } + + Start-ShellTask -Name 'Apply Windows preferences' -Arguments @{ + Enable = $enable + Disable = $disable + } -Script { + param($Enable, $Disable) + + # Both directions run: a cleared box restores the Windows default, + # which the original tool had no way to express. + if ($Enable.Count) { Set-PCPreference -Name $Enable -Enabled $true -NoRestartExplorer -Confirm:$false } + if ($Disable.Count) { Set-PCPreference -Name $Disable -Enabled $false -NoRestartExplorer -Confirm:$false } + } -OnSuccess { + param($results) + $items = @($results | Where-Object { $_ }) + foreach ($result in $items) { + Add-ResultRow -Grid $script:Sync.Controls.PreferenceGrid -Result $result + } + Show-Summary -Result $items + $script:LastResults = $items + Start-PreferenceRefresh + } +} +$preferenceActions.Controls.Add($applyPreferencesButton) +[void]$script:Sync.Controls.RunButtons.Add($applyPreferencesButton) + +$refreshPreferencesButton = New-Button -Text 'Reload from Windows' -Width 190 -OnClick { Start-PreferenceRefresh } +$preferenceActions.Controls.Add($refreshPreferencesButton) +[void]$script:Sync.Controls.RunButtons.Add($refreshPreferencesButton) + +$restartExplorerButton = New-Button -Text 'Restart Explorer' -Width 190 -OnClick { + Start-ShellTask -Name 'Restart Explorer' -Script { Restart-PCExplorer -Confirm:$false } -OnSuccess { + param($result) + if ($result) { Add-ResultRow -Grid $script:Sync.Controls.PreferenceGrid -Result $result } + } +} +$preferenceActions.Controls.Add($restartExplorerButton) +[void]$script:Sync.Controls.RunButtons.Add($restartExplorerButton) + +$preferenceHint = New-Object System.Windows.Forms.Label +$preferenceHint.Text = "Clearing a box restores the Windows default. Explorer restarts only when you ask it to, so nothing disappears mid-click." +$preferenceHint.AutoSize = $false +$preferenceHint.Width = 190 +$preferenceHint.Height = 90 +$preferenceHint.ForeColor = Get-ThemeColor Muted +$preferenceHint.Margin = New-Object System.Windows.Forms.Padding(0, 10, 0, 0) +$preferenceActions.Controls.Add($preferenceHint) + +$preferenceResultsCard = New-Card -Title 'Results' +$preferencesPage.Controls.Add($preferenceResultsCard, 0, 1) + +$preferenceGrid = New-ResultGrid +$preferenceResultsCard.Controls.Add($preferenceGrid) +$preferenceGrid.BringToFront() +$script:Sync.Controls.PreferenceGrid = $preferenceGrid + +Register-Page -Name 'Preferences' -Subtitle 'Read and change Windows settings, both directions' -Panel $preferencesPage + +#endregion + +#region Log page + +$logPage = New-Object System.Windows.Forms.Panel +$logCard = New-Card -Title 'Activity log' +$logPage.Controls.Add($logCard) + +$logGrid = New-Object System.Windows.Forms.ListView +$logGrid.Dock = 'Fill' +$logGrid.View = 'Details' +$logGrid.FullRowSelect = $true +$logGrid.BorderStyle = 'None' +$logGrid.Font = $script:FontMono +[void]$logGrid.Columns.Add('Time', 90) +[void]$logGrid.Columns.Add('Level', 70) +[void]$logGrid.Columns.Add('Message', 900) +$logCard.Controls.Add($logGrid) +$logGrid.BringToFront() +$script:Sync.Controls.LogGrid = $logGrid + +Register-Page -Name 'Log' -Subtitle 'Everything the tools have done this session' -Panel $logPage + +#endregion + +#region Theming + +function Update-ThemeColors { + <# + Walks the control tree and repaints from the active palette. + + Controls carry their role in .Tag ('Card', 'Heading', 'AccentButton'), + so a new control gets themed by tagging it rather than by being added to + a hand-maintained list - which is what the original framework used, and + why its theme toggle silently missed any control added later. + #> + $theme = $script:Themes[$script:Sync.Theme] + + $apply = { + param($Control) + + switch -Regex ([string]$Control.Tag) { + '^Card$' { + $Control.BackColor = $theme.Card + break + } + '^AccentButton$' { + $Control.BackColor = $theme.Accent + $Control.ForeColor = $theme.AccentText + $Control.FlatAppearance.BorderColor = $theme.Accent + break + } + '^Button$' { + $Control.BackColor = $theme.Button + $Control.ForeColor = $theme.Text + $Control.FlatAppearance.BorderColor = $theme.Border + break + } + '^Heading$' { + $Control.ForeColor = $theme.Text + break + } + } + + if ($Control -is [System.Windows.Forms.ListView]) { + $Control.BackColor = $theme.Card + $Control.ForeColor = $theme.Text + } + elseif ($Control -is [System.Windows.Forms.CheckBox] -or + $Control -is [System.Windows.Forms.RadioButton]) { + $Control.ForeColor = $theme.Text + } + elseif ($Control -is [System.Windows.Forms.TableLayoutPanel] -or + $Control -is [System.Windows.Forms.FlowLayoutPanel]) { + # Layout panels inside a card must not paint over it. + if ($Control.Parent -and [string]$Control.Parent.Tag -eq 'Card') { + $Control.BackColor = $theme.Card + } + } + + foreach ($child in $Control.Controls) { & $apply $child } + } + + $script:Sync.Form.BackColor = $theme.Back + foreach ($child in $script:Sync.Form.Controls) { & $apply $child } + + $script:Sync.Controls.StatusLabel.ForeColor = $theme.Muted + $script:Sync.Controls.SubtitleLabel.ForeColor = $theme.Muted +} + +$themeButton.Add_Click({ + $script:Sync.Theme = if ($script:Sync.Theme -eq 'Dark') { 'Light' } else { 'Dark' } + Update-ThemeColors + Write-ShellLog -Level INFO -Message "Theme switched to $($script:Sync.Theme)." +}) + +#endregion + +#region Elevation + +function Update-ElevationState { + $isAdmin = Test-PCAdmin + $button = $script:Sync.Controls.ElevateButton + $button.Visible = -not $isAdmin + + $suffix = if ($isAdmin) { 'Administrator' } else { 'Standard user - some actions are unavailable' } + $script:Sync.Form.Text = "$($script:App.Name) $($script:App.Version) - $suffix" +} + +$elevateButton.Add_Click({ + $arguments = @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', + '-File', "`"$PSCommandPath`"", '-Theme', $script:Sync.Theme + ) + + try { + Start-Process -FilePath 'powershell.exe' -ArgumentList $arguments -Verb RunAs + $script:Sync.Form.Close() + } + catch { + # The user declining the UAC prompt is a normal outcome, not an error. + Write-ShellLog -Level WARN -Message 'Elevation was cancelled.' + } +}) + +#endregion + +#region Timers + +# Drains the log queue onto the UI thread. Batched, because appending one +# ListView item per tick made the original framework's log pane crawl under a +# chatty task. +$logTimer = New-Object System.Windows.Forms.Timer +$logTimer.Interval = 160 +$logTimer.Add_Tick({ + $grid = $script:Sync.Controls.LogGrid + if (-not $grid -or $grid.IsDisposed) { return } + + $batch = [System.Collections.Generic.List[object]]::new() + $entry = $null + while ($batch.Count -lt 100 -and $script:Sync.LogQueue.TryDequeue([ref]$entry)) { + if ($entry) { $batch.Add($entry) } + } + if ($batch.Count -eq 0) { return } + + $grid.BeginUpdate() + try { + foreach ($item in $batch) { + $row = New-Object System.Windows.Forms.ListViewItem([string]$item.Timestamp) + [void]$row.SubItems.Add([string]$item.Level) + [void]$row.SubItems.Add([string]$item.Message) + $row.ForeColor = switch ([string]$item.Level) { + 'ERROR' { Get-ThemeColor Danger } + 'WARN' { Get-ThemeColor Warning } + 'DEBUG' { Get-ThemeColor Muted } + default { Get-ThemeColor Text } + } + [void]$grid.Items.Add($row) + } + + # Keep the pane bounded; the file log is the complete record. + while ($grid.Items.Count -gt 2000) { $grid.Items.RemoveAt(0) } + + $grid.EnsureVisible($grid.Items.Count - 1) + } + finally { + $grid.EndUpdate() + } +}) + +# Reaps finished runspace tasks. +$taskTimer = New-Object System.Windows.Forms.Timer +$taskTimer.Interval = 150 +$taskTimer.Add_Tick({ + $finished = @($script:Sync.Tasks | Where-Object { $_.Handle -and $_.Handle.IsCompleted }) + foreach ($task in $finished) { + $script:Sync.Tasks.Remove($task) + Complete-ShellTask -Task $task + } +}) + +#endregion + +#region Startup and shutdown + +$form.Add_Shown({ + Update-ThemeColors + Update-ElevationState + Show-Page -Name 'Maintenance' + + $logTimer.Start() + $taskTimer.Start() + + Write-ShellLog -Level INFO -Message "$($script:App.Name) $($script:App.Version) started." + Write-ShellLog -Level INFO -Message "Log file: $(Get-PCLogPath)" + + if (-not (Test-PCAdmin)) { + 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) + } +}) + +$form.Add_FormClosing({ + # These parameters are deliberately not named after the WinForms + # convention: PowerShell reserves those names as automatic variables, and + # shadowing an automatic variable in a param block invites a confusing bug. + param($closingSender, $closingArgs) + + if ($script:Sync.Busy) { + $answer = [System.Windows.Forms.MessageBox]::Show( + "A task is still running.`r`n`r`nClosing now may leave it half-finished. Close anyway?", + 'PC Tools', 'YesNo', 'Warning') + + if ($answer -ne 'Yes') { + $closingArgs.Cancel = $true + return + } + } + + try { $logTimer.Stop(); $logTimer.Dispose() } catch { } + try { $taskTimer.Stop(); $taskTimer.Dispose() } catch { } + + foreach ($task in @($script:Sync.Tasks)) { + try { $task.Shell.Stop(); $task.Shell.Dispose() } catch { } + } + + try { $script:Pool.Close(); $script:Pool.Dispose() } catch { } +}) + +[void]$form.ResumeLayout($true) +[void]$form.ShowDialog() +$form.Dispose() + +#endregion diff --git a/tests/Configuration.Tests.ps1 b/tests/Configuration.Tests.ps1 new file mode 100644 index 0000000..a6f75c0 --- /dev/null +++ b/tests/Configuration.Tests.ps1 @@ -0,0 +1,153 @@ +#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.5.0' } + +<# + Tests for user-supplied JSON configuration and the profile lookup it feeds. + + A config file is the one place a user's own text becomes executable + behaviour, so every rejection path is covered: a bad action name must fail + at import, not halfway through a maintenance run. +#> + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:RepoRoot 'src/PCTools/PCTools.psd1') -Force + + $script:ConfigDir = Join-Path ([System.IO.Path]::GetTempPath()) "pctools-tests-$([guid]::NewGuid())" + New-Item -ItemType Directory -Path $script:ConfigDir -Force | Out-Null + + function New-ConfigFile { + param([string]$Name, [string]$Content) + $path = Join-Path $script:ConfigDir $Name + Set-Content -LiteralPath $path -Value $Content -Encoding UTF8 + $path + } +} + +AfterAll { + Remove-Item $script:ConfigDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Module PCTools -Force -ErrorAction SilentlyContinue +} + +Describe 'Import-PCConfiguration' { + + It 'loads profiles and parses their parameters' { + $path = New-ConfigFile 'good.json' @' +{ + "profiles": [ + { + "name": "Weekly", + "description": "Friday routine", + "restorePoint": false, + "actions": [ + { "action": "Clear-PCTempFile" }, + { "action": "Clear-PCBrowserCache", "parameters": { "Browser": ["Chrome", "Edge"] } } + ] + } + ] +} +'@ + $loaded = @(Import-PCConfiguration -Path $path) + + $loaded.Count | Should -Be 1 + $loaded[0].Name | Should -Be 'Weekly' + $loaded[0].RestorePoint | Should -BeFalse + $loaded[0].Actions[1].Parameters.Browser | Should -Be @('Chrome', 'Edge') + } + + It 'defaults restorePoint to true when the file omits it' { + # The safe default has to be the implicit one. + $path = New-ConfigFile 'default.json' '{ "profiles": [ { "name": "D", "actions": [ { "action": "Clear-PCDnsCache" } ] } ] }' + (Import-PCConfiguration -Path $path).RestorePoint | Should -BeTrue + } + + 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*' + } + + It 'rejects a profile with no actions' { + $path = New-ConfigFile 'empty.json' '{ "profiles": [ { "name": "Empty" } ] }' + { Import-PCConfiguration -Path $path } | Should -Throw -ExpectedMessage '*no actions*' + } + + It 'rejects a profile with no name' { + $path = New-ConfigFile 'noname.json' '{ "profiles": [ { "actions": [ { "action": "Clear-PCDnsCache" } ] } ] }' + { Import-PCConfiguration -Path $path } | Should -Throw -ExpectedMessage '*no name*' + } + + It 'rejects a file that is not JSON' { + $path = New-ConfigFile 'broken.json' 'this is not json' + { Import-PCConfiguration -Path $path } | Should -Throw -ExpectedMessage '*not valid JSON*' + } + + It 'rejects JSON with no profiles array' { + $path = New-ConfigFile 'noprofiles.json' '{ "something": 1 }' + { Import-PCConfiguration -Path $path } | Should -Throw -ExpectedMessage "*no 'profiles' array*" + } + + It 'rejects a missing file' { + { Import-PCConfiguration -Path (Join-Path $script:ConfigDir 'nope.json') } | + Should -Throw -ExpectedMessage '*No configuration file*' + } +} + +Describe 'Imported profiles and Get-PCMaintenanceProfile' { + + AfterEach { + # Clear imported state so each test starts from the built-ins. + $empty = New-ConfigFile 'reset.json' '{ "profiles": [ { "name": "Reset", "actions": [ { "action": "Clear-PCDnsCache" } ] } ] }' + Import-PCConfiguration -Path $empty | Out-Null + & (Get-Module PCTools) { $script:PCImportedProfile = @() } + } + + It 'lists imported profiles alongside the built-ins' { + $path = New-ConfigFile 'extra.json' '{ "profiles": [ { "name": "Weekly", "actions": [ { "action": "Clear-PCTempFile" } ] } ] }' + Import-PCConfiguration -Path $path | Out-Null + + $names = (Get-PCMaintenanceProfile).Name + $names | Should -Contain 'Weekly' + $names | Should -Contain 'Full' + } + + It 'lets a user profile override a built-in of the same name' { + $path = New-ConfigFile 'override.json' '{ "profiles": [ { "name": "Quick", "description": "Mine", "actions": [ { "action": "Clear-PCDnsCache" } ] } ] }' + Import-PCConfiguration -Path $path | Out-Null + + (Get-PCMaintenanceProfile -Name Quick).Description | Should -Be 'Mine' + @((Get-PCMaintenanceProfile) | Where-Object Name -eq 'Quick').Count | Should -Be 1 + } + + It 'replaces the previous import rather than accumulating' { + $first = New-ConfigFile 'first.json' '{ "profiles": [ { "name": "First", "actions": [ { "action": "Clear-PCTempFile" } ] } ] }' + Import-PCConfiguration -Path $first | Out-Null + + $second = New-ConfigFile 'second.json' '{ "profiles": [ { "name": "Second", "actions": [ { "action": "Clear-PCDnsCache" } ] } ] }' + Import-PCConfiguration -Path $second | Out-Null + + $names = (Get-PCMaintenanceProfile).Name + $names | Should -Not -Contain 'First' + $names | Should -Contain 'Second' + } +} + +Describe 'New network diagnostics' { + + It 'Test-PCRoute numbers hops from 1 and stops at MaxHops' { + $hops = @(Test-PCRoute -Target '203.0.113.1' -MaxHops 3 -TimeoutSeconds 1) + + $hops.Count | Should -BeLessOrEqual 3 + if ($hops.Count -gt 0) { + $hops[0].Hop | Should -Be 1 + $hops[0].PSObject.TypeNames | Should -Contain 'PCTools.RouteHop' + } + } + + It 'Test-PCMtu reports rather than throws when the target ignores ICMP' { + # 203.0.113.0/24 is TEST-NET-3 and is guaranteed not to answer. + $probe = Test-PCMtu -Target '203.0.113.1' -TimeoutSeconds 1 + + $probe | Should -Not -BeNullOrEmpty + $probe.PSObject.TypeNames | Should -Contain 'PCTools.MtuProbe' + $probe.Detail | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Shell.Tests.ps1 b/tests/Shell.Tests.ps1 new file mode 100644 index 0000000..fcf669e --- /dev/null +++ b/tests/Shell.Tests.ps1 @@ -0,0 +1,141 @@ +#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.5.0' } + +<# + Static analysis of the GUI shell. + + The shell cannot be exercised headlessly - it needs WinForms and a desktop - + but its most damaging bugs are structural and visible in the AST. These + tests cover the three that actually bit this codebase. +#> + +BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + $script:ShellPath = Join-Path $script:RepoRoot 'src/Shell/Start-PCToolsShell.ps1' + + $script:ShellAst = [System.Management.Automation.Language.Parser]::ParseFile( + $script:ShellPath, [ref]$null, [ref]$null) + + $script:ShellText = Get-Content -LiteralPath $script:ShellPath -Raw + + Import-Module (Join-Path $script:RepoRoot 'src/PCTools/PCTools.psd1') -Force + $script:ExportedCommand = (Get-Module PCTools).ExportedFunctions.Keys +} + +AfterAll { + Remove-Module PCTools -Force -ErrorAction SilentlyContinue +} + +Describe 'Shell structure' { + + It 'requires PowerShell 5.1, the runtime WinForms needs' { + $script:ShellText | Should -Match '#requires -Version 5\.1' + } + + 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 + # for the right code path. + $declaredBlock = [regex]::Match($script:ShellText, '(?s)\$controls = @\{(.*?)\n\}') + $declaredBlock.Success | Should -BeTrue -Because 'the Controls initializer must exist' + + $declared = [regex]::Matches($declaredBlock.Groups[1].Value, '(?m)^\s*(\w+)\s*=') | + ForEach-Object { $_.Groups[1].Value } + + $used = [regex]::Matches($script:ShellText, '\$(?:script:)?[Ss]ync\.Controls\.(\w+)') | + ForEach-Object { $_.Groups[1].Value } | + Sort-Object -Unique + + foreach ($key in $used) { + $declared | Should -Contain $key -Because "Sync.Controls.$key is read somewhere in the shell" + } + } + + It 'never captures $this inside GetNewClosure' { + # GetNewClosure() snapshots the current scope. Applied to an event + # handler that uses $this, it captures $null and shadows the sender + # WinForms supplies at click time. + $closures = $script:ShellAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Member.Value -eq 'GetNewClosure' + }, $true) + + foreach ($closure in $closures) { + $closure.Expression.Extent.Text | Should -Not -Match '\$this' -Because 'GetNewClosure would capture $this as null' + } + } + + It 'calls only PCTools commands that the module exports' { + # Catches a renamed or mistyped action before a user finds it by + # clicking the button that no longer works. + $commands = $script:ShellAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] + }, $true) + + $pcCommands = $commands | + ForEach-Object { $_.GetCommandName() } | + Where-Object { $_ -and $_ -match '^\w+-PC\w+$' } | + Sort-Object -Unique + + $pcCommands.Count | Should -BeGreaterThan 0 -Because 'the shell is supposed to drive the module' + + foreach ($name in $pcCommands) { + $script:ExportedCommand | Should -Contain $name + } + } + + It 'runs every module action through the background runspace, not the UI thread' { + # The whole point of the shell rewrite. A direct call to a long-running + # action from a click handler is the freeze the original tools had. + $handlers = [regex]::Matches( + $script:ShellText, + '(?s)Add_Click\(\{(.*?)\}\)') + + $longRunning = @( + 'Repair-PCSystemImage', 'Repair-PCSystemFile', 'Test-PCDisk', + 'Invoke-PCMaintenance', 'Get-PCNetworkReport', 'Reset-PCNetworkStack', + 'Install-PCApplication' + ) + + foreach ($handler in $handlers) { + $body = $handler.Groups[1].Value + foreach ($action in $longRunning) { + if ($body -match "(?