From 825876fdceaad9e004642406827320359c286328 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 21:02:45 +0000 Subject: [PATCH 1/6] Phase 2: promote the GUI framework to the application shell Turns gui-framework.ps1 from an unused demo into the real shell, replacing the two bespoke WinForms UIs. The shell owns presentation only; every action comes from PCTools and runs on a background runspace, so the window stays responsive during a DISM run that the original tools froze for the duration. Pages: Maintenance (profile picker, preview, results grid), Network (verdict banner, adapter list, fixes), Preferences (reads real state, applies in both directions), Log. Structural problems fixed relative to the original framework: - Invoke-UI binds its arguments into a closure instead of relying on scope capture. A block queued with BeginInvoke runs after its creating scope is gone, and the captured locals are unresolvable by then - the callback fails outright rather than doing anything. The original only worked because its task pump was a WinForms Timer already on the UI thread, so InvokeRequired was false and the block ran synchronously; anything that completed a task off the UI thread would have broken it. - Every control key is declared up front. Under Set-StrictMode -Version Latest, reading a not-yet-assigned hashtable key is a terminating error, so a guard such as `if ($sync.Controls.StatusLabel)` threw rather than returning false. - Nav click handlers no longer wrap $this in GetNewClosure(), which would capture $null and shadow the sender WinForms supplies at click time. - The task list is a synchronized ArrayList, matching the rest of $sync. - Per-monitor DPI awareness, so layouts stop blurring and clipping at 150% scaling. - Docked TableLayoutPanel throughout, replacing absolute pixel coordinates and the disabled maximize button. - Closing while a task runs prompts instead of silently orphaning it, and the runspace pool is disposed on exit. FreedDisplay became a plain property rather than a ScriptProperty: these objects cross a runspace boundary, and a lazily evaluated property would reach back into a runspace that has since returned to the pool. Format-PCByteSize is now public. The shell needs to format a total across several results, and reaching into the module's private scope for that is not an interface. Tests: static analysis of the shell over its AST - control keys declared, no $this inside GetNewClosure, every PCTools command it calls actually exported, no long-running action invoked from a click handler, argument binding, pool disposal, and the Winsock confirmation prompt. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ugbD2a2kVkzXGagAw9zzf --- src/PCTools/PCTools.psd1 | 1 + src/PCTools/Private/New-PCActionResult.ps1 | 14 +- .../{Private => Public}/Format-PCByteSize.ps1 | 6 + src/Shell/Start-PCToolsShell.ps1 | 1559 +++++++++++++++++ tests/Shell.Tests.ps1 | 141 ++ 5 files changed, 1714 insertions(+), 7 deletions(-) rename src/PCTools/{Private => Public}/Format-PCByteSize.ps1 (76%) create mode 100644 src/Shell/Start-PCToolsShell.ps1 create mode 100644 tests/Shell.Tests.ps1 diff --git a/src/PCTools/PCTools.psd1 b/src/PCTools/PCTools.psd1 index 17f584c..de3c599 100644 --- a/src/PCTools/PCTools.psd1 +++ b/src/PCTools/PCTools.psd1 @@ -48,6 +48,7 @@ 'Invoke-PCMaintenance' # Host integration + 'Format-PCByteSize' 'Get-PCLogPath' 'Register-PCLogSink' 'Test-PCAdmin' 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/Shell/Start-PCToolsShell.ps1 b/src/Shell/Start-PCToolsShell.ps1 new file mode 100644 index 0000000..5ea273c --- /dev/null +++ b/src/Shell/Start-PCToolsShell.ps1 @@ -0,0 +1,1559 @@ +#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 + +# 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) + +$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({ + param($eventSender, $eventArgs) + + 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') { + $eventArgs.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/Shell.Tests.ps1 b/tests/Shell.Tests.ps1 new file mode 100644 index 0000000..5c43b2a --- /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 "(? Date: Thu, 27 Aug 2026 06:53:51 +0000 Subject: [PATCH 2/6] Phase 3: safety, trust and distribution The preflight, restore-point gating and result summary landed with the module and shell. This is the distribution half. - Release workflow: builds from a v* tag, refuses to publish if the tag and PCTools.psd1 ModuleVersion disagree, runs the full suite first, stages artifacts, and publishes them with SHA256SUMS. Authenticode signing runs when a SIGNING_CERTIFICATE secret is configured, and the checksums are regenerated afterwards - signing rewrites the files, so checksums taken before it would not match what a user downloads. - Checksum generation split out of the Release task for that reason. - pc-tools.ps1: a real entry point, with -NoGui to load the module into a console session. It fails with an explanatory message when piped through iex, because the GUI needs the module beside it. - Release archives ship a layout (pc-tools.ps1 + src/) rather than loose scripts, alongside the module on its own and the legacy single-file tools so existing README URLs keep working. - README rewritten: verified download first, with a hash check that runs before anything executes. The irm | iex commands are kept, pinned to a tag, and documented with their trade-off rather than recommended - main is whatever was pushed to it last, run as Administrator. - MIGRATION.md maps every old function to its replacement, and lists the behaviour changes worth knowing. ROADMAP.md corrected. The async callback problem I described in the original plan was wrong in both mechanism and severity: the queued block does not see stale values, it loses $Task entirely and fails outright, and it is latent rather than live because the task pump is a WinForms Timer already on the UI thread. The corrected entry records the StrictMode hashtable-key issue found during the port as well. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ugbD2a2kVkzXGagAw9zzf --- .github/workflows/release.yml | 103 ++++++++++++++ .gitignore | 1 + CHANGELOG.md | 55 ++++++++ MIGRATION.md | 93 +++++++++++++ README.md | 251 +++++++++++++++++++++++++++------- ROADMAP.md | 42 ++++-- build/Invoke-Build.ps1 | 56 ++++++-- pc-tools.ps1 | 83 +++++++++++ 8 files changed, 618 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 MIGRATION.md create mode 100644 pc-tools.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5d03151 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,103 @@ +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 + + - 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..949b1a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,61 @@ 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. + +### Changed +- 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/README.md b/README.md index 037f7ee..c63277c 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,226 @@ # 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.3.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 +``` + +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.3.0/pc-cleanuptool.ps1 | iex +irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.3.0/pc-netdiag.ps1 | iex +``` + +The verified download above is the recommended path. + +--- + +## Using the GUI + +```powershell +.\pc-tools.ps1 # dark theme +.\pc-tools.ps1 -Theme Light +``` + +- **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. + +Long-running work happens on a background runspace, so the window stays +responsive during a DISM or SFC run. + +--- + +## 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 +``` -#### Alternative -1. Download or clone the repository -2. Open **PowerShell as Administrator** -3. Run the script: .\pc-cleanuptool.ps1 +Run it for real: -#### 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). +```powershell +Invoke-PCMaintenance -ProfileName Quick | + Format-Table Action, Status, FreedDisplay, Detail +``` -### Net Diag GUI (v0.2 - New Release!) -A GUI tool for network diagnostics and fixes, with a refreshed dark UI, improved layout, and controls. +Diagnose a network problem: -#### 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) +```powershell +$report = Get-PCNetworkReport +$report.Verdict.Verdict # e.g. 'DNS is not resolving' +$report.Verdict.Advice +$report | Export-PCReport +``` -#### 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. +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 +``` + +### 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 +``` --- -## 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. +## 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-3 are done; Phase 4 (network profiles, +more preferences, more diagnostics) and Phase 5 (PowerShell Gallery, winget) are +next. Suggestions and contributions are welcome. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index 1685ed2..ac31e58 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. @@ -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 | Next | Network profiles, more preference toggles, more diagnostics. | +| 5 Distribution | Next | PowerShell Gallery, winget. | +| 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..8395943 100644 --- a/build/Invoke-Build.ps1 +++ b/build/Invoke-Build.ps1 @@ -17,7 +17,7 @@ #> [CmdletBinding()] param( - [ValidateSet('Test', 'Analyze', 'Release', 'All')] + [ValidateSet('Test', 'Analyze', 'Release', 'Checksum', 'All')] [string]$Task = 'All', [string]$Version @@ -103,34 +103,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 From 9aba51e51fee21a74864bd7f7858520cb08a0a96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 07:00:19 +0000 Subject: [PATCH 3/6] Phase 4: network profiles, JSON config and new diagnostics The features from the original README roadmap, cheap now that actions and profiles are data rather than click handlers. Network profiles (the Home/Work presets): - Export-PCNetworkProfile captures an adapter's full configuration - DHCP or static, address, prefix, gateway, DNS - as JSON. - Import-PCNetworkProfile applies it. 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. - Get-PCNetworkProfile lists what has been saved. JSON configuration: - Import-PCConfiguration loads user-defined maintenance profiles. They appear in Get-PCMaintenanceProfile and run through Invoke-PCMaintenance with no further plumbing, because a profile was already just data. One named the same as a built-in overrides it; re-importing replaces rather than accumulates. - Every action name is validated at import against the module's exported commands, so a typo fails there instead of partway through a run. New diagnostics: - Test-PCRoute: per-hop traceroute over Ping with an increasing TTL rather than by shelling out to tracert, so hops honour the timeout and come back as objects. Answers where connectivity stops, not just that it has. - Test-PCMtu: binary-searches the path MTU with the don't-fragment flag. This is the diagnostic for the confusing case where DNS resolves and small requests succeed but large transfers and TLS handshakes stall. - Get-PCWirelessStatus: parses and grades the Wi-Fi association. Net Diag captured the same netsh output as raw text in its full report. All five are wired into the shell's Network page, plus save/apply adapter profile. Every field read from user-supplied JSON is now probed before it is read. Under Set-StrictMode -Version Latest an absent property is a terminating error, so a config file missing "name" or "actions" produced a PowerShell property error instead of the message explaining what was wrong with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ugbD2a2kVkzXGagAw9zzf --- CHANGELOG.md | 17 ++ README.md | 49 +++++- ROADMAP.md | 4 +- src/PCTools/PCTools.psd1 | 9 +- src/PCTools/PCTools.psm1 | 3 + src/PCTools/Private/Get-PCProfileStore.ps1 | 11 ++ .../Public/Get-PCMaintenanceProfile.ps1 | 9 ++ src/PCTools/Public/Import-PCConfiguration.ps1 | 124 ++++++++++++++ .../Network/Export-PCNetworkProfile.ps1 | 85 ++++++++++ .../Public/Network/Get-PCNetworkProfile.ps1 | 34 ++++ .../Public/Network/Get-PCWirelessStatus.ps1 | 77 +++++++++ .../Network/Import-PCNetworkProfile.ps1 | 77 +++++++++ src/PCTools/Public/Network/Test-PCMtu.ps1 | 100 ++++++++++++ src/PCTools/Public/Network/Test-PCRoute.ps1 | 84 ++++++++++ src/Shell/Start-PCToolsShell.ps1 | 109 +++++++++++++ tests/Configuration.Tests.ps1 | 153 ++++++++++++++++++ 16 files changed, 936 insertions(+), 9 deletions(-) create mode 100644 src/PCTools/Private/Get-PCProfileStore.ps1 create mode 100644 src/PCTools/Public/Import-PCConfiguration.ps1 create mode 100644 src/PCTools/Public/Network/Export-PCNetworkProfile.ps1 create mode 100644 src/PCTools/Public/Network/Get-PCNetworkProfile.ps1 create mode 100644 src/PCTools/Public/Network/Get-PCWirelessStatus.ps1 create mode 100644 src/PCTools/Public/Network/Import-PCNetworkProfile.ps1 create mode 100644 src/PCTools/Public/Network/Test-PCMtu.ps1 create mode 100644 src/PCTools/Public/Network/Test-PCRoute.ps1 create mode 100644 tests/Configuration.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 949b1a0..acbbce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 - Install instructions are pinned to a release tag and hash-verified. The diff --git a/README.md b/README.md index c63277c..db465f7 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Download the release archive, **verify it**, and run it from disk: ```powershell # 1. Download the current release and its checksums -$version = 'v0.3.0' +$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 @@ -58,8 +58,8 @@ 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.3.0/pc-cleanuptool.ps1 | iex -irm https://raw.githubusercontent.com/likeBloodMoon/pc-powershelltools/v0.3.0/pc-netdiag.ps1 | iex +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. @@ -120,6 +120,40 @@ $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 @@ -129,6 +163,10 @@ 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 @@ -215,9 +253,8 @@ taskbar. ## Roadmap -See [ROADMAP.md](ROADMAP.md). Phases 0-3 are done; Phase 4 (network profiles, -more preferences, more diagnostics) and Phase 5 (PowerShell Gallery, winget) are -next. +See [ROADMAP.md](ROADMAP.md). Phases 0-4 are done. Phase 5 (PowerShell Gallery, +winget) is next. Suggestions and contributions are welcome. diff --git a/ROADMAP.md b/ROADMAP.md index ac31e58..791d63f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,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: @@ -209,7 +209,7 @@ Phases 0-3 are implemented. What actually shipped, against what was planned: | 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 | Next | Network profiles, more preference toggles, more diagnostics. | +| 4 Features | Done | Network profiles, JSON config, traceroute, MTU probe, Wi-Fi status. Nine preference toggles, up from six, all reversible. | | 5 Distribution | Next | PowerShell Gallery, winget. | | 6 `quickspeedboost.ps1` | Partly | Its worthwhile parts are in the module; the script itself is still present and now documented honestly in the README. | diff --git a/src/PCTools/PCTools.psd1 b/src/PCTools/PCTools.psd1 index de3c599..ab3df0f 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,6 +51,7 @@ # Orchestration and reporting 'Export-PCReport' 'Get-PCMaintenanceProfile' + 'Import-PCConfiguration' 'Invoke-PCMaintenance' # Host integration 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/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 index 5ea273c..7bbfef5 100644 --- a/src/Shell/Start-PCToolsShell.ps1 +++ b/src/Shell/Start-PCToolsShell.ps1 @@ -46,6 +46,10 @@ if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') { 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. @@ -1151,6 +1155,111 @@ $resetStackButton = New-Button -Text 'Reset network stack' -Width 200 -OnClick { $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 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 + } +} From b3fe30bec43b5f23bc55735133d748c700e9fc78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 07:02:12 +0000 Subject: [PATCH 4/6] Phase 5: PowerShell Gallery publishing - Release workflow publishes the module to the PowerShell Gallery on a tagged build, gated on a PSGALLERY_API_KEY secret so a release without one still ships as an archive. It refuses to republish an existing version rather than letting the Gallery reject it with a less useful error - Gallery versions are immutable. - Manifest carries the metadata the Gallery surfaces: CompatiblePSEditions, edition tags, inline release notes. - README documents Install-Module as the least-ceremony path, and says plainly why it is better than irm | iex: signed-package verification, versioning and Update-Module. On the roadmap's "portable executable": ps2exe output is unsigned and reliably trips SmartScreen and antivirus heuristics, which is worse for trust than the current script. Recorded as such rather than built. winget is deferred until releases are actually signed. Verified the release pipeline end to end: staged artifacts, expanded the archive, confirmed the layout is complete, ran the extracted entry point, and checked the archive against its published SHA256SUMS line. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ugbD2a2kVkzXGagAw9zzf --- .github/workflows/release.yml | 21 +++++++++++++++++++++ CHANGELOG.md | 4 ++++ README.md | 23 +++++++++++++++++++++-- ROADMAP.md | 4 ++-- src/PCTools/PCTools.psd1 | 19 +++++++++++++++++-- 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d03151..1cdcddd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,6 +82,27 @@ jobs: # 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: diff --git a/CHANGELOG.md b/CHANGELOG.md index acbbce5..98c0995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. diff --git a/README.md b/README.md index db465f7..586e82c 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,20 @@ 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 +``` + +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` @@ -253,8 +267,13 @@ taskbar. ## Roadmap -See [ROADMAP.md](ROADMAP.md). Phases 0-4 are done. Phase 5 (PowerShell Gallery, -winget) is next. +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. diff --git a/ROADMAP.md b/ROADMAP.md index 791d63f..49c271c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -174,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. @@ -210,7 +210,7 @@ Phases 0-3 are implemented. What actually shipped, against what was planned: | 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 | Next | PowerShell Gallery, winget. | +| 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 diff --git a/src/PCTools/PCTools.psd1 b/src/PCTools/PCTools.psd1 index ab3df0f..43bf317 100644 --- a/src/PCTools/PCTools.psd1 +++ b/src/PCTools/PCTools.psd1 @@ -66,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 } } } From aa635710ff2b55e932e09135add87ccc2caa7658 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 07:04:03 +0000 Subject: [PATCH 5/6] Make PSScriptAnalyzer a usable signal The first CI run reported 1114 warnings, which is the same as reporting none: nobody reads a thousand findings, and a real one cannot be seen among them. - Excluded the rules producing false positives by construction. PSReviewUnusedParameter was the worst: nearly every public action wraps its work in Invoke-PCAction -Body { ... }, and the analyzer does not follow parameter use into that scriptblock, so it flagged parameters used two lines further down. - Dropped the purely cosmetic formatting rules. They accounted for most of the volume, against a style the existing scripts already used. Worth a deliberate one-off reformat someday; not worth a gate that hides real findings today. - Kept PSUseCompatibleSyntax, which catches syntax valid in PowerShell 7 but not in 5.1 - the runtime the GUI actually requires. - Analyze now summarises findings by rule before listing them, because the useful question is always which rule and how often. - Added a warning budget. Errors always fail the build; warnings fail once they exceed what the repository has agreed to carry, so the count can only go down. Set provisionally pending the real number from CI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ugbD2a2kVkzXGagAw9zzf --- PSScriptAnalyzerSettings.psd1 | 43 ++++++++++++++++++-------------- build/Invoke-Build.ps1 | 46 +++++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 28 deletions(-) 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/build/Invoke-Build.ps1 b/build/Invoke-Build.ps1 index 8395943..64b017d 100644 --- a/build/Invoke-Build.ps1 +++ b/build/Invoke-Build.ps1 @@ -20,7 +20,11 @@ param( [ValidateSet('Test', 'Analyze', 'Release', 'Checksum', 'All')] [string]$Task = 'All', - [string]$Version + [string]$Version, + + # Warnings above this fail the build. Lower it as findings are fixed; + # raising it should be a deliberate, reviewed decision. + [int]$MaxWarning = 500 ) $ErrorActionPreference = 'Stop' @@ -75,19 +79,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 + } + + # 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') - if ($errors.Count -gt 0) { - throw "PSScriptAnalyzer reported $($errors.Count) error(s)." - } + $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 { From cfa2ea1990019c4ad145bb41b39a7106a10202b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 07:10:26 +0000 Subject: [PATCH 6/6] Stop shadowing an automatic variable, and set the warning budget The retuned analyzer dropped from 1114 warnings to 147, which made the findings readable - and one of them was real. The FormClosing handler declared param($eventSender, $eventArgs), and $eventArgs is a PowerShell automatic variable; shadowing it in a param block is a bug waiting for whoever edits that handler next. Renamed to $closingSender/$closingArgs. Warning budget set to the measured 146. Errors always fail the build; warnings now fail once they exceed what the repository has agreed to carry, so the count can only go down. Most of the remainder is 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. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ugbD2a2kVkzXGagAw9zzf --- build/Invoke-Build.ps1 | 8 ++++++-- src/Shell/Start-PCToolsShell.ps1 | 7 +++++-- tests/Shell.Tests.ps1 | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/build/Invoke-Build.ps1 b/build/Invoke-Build.ps1 index 64b017d..75ba2be 100644 --- a/build/Invoke-Build.ps1 +++ b/build/Invoke-Build.ps1 @@ -22,9 +22,13 @@ param( [string]$Version, - # Warnings above this fail the build. Lower it as findings are fixed; + # 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 = 500 + [int]$MaxWarning = 146 ) $ErrorActionPreference = 'Stop' diff --git a/src/Shell/Start-PCToolsShell.ps1 b/src/Shell/Start-PCToolsShell.ps1 index 7bbfef5..4fad825 100644 --- a/src/Shell/Start-PCToolsShell.ps1 +++ b/src/Shell/Start-PCToolsShell.ps1 @@ -1638,7 +1638,10 @@ $form.Add_Shown({ }) $form.Add_FormClosing({ - param($eventSender, $eventArgs) + # 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( @@ -1646,7 +1649,7 @@ $form.Add_FormClosing({ 'PC Tools', 'YesNo', 'Warning') if ($answer -ne 'Yes') { - $eventArgs.Cancel = $true + $closingArgs.Cancel = $true return } } diff --git a/tests/Shell.Tests.ps1 b/tests/Shell.Tests.ps1 index 5c43b2a..fcf669e 100644 --- a/tests/Shell.Tests.ps1 +++ b/tests/Shell.Tests.ps1 @@ -123,7 +123,7 @@ Describe 'Shell structure' { It 'guards against closing while a task is still running' { $script:ShellText | Should -Match 'Add_FormClosing' - $script:ShellText | Should -Match '\$eventArgs\.Cancel = \$true' + $script:ShellText | Should -Match '\$closingArgs\.Cancel = \$true' } }