diff --git a/.github/Docs/ARCHITECTURE.md b/.github/Docs/ARCHITECTURE.md index 5eb3aad4..3d28321b 100644 --- a/.github/Docs/ARCHITECTURE.md +++ b/.github/Docs/ARCHITECTURE.md @@ -285,6 +285,67 @@ pre-release workflow delegates versioning and both artifact builds. `start.ps1` is an ASCII-safe launcher. It locates/elevates PowerShell 7 and downloads or executes `start-core.ps1`. The core is generated from the ordered fragments in `start-modules/` by `.github/scripts/Invoke-Build-Start.ps1` and validated by `.github/scripts/Test-CompiledStartScript.ps1`. +### Supported configuration and result codes + +Every user-scoped artifact (PowerShell profile, Oh My Posh theme, Windows Terminal settings, desktop shortcut) is written under the account that **runs the process**. A standard account that elevates itself with administrator credentials would therefore receive a log full of successes and, after the reboot, a machine that looks untouched. + +The launcher therefore accepts one configuration only, and checks it **before** touching anything (before Defender, before installing PowerShell 7): + +| Check | Source | +| ----- | ------ | +| Signed-in account | `Win32_ComputerSystem.UserName` (console session owner) | +| It is a local administrator | membership of the group returned by `Get-LocalGroup -SID S-1-5-32-544`, compared by SID, with a `net.exe localgroup` fallback for nested domain groups | +| It is the account running the script | short name comparison against `WindowsIdentity.GetCurrent()` | + +| Outcome | Reported | +| ------- | -------- | +| Supported | `[WinToolkit] result=0` | +| Signed-in account is not a local administrator | `result=4`, `reason=NotLocalAdministrator` | +| Running under a different account than the signed-in one | `result=4`, `reason=AccountMismatch` | + +`start.ps1` always exits with process code **0** (an unsupported machine is a guided +condition, not an error) and prints the dedicated code on a greppable line, so an +automated invocation stays green while still being able to tell the two apart. +`start-core.ps1` keeps its own contract: `0` success, `2` partial success, +`1` blocking error. + +The `WTOOLKIT_ORIGINAL_*` mechanism stays for the legitimate case of an +administrator elevating with a *different* administrator account: the launcher +exports the signed-in identity and paths, and the core writes the user-scoped +artifacts there. + +### PowerShell 7 version policy + +A PowerShell 7 that is merely present is not accepted as final: the banner +*"A new PowerShell stable release is available"* is exactly what an outdated +build produces. The launcher therefore, when it finds an existing PowerShell 7: + +1. runs `winget upgrade --id Microsoft.PowerShell --exact --silent` (WinGet + decides on its own whether an update exists); +2. re-resolves `pwsh`, because the running host keeps the bits it already + loaded and the new build applies from the next session; +3. falls back to comparing the installed version with the + PowerShell `releases/latest` feed, reporting the gap without changing + anything when WinGet is unavailable. + +`-PrintContext` is the diagnostic switch: it prints the detected identity +context and the result code, and exits without installing anything. + +### Windows Update service scope + +`wuauserv` and `bits` are suspended only when they are in their **standard active +state** (running, automatic startup type) — the only state that conflicts with +the AppX and WinGet installs. A service the administrator already disabled, +stopped or set to manual is left completely untouched, at suspend and at restore +time, and the snapshot records that decision (`InScope`) so a recovery after a +crash restores exactly what was touched. + +The restore never re-applies the recorded state to a service it did not stop: +the previous behaviour stopped a service the system had started on its own +during the run and reported the refusal as *"Windows Update services restore +incomplete"*. A service it does not own that changes state is reported as +information. Only a service this run stopped can make the restore fail. + Compiled artifacts are **machine-only**: they are read by the PowerShell host, never by a human or an AI reviewer, so they must stay as compact as possible. The build therefore never reformats them — no blank-line injection, no comment restoration, no re-reading of diff --git a/.github/tests/StartModules/StartModules.Launcher.Tests.ps1 b/.github/tests/StartModules/StartModules.Launcher.Tests.ps1 new file mode 100644 index 00000000..86ca2527 --- /dev/null +++ b/.github/tests/StartModules/StartModules.Launcher.Tests.ps1 @@ -0,0 +1,278 @@ +# WinToolkit CI/CD V4.2.0 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# +Launcher contract and supported-configuration gate. + +The defect this suite pins down: a standard account that elevated itself with +administrator credentials received every personalized setting - PowerShell +profile, Oh My Posh theme, Windows Terminal settings, desktop shortcut - in the +ADMINISTRATOR profile, and the log reported every step as successful. After the +reboot the machine looked untouched. + +The launcher now refuses that configuration before touching anything, and +reports it on a dedicated result code. +#> + +BeforeAll { + $script:RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..') + $moduleRoot = Join-Path $script:RepoRoot 'start-modules' + foreach ($file in (Get-ChildItem -Path $moduleRoot -Filter '*.ps1' | Sort-Object Name)) { + # Skip interactive entry point + if ($file.Name -eq '90-Skeleton.Main.ps1') { continue } + . $file.FullName + } + + # The launcher is a script, not a module: its functions are extracted through + # the AST so the gate logic can be unit tested without executing the launcher. + $script:launcherPath = Join-Path $script:RepoRoot 'start.ps1' + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($script:launcherPath, [ref]$null, [ref]$parseErrors) + $parseErrors | Should -BeNullOrEmpty + $launcherFunctions = $ast.FindAll( + { param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, + $false) + $launcherFunctions.Count | Should -BeGreaterThan 0 + . ([scriptblock]::Create(($launcherFunctions | ForEach-Object { $_.Extent.Text }) -join "`r`n")) + + $script:State.LogFile = $null + if (-not (Test-Path Variable:Global:MsgStyles)) { + $Global:MsgStyles = @{ + Success = @{ Icon = '[OK]'; Color = 'Green' } + Warning = @{ Icon = '[WARN]'; Color = 'Yellow' } + Error = @{ Icon = '[ERR]'; Color = 'Red' } + Info = @{ Icon = '[INFO]'; Color = 'Cyan' } + } + } + $script:AppConfig.Paths.Languages = (Resolve-Path (Join-Path $script:RepoRoot 'languages')).Path + Initialize-SourceTextLocalization -LanguageCode 'en-US' +} + +Describe 'Get-AccountShortName' { + It 'strips the domain or host prefix' { + Get-AccountShortName -AccountName 'HOST\User' | Should -Be 'User' + } + + It 'leaves a bare name untouched' { + Get-AccountShortName -AccountName 'User' | Should -Be 'User' + } + + It 'keeps only the last segment of a deep prefix' { + Get-AccountShortName -AccountName 'A\B\C' | Should -Be 'C' + } + + It 'returns an empty string for an empty input' { + Get-AccountShortName -AccountName '' | Should -Be '' + Get-AccountShortName -AccountName $null | Should -Be '' + } +} + +Describe 'Get-InteractiveUserSupport' { + It 'reports a complete context on the machine running the suite' { + # No mocks: this is the reporting contract, whatever the verdict is here. + $support = Get-InteractiveUserSupport + $support.CurrentUser | Should -Not -BeNullOrEmpty + $support.InteractiveUser | Should -Not -BeNullOrEmpty + $support.InteractiveIsAdmin | Should -BeOfType [bool] + $support.SameAccount | Should -BeOfType [bool] + $support.Supported | Should -BeOfType [bool] + } + + It 'names the reason when the configuration is not supported' { + $support = Get-InteractiveUserSupport + if ($support.Supported) { + $support.Reason | Should -BeNullOrEmpty + } + else { + $support.Reason | Should -BeIn @('NotLocalAdministrator', 'AccountMismatch') + } + } +} + +Describe 'start.ps1 contract' { + It 'parses and stays compatible with Windows PowerShell 5.1 (ASCII only)' { + $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($script:launcherPath, [ref]$null, [ref]$parseErrors) + $parseErrors.Count | Should -Be 0 + + # The launcher runs under Windows PowerShell 5.1 before the elevation, so + # a single non-ASCII byte can break the file it is embedded in. + $bytes = [System.IO.File]::ReadAllBytes($script:launcherPath) + @($bytes | Where-Object { $_ -gt 127 }).Count | Should -Be 0 + } + + It 'places the gate before the first side effect of the file' { + $content = Get-Content -Raw -LiteralPath $script:launcherPath + + # Order is the whole point: on an unsupported machine the gate must run + # before Defender is touched and before PowerShell 7 is installed. + $gateIndex = $content.IndexOf('Write-InteractiveUserSupport -Support $support') + $defenderIndex = $content.IndexOf("Request-DefenderPause`r`n") + $gateIndex | Should -BeGreaterThan 0 + $defenderIndex | Should -BeGreaterThan $gateIndex + } + + It 'exposes a dedicated result code for an unsupported interactive user' { + $content = Get-Content -Raw -LiteralPath $script:launcherPath + $content | Should -Match 'UnsupportedInteractiveUser\s*=\s*\d+' + $content | Should -Match '\[WinToolkit\] result=\{0\} reason=\{1\}' + } + + It 'keeps a version check for an already installed PowerShell 7' { + $content = Get-Content -Raw -LiteralPath $script:launcherPath + $content | Should -Match 'function\s+Sync-PowerShellVersion' + $content | Should -Match "'upgrade',\s*'--id',\s*'Microsoft\.PowerShell'" + + # A PowerShell 7 that is merely present must not be accepted as final: + # that is what left the "A new PowerShell stable release is available" + # banner on every session. + $content | Should -Not -Match 'if\s*\(-not\s+\$pwsh\)\s*\{\s*\$pwsh\s*=\s*Install-Pwsh\s*\}' + } + + It 'prints the identity context and exits without installing (diagnostics)' { + $output = & (Get-Process -Id $PID).Path -NoLogo -NoProfile -File $script:launcherPath -PrintContext 2>&1 + $LASTEXITCODE | Should -Be 0 + + $text = ($output | Out-String) + $text | Should -Match '\[WinToolkit\] Signed-in user\s*:' + $text | Should -Match '\[WinToolkit\] Running as\s*:' + $text | Should -Match '\[WinToolkit\] Supported\s*:' + $text | Should -Match '\[WinToolkit\] result=\d+' + } +} + +Describe 'launcher diagnostic log and gate pause' { + + BeforeAll { + # The launcher is a script, not a module, and the file-level BeforeAll + # dot-sources only its FUNCTION definitions. The log writer reads two + # top-level variables of the launcher, so their real assignments are + # extracted from the file and evaluated here: the test then runs against + # the values that actually ship, not against copies kept in sync by hand. + $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile($script:launcherPath, [ref]$null, [ref]$null) + foreach ($assignment in $launcherAst.FindAll( + { param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + @('$CoreScriptUrl', '$script:LauncherResultCodes') -contains $node.Left.Extent.Text + }, $true)) { + . ([scriptblock]::Create($assignment.Extent.Text)) + } + $script:LauncherResultCodes | Should -Not -BeNullOrEmpty + $CoreScriptUrl | Should -Not -BeNullOrEmpty + } + + BeforeEach { + $script:logDir = Join-Path $env:TEMP ('wt-launcher-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -Path $script:logDir -ItemType Directory -Force + } + + AfterEach { + Remove-Item -LiteralPath $script:logDir -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'Get-LauncherLogPath returns a .log path under the WinToolkit logs folder' { + $path = Get-LauncherLogPath + $path | Should -Not -BeNullOrEmpty + $path | Should -BeLike '*\WinToolkit\logs\WinToolkitLauncher_*.log' + $path | Should -BeLike '*_*_*.log' + } + + It 'Get-LauncherLogPath creates the log folder when it is missing' { + $expectedDir = Join-Path $env:LOCALAPPDATA 'WinToolkit\logs' + $existed = Test-Path -LiteralPath $expectedDir + try { + if ($existed) { Remove-Item -LiteralPath $expectedDir -Recurse -Force -ErrorAction SilentlyContinue } + + $path = Get-LauncherLogPath + $path | Should -Not -BeNullOrEmpty + Test-Path -LiteralPath $expectedDir | Should -BeTrue + } + finally { + if ($existed) { $null = New-Item -Path $expectedDir -ItemType Directory -Force -ErrorAction SilentlyContinue } + } + } + + It 'Write-LauncherLog appends the lines to the file' { + $file = Join-Path $script:logDir 'probe.log' + Write-LauncherLog -Path $file -Line @('first line', 'second line') + + $content = Get-Content -LiteralPath $file + $content | Should -Contain 'first line' + $content | Should -Contain 'second line' + } + + It 'Write-LauncherLog is a no-op and never throws on an unwritable path' { + { Write-LauncherLog -Path (Join-Path $script:logDir 'missing\dir\probe.log') -Line @('x') } | + Should -Not -Throw + # An empty path is the "log unavailable" contract, not an error. + { Write-LauncherLog -Path $null -Line @('x') } | Should -Not -Throw + { Write-LauncherLog -Path (Join-Path $script:logDir 'probe.log') -Line @() } | Should -Not -Throw + } + + It 'the log header records the identity context and the executed artifact' { + $file = Join-Path $script:logDir 'header.log' + $support = Get-InteractiveUserSupport + Write-LauncherLogHeader -Support $support -Path $file + + $content = (Get-Content -LiteralPath $file) -join "`n" + $content | Should -Match '\[START WINTOOLKIT LAUNCHER\]' + $content | Should -Match 'SignedInUser\s*:' + $content | Should -Match 'RunningAs\s*:' + $content | Should -Match 'LocalAdmin\s*:' + $content | Should -Match 'Supported\s*:' + # The URL is what makes "the user ran an artifact I did not build" visible. + $content | Should -Match 'CoreUrl\s*:.*start-core\.ps1' + } + + It 'the gate appends the refusal reason and the result code to the log' { + $file = Join-Path $script:logDir 'gate.log' + $refused = [pscustomobject]@{ + CurrentUser = 'HOST\Someone' + InteractiveUser = 'HOST\User' + InteractiveIsAdmin = $false + SameAccount = $true + Supported = $false + Reason = 'NotLocalAdministrator' + } + + # The gate ends with exit, so only the log writer is exercised here: the + # refusal must be recorded even though the process is about to terminate. + Write-LauncherLog -Path $file -Line @( + '[GATE] Refused: unsupported configuration. Nothing has been installed.' + ('[GATE] Reason : {0}' -f $refused.Reason) + ('[GATE] result : {0}' -f $script:LauncherResultCodes.UnsupportedInteractiveUser) + ) + + $content = (Get-Content -LiteralPath $file) -join "`n" + $content | Should -Match 'NotLocalAdministrator' + $content | Should -Match "result\s*:\s*$($script:LauncherResultCodes.UnsupportedInteractiveUser)" + } + + It 'the gate waits for a keypress before exiting, and only on an interactive console' { + $content = Get-Content -Raw -LiteralPath $script:launcherPath + + # The pause must be guarded by IsInputRedirected, exactly like the Defender + # prompt: in CI, a scheduled task or irm|iex there is no console to hold + # open, and an unguarded Read-Host would hang the run forever. + $content | Should -Match 'if\s*\(-not\s*\[Console\]::IsInputRedirected\)\s*\{\s*\r?\n\s*\$null\s*=\s*Read-Host' + $content | Should -Match "Read-Host -Prompt 'Premi INVIO per chiudere'" + + # ... and it must be before the exit, otherwise the window still closes. + $pauseIndex = $content.IndexOf('Premi INVIO per chiudere') + $exitIndex = $content.LastIndexOf('exit 0') + $pauseIndex | Should -BeGreaterThan 0 + $exitIndex | Should -BeGreaterThan $pauseIndex + } + + It 'the launcher creates the log before the gate runs' { + $content = Get-Content -Raw -LiteralPath $script:launcherPath + + # A refused run is the case that leaves no other evidence, so the log has + # to exist before the gate can decide to stop. + $logIndex = $content.IndexOf('$launcherLogPath = Get-LauncherLogPath') + $gateIndex = $content.IndexOf('Write-InteractiveUserSupport -Support $support -LogPath $launcherLogPath') + $logIndex | Should -BeGreaterThan 0 + $gateIndex | Should -BeGreaterThan $logIndex + } +} diff --git a/.github/tests/StartModules/StartModules.Services.Tests.ps1 b/.github/tests/StartModules/StartModules.Services.Tests.ps1 new file mode 100644 index 00000000..1823c39c --- /dev/null +++ b/.github/tests/StartModules/StartModules.Services.Tests.ps1 @@ -0,0 +1,241 @@ +# WinToolkit CI/CD V4.2.0 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +<# +Windows Update service scope: only the services in their STANDARD ACTIVE state +(running with the automatic startup type) are suspended and restored. + +The defect this suite pins down: the restore re-applied the recorded state to +every service in the snapshot, including the ones it had never stopped. A +service the administrator had already stopped - and that the system started on +its own during the run - was therefore stopped again, and the refusal was +reported as "Windows Update services restore incomplete". + +The Get-Service stub distinguishes the snapshot call (an array of names) from +the verification call (a single name), so the suspend path can be exercised +end to end without a stateful mock. +#> + +BeforeAll { + $script:RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..') + $moduleRoot = Join-Path $script:RepoRoot 'start-modules' + foreach ($file in (Get-ChildItem -Path $moduleRoot -Filter '*.ps1' | Sort-Object Name)) { + # Skip interactive entry point + if ($file.Name -eq '90-Skeleton.Main.ps1') { continue } + . $file.FullName + } + + $script:State.LogFile = $null + if (-not (Test-Path Variable:Global:MsgStyles)) { + $Global:MsgStyles = @{ + Success = @{ Icon = '[OK]'; Color = 'Green' } + Warning = @{ Icon = '[WARN]'; Color = 'Yellow' } + Error = @{ Icon = '[ERR]'; Color = 'Red' } + Info = @{ Icon = '[INFO]'; Color = 'Cyan' } + } + } + $script:AppConfig.Paths.Languages = (Resolve-Path (Join-Path $script:RepoRoot 'languages')).Path + Initialize-SourceTextLocalization -LanguageCode 'en-US' + + # Records what the code under test decided, in the same shape the script + # uses for the services it manages. + $global:Stopped = @() + $global:Started = @() + $global:StartupTypeWrites = @() +} + +Describe 'Windows Update services - suspension scope' { + BeforeEach { + $script:StatusRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('wt-services-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $script:StatusRoot -Force + $script:AppConfig.Paths.WinToolkitDir = $script:StatusRoot + $script:State.LogFile = $null + $global:Stopped = @() + $global:Started = @() + $global:StartupTypeWrites = @() + + Mock Stop-Service { $global:Stopped += $Name } -ParameterFilter { $true } + Mock Start-Service { $global:Started += $Name } -ParameterFilter { $true } + Mock Set-Service { $global:StartupTypeWrites += "$Name=$StartupType" } -ParameterFilter { $true } + } + + AfterEach { + Remove-Item -LiteralPath $script:StatusRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'suspends only the services in their standard active state' { + # wuauserv is in the factory state and conflicts with WinGet; bits was + # stopped beforehand and must not be touched. + Mock Get-Service { + param($Name) + $names = @($Name) + if ($names.Count -gt 1) { + return @( + [pscustomobject]@{ Name = 'wuauserv'; Status = 'Running'; StartType = 'Automatic' } + [pscustomobject]@{ Name = 'bits'; Status = 'Stopped'; StartType = 'Automatic' } + ) + } + # Verification call right after a stop: the service is down. + return [pscustomobject]@{ Name = $names[0]; Status = 'Stopped'; StartType = 'Automatic' } + } -ParameterFilter { $true } + + Invoke-StopUpdateServices + + $global:Stopped | Should -Be @('wuauserv') + } + + It 'leaves a manually started service alone' { + # Running but not automatic is not a standard active state: not suspended, + # and its startup type is never rewritten. + Mock Get-Service { + @( + [pscustomobject]@{ Name = 'wuauserv'; Status = 'Running'; StartType = 'Manual' } + [pscustomobject]@{ Name = 'bits'; Status = 'Running'; StartType = 'Manual' } + ) + } -ParameterFilter { $true } + + Invoke-StopUpdateServices + + $global:Stopped | Should -BeNullOrEmpty + } + + It 'leaves a disabled service alone' { + Mock Get-Service { + param($Name) + $names = @($Name) + if ($names.Count -gt 1) { + return @( + [pscustomobject]@{ Name = 'wuauserv'; Status = 'Running'; StartType = 'Automatic' } + [pscustomobject]@{ Name = 'bits'; Status = 'Stopped'; StartType = 'Disabled' } + ) + } + return [pscustomobject]@{ Name = $names[0]; Status = 'Stopped'; StartType = 'Automatic' } + } -ParameterFilter { $true } + + Invoke-StopUpdateServices + + $global:Stopped | Should -Be @('wuauserv') + $global:StartupTypeWrites | Should -BeNullOrEmpty + } + + + It 'records the decision so a later restore knows what it owns' { + Mock Get-Service { + param($Name) + $names = @($Name) + if ($names.Count -gt 1) { + return @( + [pscustomobject]@{ Name = 'wuauserv'; Status = 'Running'; StartType = 'Automatic' } + [pscustomobject]@{ Name = 'bits'; Status = 'Stopped'; StartType = 'Automatic' } + ) + } + return [pscustomobject]@{ Name = $names[0]; Status = 'Stopped'; StartType = 'Automatic' } + } -ParameterFilter { $true } + + Invoke-StopUpdateServices + + $status = Read-UpdateServicesStatus + $status | Should -Not -BeNullOrEmpty + ($status.Services | Where-Object { $_.Name -eq 'wuauserv' }).InScope | Should -BeTrue + ($status.Services | Where-Object { $_.Name -eq 'bits' }).InScope | Should -BeFalse + } + + It 'suspends nothing when no service is in a standard active state' { + Mock Get-Service { + @( + [pscustomobject]@{ Name = 'wuauserv'; Status = 'Stopped'; StartType = 'Automatic' } + [pscustomobject]@{ Name = 'bits'; Status = 'Stopped'; StartType = 'Disabled' } + ) + } -ParameterFilter { $true } + + Invoke-StopUpdateServices + + $global:Stopped | Should -BeNullOrEmpty + # Nothing pending: the restore must find the run already completed. + (Read-UpdateServicesStatus).State | Should -Be 'Restored' + } +} + +Describe 'Invoke-StartUpdateServices - services it does not own' { + BeforeEach { + $script:StatusRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('wt-services-' + [guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $script:StatusRoot -Force + $script:AppConfig.Paths.WinToolkitDir = $script:StatusRoot + $script:State.LogFile = $null + $global:Stopped = @() + $global:Started = @() + $global:StartupTypeWrites = @() + + Mock Stop-Service { $global:Stopped += $Name } -ParameterFilter { $true } + Mock Start-Service { $global:Started += $Name } -ParameterFilter { $true } + Mock Set-Service { $global:StartupTypeWrites += "$Name=$StartupType" } -ParameterFilter { $true } + Mock Get-Service { + param($Name) + $requested = [string]@($Name)[0] + if ($requested -eq 'bits') { + # Started by the system while the run was in progress. + return [pscustomobject]@{ Name = 'bits'; Status = 'Running'; StartType = 'Automatic' } + } + return [pscustomobject]@{ Name = $requested; Status = 'Stopped'; StartType = 'Automatic' } + } -ParameterFilter { $true } + } + + AfterEach { + Remove-Item -LiteralPath $script:StatusRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'never stops a service it did not suspend (the bits defect)' { + Write-UpdateServicesStatus -Status @{ + Version = 2 + State = 'Suspended' + LastError = $null + Services = @( + [pscustomobject]@{ Name = 'wuauserv'; Status = 'Running'; StartType = 'Automatic'; InScope = $true } + [pscustomobject]@{ Name = 'bits'; Status = 'Stopped'; StartType = 'Automatic'; InScope = $false } + ) + CreatedUtc = [DateTime]::UtcNow.ToString('o') + } + + $result = Invoke-StartUpdateServices + + $result | Should -BeTrue + $global:Stopped | Should -BeNullOrEmpty + $global:Started | Should -Be @('wuauserv') + $global:StartupTypeWrites | Should -Be @('wuauserv=Automatic') + (Read-UpdateServicesStatus).State | Should -Be 'Restored' + } + + It 'recomputes the scope for a payload written before InScope existed' { + # Version 1 payloads have no InScope: the decision is derived from the + # recorded state, and the old CIM StartMode name is normalized. + Write-UpdateServicesStatus -Status @{ + Version = 1 + State = 'Suspended' + LastError = $null + Services = @( + [pscustomobject]@{ Name = 'wuauserv'; Status = 'Running'; StartType = 'Auto' } + [pscustomobject]@{ Name = 'bits'; Status = 'Stopped'; StartType = 'Auto' } + ) + CreatedUtc = [DateTime]::UtcNow.ToString('o') + } + + $result = Invoke-StartUpdateServices + + $result | Should -BeTrue + $global:Stopped | Should -BeNullOrEmpty + $global:Started | Should -Be @('wuauserv') + $global:StartupTypeWrites | Should -Be @('wuauserv=Automatic') + } +} + +Describe 'Test-ToolkitPathBelongsToInteractiveUser' { + It 'accepts a path inside the current profile' { + Test-ToolkitPathBelongsToInteractiveUser -Path (Join-Path $env:USERPROFILE 'Documents\PowerShell') | Should -BeTrue + } + + It 'reports a path that belongs to another account' { + # The exact failure: the Documents folder of the administrator profile is + # resolved and written while the standard user is signed in. + Test-ToolkitPathBelongsToInteractiveUser -Path 'C:\Users\SomeoneElse\Documents\PowerShell' | Should -BeFalse + } +} diff --git a/README.md b/README.md index f6d4694b..c78f9bbe 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ WinToolkit is a powerful and compact suite of PowerShell scripts, inspired by th > > - **Internet connection**; > - **free disk space**: >= 50 GB [(see the FAQ section)](.github/Docs/FAQ.md); -> - **Windows >= 10 (1809)**. +> - **Windows >= 10 (1809)**; +> - **Local administrator account**: `start.ps1` personalizes the account that runs it, so it must be started from the account signed in to Windows. If the signed-in account is a standard one, the script stops before installing anything instead of writing every setting into the administrator profile. | Windows Versions | Supported | | :-------------------- | :----------- | @@ -166,12 +167,13 @@ Thank you from the heart for your support! --- + ## 👥 Top 10 Contributors -| Rank | Contributor | Commits | PRs | -| :--- | :--- | :--- | :--- | -| 1 | Magnetarman [Magnetarman](https://github.com/magnetarman) | 2534 | 69 | -| 2 | pomodori92 [pomodori92](https://github.com/pomodori92) | 26 | 26 | +| Rank | Contributor | Commits | PRs | +| :--- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ | :-- | +| 1 | Magnetarman [Magnetarman](https://github.com/magnetarman) | 2534 | 69 | +| 2 | pomodori92 [pomodori92](https://github.com/pomodori92) | 26 | 26 | diff --git a/start-core.ps1 b/start-core.ps1 index d0031d63..d22df970 100644 --- a/start-core.ps1 +++ b/start-core.ps1 @@ -4,7 +4,7 @@ param( ) Set-StrictMode -Version Latest $script:Branch = 'Dev' -$ToolkitVersion = "2.6.0 (Build 5)" +$ToolkitVersion = "Work In Progress" $GitHubRepoRawBase = @{ Dev = "https://raw.githubusercontent.com/Magnetarman/WinToolkit/refs/heads/Dev" main = "https://raw.githubusercontent.com/Magnetarman/WinToolkit/refs/heads/main" @@ -648,29 +648,39 @@ function Invoke-StopUpdateServices { $savedServices = @( Get-Service -Name $script:AppConfig.UpdateServices -ErrorAction SilentlyContinue | ForEach-Object { + $startupType = [string]$_.StartType + if ($startupType -eq 'Auto') { $startupType = 'Automatic' } [pscustomobject]@{ Name = $_.Name Status = [string]$_.Status - StartType = [string]$_.StartType + StartType = $startupType + InScope = ([string]$_.Status -eq 'Running' -and $startupType -eq 'Automatic') } } ) + $inScope = @($savedServices | Where-Object { $_.InScope }) + foreach ($outOfScope in @($savedServices | Where-Object { -not $_.InScope })) { + Write-ToolkitLog -Level 'INFO' -Message "Service $($outOfScope.Name) left untouched: $($outOfScope.Status)/$($outOfScope.StartType) (not a standard active state)." + } $status = @{ - Version = 1 + Version = 2 State = 'Suspending' LastError = $null Services = $savedServices CreatedUtc = [DateTime]::UtcNow.ToString('o') } Write-UpdateServicesStatus -Status $status + if ($inScope.Count -eq 0) { + Write-ToolkitLog -Level 'INFO' -Message 'No Windows Update service is in a standard active state: nothing to suspend.' + Set-UpdateServicesState -Status $status -State 'Restored' + return + } try { - foreach ($saved in $savedServices) { - if ($saved.Status -ne 'Stopped') { - Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.serviceStop0' -Args @($saved.Name)) - Stop-Service -Name $saved.Name -Force -ErrorAction Stop - $current = Get-Service -Name $saved.Name -ErrorAction Stop - if ($current.Status -ne 'Stopped') { throw "Service $($saved.Name) did not stop." } - } + foreach ($saved in $inScope) { + Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.serviceStop0' -Args @($saved.Name)) + Stop-Service -Name $saved.Name -Force -ErrorAction Stop + $current = Get-Service -Name $saved.Name -ErrorAction Stop + if ($current.Status -ne 'Stopped') { throw "Service $($saved.Name) did not stop." } } Set-UpdateServicesState -Status $status -State 'Suspended' Write-StyledMessage -Type Success -Text (Get-SourceTextLoc 'uiText.updateServicesSuccessfullySuspended') @@ -686,44 +696,53 @@ function Invoke-StartUpdateServices { if (-not $status -or $status.State -eq 'Restored') { return $true } Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.resettingWindowsUpdateServices') $restoreErrors = @() + $unmanagedNotes = @() + $restored = 0 foreach ($saved in @($status.Services)) { + $startupType = switch ($saved.StartType) { + 'Auto' { 'Automatic' } + 'Disabled' { 'Disabled' } + default { $saved.StartType } + } + $inScope = if ($saved.PSObject.Properties.Name -contains 'InScope') { + [bool]$saved.InScope + } + else { + ([string]$saved.Status -eq 'Running' -and $startupType -eq 'Automatic') + } + if (-not $inScope) { + $observed = Get-Service -Name $saved.Name -ErrorAction SilentlyContinue + if ($observed -and [string]$observed.Status -ne [string]$saved.Status) { + $unmanagedNotes += "$($saved.Name) is now $($observed.Status), it was $($saved.Status) and was not managed by this run" + } + continue + } + $restored++ try { $service = Get-Service -Name $saved.Name -ErrorAction Stop - $startupType = switch ($saved.StartType) { - 'Auto' { 'Automatic' } - 'Disabled' { 'Disabled' } - default { $saved.StartType } - } Set-Service -Name $saved.Name -StartupType $startupType -ErrorAction Stop if ($saved.Status -eq 'Running' -and $service.Status -ne 'Running') { Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.startingService0' -Args @($saved.Name)) Start-Service -Name $saved.Name -ErrorAction Stop } - elseif ($saved.Status -eq 'Stopped' -and $service.Status -ne 'Stopped') { - Stop-Service -Name $saved.Name -Force -ErrorAction Stop - } } catch { $restoreErrors += "$($saved.Name): $($_.Exception.Message)" } } + foreach ($note in $unmanagedNotes) { + Write-ToolkitLog -Level 'INFO' -Message "Service not managed by this run: $note." + } if ($restoreErrors.Count -gt 0) { - $dosvcErrors = @($restoreErrors | Where-Object { $_ -match '^dosvc:' }) - $otherErrors = @($restoreErrors | Where-Object { $_ -notmatch '^dosvc:' }) - if ($otherErrors.Count -gt 0) { - Set-UpdateServicesState -Status $status -State 'RestoreFailed' -LastError ($otherErrors -join '; ') - Write-ToolkitLog -Level 'ERROR' -Message "Unable to restore Windows Update services: $($status.LastError)" - Write-StyledMessage -Type Error -Text (Get-SourceTextLoc 'uiText.updateServicesRestoreIncomplete0' -Args @($status.LastError)) - return $false - } - if ($dosvcErrors.Count -gt 0) { - Set-UpdateServicesState -Status $status -State 'Restored' - $dosvcDetail = $dosvcErrors -join '; ' - Write-ToolkitLog -Level 'WARNING' -Message "Windows Update service dosvc could not be restored (known Windows limitation): $dosvcDetail" - Write-StyledMessage -Type Warning -Text (Get-SourceTextLoc 'uiText.dosvcNotRestoredKnownLimitation') - } + Set-UpdateServicesState -Status $status -State 'RestoreFailed' -LastError ($restoreErrors -join '; ') + Write-ToolkitLog -Level 'ERROR' -Message "Unable to restore Windows Update services: $($status.LastError)" + Write-StyledMessage -Type Error -Text (Get-SourceTextLoc 'uiText.updateServicesRestoreIncomplete0' -Args @($status.LastError)) + return $false } Set-UpdateServicesState -Status $status -State 'Restored' + if ($restored -eq 0) { + Write-ToolkitLog -Level 'INFO' -Message 'No suspended Windows Update service to restore.' + } Write-StyledMessage -Type Success -Text (Get-SourceTextLoc 'uiText.updateServicesRestored') return $true } @@ -1721,6 +1740,9 @@ function Install-PspEnvironment { try { $paths = Resolve-ToolkitPowerShellProfileDirectory Write-ToolkitLog -Level 'INFO' -Message "PowerShell profile directory resolved: $($paths.ProfileDirectory)" + if (-not (Test-ToolkitPathBelongsToInteractiveUser -Path $paths.ProfileDirectory)) { + Write-StyledMessage -Type Warning -Text (Get-SourceTextLoc 'uiText.elevationSwitchedAccount0' -Args @($context.OriginalUser, $context.CurrentUser)) + } } catch { Write-StyledMessage -Type Error -Text (Get-SourceTextLoc 'uiText.profileDirectoryUnavailable0' -Args @($_.Exception.Message)) @@ -1815,6 +1837,9 @@ function New-ToolkitDesktopShortcut { Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.desktopShortcutCreation') try { $desktop = Get-ToolkitUserFolderPath -Kind 'Desktop' + if (-not (Test-ToolkitPathBelongsToInteractiveUser -Path $desktop)) { + Write-ToolkitLog -Level 'WARNING' -Message "Desktop shortcut target belongs to another account: $desktop" + } $shortcut = Join-Path $desktop "Win Toolkit.lnk" $iconDir = $script:AppConfig.Paths.WinToolkitDir $icon = Join-Path $iconDir "WinToolkit.ico" @@ -1957,6 +1982,31 @@ function Get-ToolkitUserFolderPath { } throw "Unable to resolve a usable '$Kind' known folder for user '$($context.CurrentUser)'." } +function Test-ToolkitPathBelongsToInteractiveUser { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + $context = Get-ToolkitOriginalUserContext + $root = if ($context.AccountSwitched -and $context.UserProfile) { $context.UserProfile } else { $env:USERPROFILE } + if (-not $root) { + return $true + } + try { + $fullPath = [System.IO.Path]::GetFullPath($Path) + $fullRoot = [System.IO.Path]::GetFullPath($root).TrimEnd('\') + '\' + } + catch { + Write-ToolkitLog -Level 'WARNING' -Message "Could not compare '$Path' with the user profile: $($_.Exception.Message)" + return $true + } + $belongs = $fullPath.StartsWith($fullRoot, [System.StringComparison]::OrdinalIgnoreCase) + if (-not $belongs) { + Write-ToolkitLog -Level 'WARNING' -Message "Resolved path '$fullPath' is outside the signed-in profile '$fullRoot'." + } + return $belongs +} function Test-FileHasMinimumSize { param( [Parameter(Mandatory = $true)][string]$Path, @@ -2451,6 +2501,8 @@ function Invoke-WinToolkitSetup { Show-Header -Title $script:AppConfig.Header.Title -Version $script:AppConfig.Header.Version Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.powershell0' -Args @($PSVersionTable.PSVersion)) Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.startingWinToolkitSetup') + $identityContext = Get-ToolkitOriginalUserContext + Write-ToolkitLog -Level 'INFO' -Message "Identity: signed-in '$($identityContext.OriginalUser)' | running as '$($identityContext.CurrentUser)' | account switched: $($identityContext.AccountSwitched)." $null = Request-DefenderPause $steps = @( New-SetupStep -Name 'System clock' -Run { Repair-SystemClock } diff --git a/start-modules/30-Module.Environment.ps1 b/start-modules/30-Module.Environment.ps1 index ed09f887..9604cccc 100644 --- a/start-modules/30-Module.Environment.ps1 +++ b/start-modules/30-Module.Environment.ps1 @@ -464,6 +464,14 @@ function Invoke-StopUpdateServices { <# .SYNOPSIS Temporarily suspends Windows Update and related services to avoid conflicts with Winget. + + .DESCRIPTION + Only the services in their STANDARD ACTIVE state (running, with the automatic + startup type) are suspended: those are the ones that conflict with the AppX + and WinGet installs. A service the administrator already disabled, stopped or + set to manual is left completely untouched, here and at restore time, and the + snapshot records that decision (InScope) so a recovery after a crash restores + exactly what was touched, and nothing else. #> [CmdletBinding(SupportsShouldProcess)] param() @@ -477,16 +485,27 @@ function Invoke-StopUpdateServices { $savedServices = @( Get-Service -Name $script:AppConfig.UpdateServices -ErrorAction SilentlyContinue | ForEach-Object { + # Payloads written by older versions stored the CIM StartMode name. + $startupType = [string]$_.StartType + if ($startupType -eq 'Auto') { $startupType = 'Automatic' } [pscustomobject]@{ Name = $_.Name Status = [string]$_.Status - StartType = [string]$_.StartType + StartType = $startupType + InScope = ([string]$_.Status -eq 'Running' -and $startupType -eq 'Automatic') } } ) + $inScope = @($savedServices | Where-Object { $_.InScope }) + foreach ($outOfScope in @($savedServices | Where-Object { -not $_.InScope })) { + # The administrator's own choice, recorded so the restore leaves it alone + # too. A service left in a non-standard state is not a failure. + Write-ToolkitLog -Level 'INFO' -Message "Service $($outOfScope.Name) left untouched: $($outOfScope.Status)/$($outOfScope.StartType) (not a standard active state)." + } + $status = @{ - Version = 1 + Version = 2 State = 'Suspending' LastError = $null Services = $savedServices @@ -494,14 +513,19 @@ function Invoke-StopUpdateServices { } Write-UpdateServicesStatus -Status $status + if ($inScope.Count -eq 0) { + # Nothing to suspend: the run must not leave a pending restore behind. + Write-ToolkitLog -Level 'INFO' -Message 'No Windows Update service is in a standard active state: nothing to suspend.' + Set-UpdateServicesState -Status $status -State 'Restored' + return + } + try { - foreach ($saved in $savedServices) { - if ($saved.Status -ne 'Stopped') { - Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.serviceStop0' -Args @($saved.Name)) - Stop-Service -Name $saved.Name -Force -ErrorAction Stop - $current = Get-Service -Name $saved.Name -ErrorAction Stop - if ($current.Status -ne 'Stopped') { throw "Service $($saved.Name) did not stop." } - } + foreach ($saved in $inScope) { + Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.serviceStop0' -Args @($saved.Name)) + Stop-Service -Name $saved.Name -Force -ErrorAction Stop + $current = Get-Service -Name $saved.Name -ErrorAction Stop + if ($current.Status -ne 'Stopped') { throw "Service $($saved.Name) did not stop." } } Set-UpdateServicesState -Status $status -State 'Suspended' Write-StyledMessage -Type Success -Text (Get-SourceTextLoc 'uiText.updateServicesSuccessfullySuspended') @@ -516,7 +540,7 @@ function Invoke-StopUpdateServices { function Invoke-StartUpdateServices { <# .SYNOPSIS - Restores Windows Update and related services. + Restores the Windows Update services this run suspended. .DESCRIPTION The startup type is restored from what Get-Service reported (PS7 exposes @@ -532,53 +556,71 @@ function Invoke-StartUpdateServices { Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.resettingWindowsUpdateServices') $restoreErrors = @() + $unmanagedNotes = @() + $restored = 0 + foreach ($saved in @($status.Services)) { + # Unknown/older payloads stored the CIM StartMode name instead. + $startupType = switch ($saved.StartType) { + 'Auto' { 'Automatic' } + 'Disabled' { 'Disabled' } + default { $saved.StartType } + } + + # A payload written before InScope existed carries no decision: it is + # recomputed from the recorded state, which is what the old code acted on. + $inScope = if ($saved.PSObject.Properties.Name -contains 'InScope') { + [bool]$saved.InScope + } + else { + ([string]$saved.Status -eq 'Running' -and $startupType -eq 'Automatic') + } + + # Not managed by this run: never written, only observed. The old code + # re-applied the recorded state here, which is how a service the system + # had started on its own ended up being stopped again, and reported as a + # failed restore. + if (-not $inScope) { + $observed = Get-Service -Name $saved.Name -ErrorAction SilentlyContinue + if ($observed -and [string]$observed.Status -ne [string]$saved.Status) { + $unmanagedNotes += "$($saved.Name) is now $($observed.Status), it was $($saved.Status) and was not managed by this run" + } + continue + } + + $restored++ try { $service = Get-Service -Name $saved.Name -ErrorAction Stop - # Unknown/older payloads stored the CIM StartMode name instead. - $startupType = switch ($saved.StartType) { - 'Auto' { 'Automatic' } - 'Disabled' { 'Disabled' } - default { $saved.StartType } - } Set-Service -Name $saved.Name -StartupType $startupType -ErrorAction Stop if ($saved.Status -eq 'Running' -and $service.Status -ne 'Running') { Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.startingService0' -Args @($saved.Name)) Start-Service -Name $saved.Name -ErrorAction Stop } - elseif ($saved.Status -eq 'Stopped' -and $service.Status -ne 'Stopped') { - Stop-Service -Name $saved.Name -Force -ErrorAction Stop - } } catch { $restoreErrors += "$($saved.Name): $($_.Exception.Message)" } } - if ($restoreErrors.Count -gt 0) { - $dosvcErrors = @($restoreErrors | Where-Object { $_ -match '^dosvc:' }) - $otherErrors = @($restoreErrors | Where-Object { $_ -notmatch '^dosvc:' }) - - if ($otherErrors.Count -gt 0) { - Set-UpdateServicesState -Status $status -State 'RestoreFailed' -LastError ($otherErrors -join '; ') - Write-ToolkitLog -Level 'ERROR' -Message "Unable to restore Windows Update services: $($status.LastError)" - Write-StyledMessage -Type Error -Text (Get-SourceTextLoc 'uiText.updateServicesRestoreIncomplete0' -Args @($status.LastError)) - return $false - } + foreach ($note in $unmanagedNotes) { + Write-ToolkitLog -Level 'INFO' -Message "Service not managed by this run: $note." + } - if ($dosvcErrors.Count -gt 0) { - # dosvc refuses to start on some Windows builds: a known limitation, not a failure. - Set-UpdateServicesState -Status $status -State 'Restored' - # $dosvcErrors is an array: it must be joined INSIDE the subexpression, - # otherwise the literal "-join" text ends up in the log line. - $dosvcDetail = $dosvcErrors -join '; ' - Write-ToolkitLog -Level 'WARNING' -Message "Windows Update service dosvc could not be restored (known Windows limitation): $dosvcDetail" - Write-StyledMessage -Type Warning -Text (Get-SourceTextLoc 'uiText.dosvcNotRestoredKnownLimitation') - } + if ($restoreErrors.Count -gt 0) { + # Only a service this run actually stopped can fail here. The dosvc + # special case is gone with the reason for it: dosvc is not in + # $script:AppConfig.UpdateServices any more, so it can never be suspended. + Set-UpdateServicesState -Status $status -State 'RestoreFailed' -LastError ($restoreErrors -join '; ') + Write-ToolkitLog -Level 'ERROR' -Message "Unable to restore Windows Update services: $($status.LastError)" + Write-StyledMessage -Type Error -Text (Get-SourceTextLoc 'uiText.updateServicesRestoreIncomplete0' -Args @($status.LastError)) + return $false } Set-UpdateServicesState -Status $status -State 'Restored' + if ($restored -eq 0) { + Write-ToolkitLog -Level 'INFO' -Message 'No suspended Windows Update service to restore.' + } Write-StyledMessage -Type Success -Text (Get-SourceTextLoc 'uiText.updateServicesRestored') return $true } diff --git a/start-modules/60-Module.PspEnvironment.ps1 b/start-modules/60-Module.PspEnvironment.ps1 index a2fc5820..60748d31 100644 --- a/start-modules/60-Module.PspEnvironment.ps1 +++ b/start-modules/60-Module.PspEnvironment.ps1 @@ -182,6 +182,11 @@ function Install-PspEnvironment { try { $paths = Resolve-ToolkitPowerShellProfileDirectory Write-ToolkitLog -Level 'INFO' -Message "PowerShell profile directory resolved: $($paths.ProfileDirectory)" + # Verified BEFORE anything is written: a profile installed into another + # account looks successful in the log and is missing after the reboot. + if (-not (Test-ToolkitPathBelongsToInteractiveUser -Path $paths.ProfileDirectory)) { + Write-StyledMessage -Type Warning -Text (Get-SourceTextLoc 'uiText.elevationSwitchedAccount0' -Args @($context.OriginalUser, $context.CurrentUser)) + } } catch { Write-StyledMessage -Type Error -Text (Get-SourceTextLoc 'uiText.profileDirectoryUnavailable0' -Args @($_.Exception.Message)) diff --git a/start-modules/70-Module.Shortcut.ps1 b/start-modules/70-Module.Shortcut.ps1 index fba06222..58ce1599 100644 --- a/start-modules/70-Module.Shortcut.ps1 +++ b/start-modules/70-Module.Shortcut.ps1 @@ -40,6 +40,11 @@ function New-ToolkitDesktopShortcut { try { $desktop = Get-ToolkitUserFolderPath -Kind 'Desktop' + # The desktop is user-scoped: a shortcut created in another account's desktop + # is reported as created and never seen by the signed-in user. + if (-not (Test-ToolkitPathBelongsToInteractiveUser -Path $desktop)) { + Write-ToolkitLog -Level 'WARNING' -Message "Desktop shortcut target belongs to another account: $desktop" + } $shortcut = Join-Path $desktop "Win Toolkit.lnk" $iconDir = $script:AppConfig.Paths.WinToolkitDir $icon = Join-Path $iconDir "WinToolkit.ico" diff --git a/start-modules/80-Module.Common.ps1 b/start-modules/80-Module.Common.ps1 index 4f0d0bd8..d58f653c 100644 --- a/start-modules/80-Module.Common.ps1 +++ b/start-modules/80-Module.Common.ps1 @@ -194,6 +194,49 @@ function Get-ToolkitUserFolderPath { } +function Test-ToolkitPathBelongsToInteractiveUser { + <# + .SYNOPSIS + Returns $true when a resolved user path is inside the signed-in profile. + + .DESCRIPTION + A known folder can resolve to a path of a different account: the Documents + value of the administrator profile, a redirected or stale "User Shell + Folders" entry, a profile relocated outside C:\Users. The steps that write + there check this BEFORE writing, so a run that would personalize the wrong + profile is reported instead of quietly succeeding. It never blocks: the + answer is a warning, and the caller decides. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $context = Get-ToolkitOriginalUserContext + $root = if ($context.AccountSwitched -and $context.UserProfile) { $context.UserProfile } else { $env:USERPROFILE } + if (-not $root) { + # Nothing to compare against: report success rather than a false alarm. + return $true + } + + try { + $fullPath = [System.IO.Path]::GetFullPath($Path) + $fullRoot = [System.IO.Path]::GetFullPath($root).TrimEnd('\') + '\' + } + catch { + Write-ToolkitLog -Level 'WARNING' -Message "Could not compare '$Path' with the user profile: $($_.Exception.Message)" + return $true + } + + $belongs = $fullPath.StartsWith($fullRoot, [System.StringComparison]::OrdinalIgnoreCase) + if (-not $belongs) { + Write-ToolkitLog -Level 'WARNING' -Message "Resolved path '$fullPath' is outside the signed-in profile '$fullRoot'." + } + return $belongs +} + + function Test-FileHasMinimumSize { <# .SYNOPSIS diff --git a/start-modules/90-Skeleton.Main.ps1 b/start-modules/90-Skeleton.Main.ps1 index e27ff733..75512eb1 100644 --- a/start-modules/90-Skeleton.Main.ps1 +++ b/start-modules/90-Skeleton.Main.ps1 @@ -52,6 +52,14 @@ function Invoke-WinToolkitSetup { Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.powershell0' -Args @($PSVersionTable.PSVersion)) Write-StyledMessage -Type Info -Text (Get-SourceTextLoc 'uiText.startingWinToolkitSetup') + # The identity context is logged explicitly and FIRST. Every user-scoped + # artifact (profile, theme, terminal settings, shortcut) is written under + # the account running the process, so a log full of successes can still + # describe a machine that looks untouched after the reboot. This line is + # what makes the difference visible instead of silent. + $identityContext = Get-ToolkitOriginalUserContext + Write-ToolkitLog -Level 'INFO' -Message "Identity: signed-in '$($identityContext.OriginalUser)' | running as '$($identityContext.CurrentUser)' | account switched: $($identityContext.AccountSwitched)." + # Non-blocking: real-time protection interferes with AppX installs, but the # setup must continue if the user keeps Defender enabled. $null = Request-DefenderPause diff --git a/start.ps1 b/start.ps1 index 71224254..f85f9451 100644 --- a/start.ps1 +++ b/start.ps1 @@ -1,9 +1,22 @@ # WinToolkit launcher. Keep this file ASCII-only and compatible with Windows PowerShell 5.1. [CmdletBinding()] param( - [string]$Language = 'Auto' + [string]$Language = 'Auto', + + # Diagnostics only: print the detected identity context and exit without + # installing anything. Used by the Pester suite and by manual triage. + [switch]$PrintContext ) +# Result codes reported by the launcher. The PROCESS exit code stays 0 on an +# unsupported machine, so an automated invocation is never red for a condition +# that is not an error; the dedicated code is printed on a stable, greppable line +# ("[WinToolkit] result= reason=") and in the log. +$script:LauncherResultCodes = @{ + Ready = 0 + UnsupportedInteractiveUser = 4 +} + $CoreScriptUrl = 'https://raw.githubusercontent.com/Magnetarman/WinToolkit/refs/heads/Dev/start-core.ps1' $StubScriptUrl = 'https://raw.githubusercontent.com/Magnetarman/WinToolkit/refs/heads/Dev/start.ps1' @@ -14,6 +27,417 @@ function Test-IsAdministrator { } +function Get-AccountShortName { + <# + .SYNOPSIS + Returns the bare account name, without domain or host prefix. + + .DESCRIPTION + Win32_ComputerSystem.UserName has no prefix, WindowsIdentity.Name always has + one, and the two are compared to decide whether the account that launched the + script is the account signed in to Windows. + #> + param([string]$AccountName) + + if ([string]::IsNullOrWhiteSpace($AccountName)) { return '' } + $trimmed = $AccountName.Trim() + $index = $trimmed.LastIndexOf('\') + if ($index -ge 0) { $trimmed = $trimmed.Substring($index + 1) } + return $trimmed +} + + +function Get-AccountSid { + <# + .SYNOPSIS + Returns the SID of an account, or $null when it cannot be resolved. + #> + param([string]$AccountName) + + if ([string]::IsNullOrWhiteSpace($AccountName)) { return $null } + try { + $account = New-Object Security.Principal.NTAccount($AccountName.Trim()) + return $account.Translate([Security.Principal.SecurityIdentifier]).Value + } + catch { + return $null + } +} + + +function Get-LocalAdministratorGroupName { + <# + .SYNOPSIS + Returns the local Administrators group name, in the current system language. + + .DESCRIPTION + Get-LocalGroup -SID is the authoritative source: it returns the real local + name ("Administrators", "Amministratori", "Administratoren"...), which is what + both Get-LocalGroupMember and net.exe accept. Translating the well-known SID + alone is NOT enough: on a localized system it yields "BUILTIN\Administrators", + a name that neither of them resolves. The translation, stripped of that + prefix, and finally the literal English name are kept as fallbacks for systems + without the LocalAccounts module. + #> + try { + $group = Get-LocalGroup -SID 'S-1-5-32-544' -ErrorAction Stop + if ($group -and $group.Name) { return $group.Name } + } + catch { + Write-Verbose "Get-LocalGroup -SID failed, falling back to SID translation: $($_.Exception.Message)" + } + + try { + $sid = New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544') + $name = [string]$sid.Translate([Security.Principal.NTAccount]).Value + $index = $name.LastIndexOf('\') + if ($index -ge 0) { $name = $name.Substring($index + 1) } + if ($name) { return $name } + } + catch { + Write-Verbose "SID translation failed: $($_.Exception.Message)" + } + + # Last resort: Windows always resolves the English name of a well-known group. + return 'Administrators' +} + + +function Test-LocalAdministrator { + <# + .SYNOPSIS + Returns $true when the account is a member of the local Administrators group. + + .DESCRIPTION + Membership is compared by SID, never by name, so a display name that looks + like a group cannot produce a false positive. When the LocalAccounts module + is unavailable the check falls back to net.exe, which also expands the + domain groups that Get-LocalGroupMember does not resolve. + #> + param([string]$AccountName) + + if ([string]::IsNullOrWhiteSpace($AccountName)) { return $false } + + $targetSid = Get-AccountSid -AccountName $AccountName + $groupName = Get-LocalAdministratorGroupName + + if ($targetSid -and $groupName) { + try { + $members = @(Get-LocalGroupMember -Group $groupName -ErrorAction Stop) + foreach ($member in $members) { + if ($member.SID -and $member.SID.Value -eq $targetSid) { return $true } + } + return $false + } + catch { + Write-Verbose "Get-LocalGroupMember failed, falling back to net.exe: $($_.Exception.Message)" + } + } + + if (-not $groupName) { return $false } + + try { + $members = @(& net.exe localgroup "$groupName" 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $members) { return $false } + foreach ($line in $members) { + $candidate = ($line -replace '^\s+', '').Trim() + if (-not $candidate) { continue } + if ($candidate -match '^-{2,}\s*$') { continue } + # Header and completion lines, in the languages this script ships. + if ($candidate -match '^(Alias|Gruppo|Group|Command|Al )') { continue } + if ((Get-AccountShortName $candidate) -ieq (Get-AccountShortName $AccountName)) { return $true } + } + return $false + } + catch { + Write-Verbose "net.exe localgroup failed: $($_.Exception.Message)" + return $false + } +} + + +function Get-InteractiveUserName { + <# + .SYNOPSIS + Returns the account signed in to Windows, which is NOT necessarily the one + running this process. + + .DESCRIPTION + With UAC credentials the console session keeps the standard account while the + elevated process runs as an administrator: they are two different users, and + every personalized artifact belongs to the session owner. Falls back to the + current account when the console owner cannot be read. + #> + try { + $system = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop + $name = [string]$system.UserName + if (-not [string]::IsNullOrWhiteSpace($name)) { return $name.Trim() } + } + catch { + Write-Verbose "Win32_ComputerSystem.UserName unavailable: $($_.Exception.Message)" + } + return [Security.Principal.WindowsIdentity]::GetCurrent().Name +} + + +function Get-InteractiveUserContext { + <# + .SYNOPSIS + Returns the identity and the user-scoped paths of the signed-in account. + + .DESCRIPTION + Exported through the WTOOLKIT_ORIGINAL_* variables so that user-scoped + artifacts (Documents profile, theme, desktop shortcut) land in the account + that will use the machine. The profile root is read from Win32_UserProfile, + because it is not always C:\Users\. + #> + $user = Get-InteractiveUserName + $desktop = '' + $documents = '' + $userProfile = '' + + try { $desktop = [Environment]::GetFolderPath('Desktop', [Environment+SpecialFolderOption]::Create) } catch { } + try { $documents = [Environment]::GetFolderPath('MyDocuments', [Environment+SpecialFolderOption]::Create) } catch { } + + $sid = Get-AccountSid -AccountName $user + if ($sid) { + try { + $profile = Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$sid'" -ErrorAction Stop + if ($profile.LocalPath) { $userProfile = $profile.LocalPath } + } + catch { + Write-Verbose "Win32_UserProfile unavailable for ${user}: $($_.Exception.Message)" + } + } + if (-not $userProfile) { + # Valid because the launcher only reaches this point when the signed-in + # account is the one running the process. + $userProfile = $env:USERPROFILE + } + + return @{ + User = $user + UserProfile = $userProfile + Desktop = $desktop + MyDocuments = $documents + } +} + + +function Get-InteractiveUserSupport { + <# + .SYNOPSIS + Decides whether this account/machine combination is supported. + + .DESCRIPTION + The toolkit personalizes PowerShell, Windows Terminal and the desktop, and + every one of those is written under the account that runs the process. A + standard account elevated with administrator credentials therefore gets a log + full of successes and, after the reboot, a machine that looks untouched. The + only supported configuration is: the signed-in account IS the account running + the script, and it is a local administrator. + #> + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name + $interactiveUser = Get-InteractiveUserName + $isAdmin = Test-LocalAdministrator -AccountName $interactiveUser + $sameAccount = (Get-AccountShortName $currentUser) -ieq (Get-AccountShortName $interactiveUser) + + $reason = $null + if (-not $isAdmin) { $reason = 'NotLocalAdministrator' } + elseif (-not $sameAccount) { $reason = 'AccountMismatch' } + + return [pscustomobject]@{ + CurrentUser = $currentUser + InteractiveUser = $interactiveUser + InteractiveIsAdmin = $isAdmin + SameAccount = $sameAccount + Supported = (-not $reason) + Reason = $reason + } +} + + +function Get-LauncherLogPath { + <# + .SYNOPSIS + Returns the path of the launcher diagnostic log, or $null when it cannot be created. + + .DESCRIPTION + The file name reuses the scheme of the core logger (10-Module.Logging.ps1: + __.log) and the same folder, so a user asked to + send their logs ends up with one consistent set of files. + + The launcher runs BEFORE elevation and is the only script that still has to + work on a machine where nothing is installed yet, so every failure here is + swallowed: a log that cannot be created must never be the reason the launcher + stops working. + #> + try { + $logDir = Join-Path $env:LOCALAPPDATA 'WinToolkit\logs' + if (-not (Test-Path -LiteralPath $logDir)) { + $null = New-Item -Path $logDir -ItemType Directory -Force -ErrorAction Stop + } + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + return (Join-Path $logDir "WinToolkitLauncher_${stamp}_$PID.log") + } + catch { + Write-Verbose "Launcher log unavailable: $($_.Exception.Message)" + return $null + } +} + + +function Write-LauncherLog { + <# + .SYNOPSIS + Appends lines to the launcher log, on a best-effort basis. + + .DESCRIPTION + Never throws and never writes to the console: the log is a diagnostic aid, + and a read-only or missing folder must degrade to "no log" instead of + breaking the launcher the user is trying to run. + #> + param( + [AllowNull()][string]$Path, + [AllowEmptyCollection()][string[]]$Line + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { return } + if (-not $Line) { return } + + try { + Add-Content -LiteralPath $Path -Value $Line -Encoding UTF8 -ErrorAction Stop + } + catch { + Write-Verbose "Launcher log write failed: $($_.Exception.Message)" + } +} + + +function Write-LauncherLogHeader { + <# + .SYNOPSIS + Writes the launcher log header: the context needed to triage a failed run. + + .DESCRIPTION + Written on EVERY launcher start, on a refused run as well as on a successful + one, so the file alone answers "what ran, as whom, against which artifact". + + CoreUrl is recorded on purpose: the launcher always downloads start-core.ps1 + from the branch hardcoded at the top of this file, so a user reporting a + problem is very often running an artifact that does not match the sources + they are editing. That single line is what makes the mismatch visible. + + ASCII only: this file runs under Windows PowerShell 5.1 before elevation. + #> + param( + [Parameter(Mandatory = $true)][object]$Support, + [AllowNull()][string]$Path + ) + + $osVersion = $null + $osBuild = $null + try { + $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop + $osVersion = $os.Version + $osBuild = $os.BuildNumber + } + catch { + Write-Verbose "OS information unavailable: $($_.Exception.Message)" + } + + Write-LauncherLog -Path $Path -Line @( + '================================================================' + '[START WINTOOLKIT LAUNCHER]' + ("StartTime : {0}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) + ("Launcher : {0}" -f $PSCommandPath) + ("CoreUrl : {0}" -f $CoreScriptUrl) + ("PSVersion : {0} ({1})" -f $PSVersionTable.PSVersion, $PSVersionTable.PSEdition) + ("OS : {0} (build {1})" -f $(if ($osVersion) { $osVersion } else { 'unknown' }), + $(if ($osBuild) { $osBuild } else { 'unknown' })) + ("SignedInUser : {0}" -f $Support.InteractiveUser) + ("RunningAs : {0}" -f $Support.CurrentUser) + ("Elevated : {0}" -f (Test-IsAdministrator)) + ("SameAccount : {0}" -f $Support.SameAccount) + ("LocalAdmin : {0}" -f $Support.InteractiveIsAdmin) + ("Supported : {0}" -f $Support.Supported) + ("Reason : {0}" -f $(if ($Support.Reason) { $Support.Reason } else { 'none' })) + ("LogFile : {0}" -f $(if ($Path) { $Path } else { 'unavailable' })) + '================================================================' + ) +} + + +function Write-InteractiveUserSupport { + <# + .SYNOPSIS + Reports the detected identity context and stops the run on an unsupported + machine, before anything is installed or changed. + + .DESCRIPTION + A refused run used to leave the user with nothing on screen to read (the + window closed with the console) and, before this log existed, with nothing on + disk either. The reason and the identity context are therefore appended to the + launcher log before exiting, and on an interactive console the exit waits for + a keypress so the explanation is still readable. + + The wait is skipped when the input is redirected (CI, scheduled task, + irm|iex): there is no console to hold open there, and a missing keypress + would hang the run forever. + #> + param( + [Parameter(Mandatory = $true)][object]$Support, + [AllowNull()][string]$LogPath + ) + + Write-Host ("[WinToolkit] Signed-in user : {0} (local administrator: {1})" -f + $Support.InteractiveUser, $(if ($Support.InteractiveIsAdmin) { 'yes' } else { 'no' })) + Write-Host ("[WinToolkit] Running as : {0}" -f $Support.CurrentUser) + + if ($Support.Supported) { return } + + Write-Host '' + Write-Host 'WinToolkit: unsupported configuration, nothing has been installed.' -ForegroundColor Yellow + Write-Host '' + if ($Support.Reason -eq 'NotLocalAdministrator') { + Write-Host 'The signed-in Windows account is not a member of the local Administrators' + Write-Host 'group. This script personalizes PowerShell, Windows Terminal and the' + Write-Host 'desktop, and an elevated standard account would receive every setting in' + Write-Host 'the administrator profile instead, with nothing left after the reboot.' + } + else { + Write-Host 'The script is running under a different account than the one signed in to' + Write-Host 'Windows. Every personal setting would be written to the wrong profile.' + } + Write-Host '' + Write-Host 'What to do: open PowerShell from the signed-in account and run this script' + Write-Host 'again. Do not use "Run as administrator" with a different user.' + Write-Host '' + Write-Host ("[WinToolkit] result={0} reason={1}" -f $script:LauncherResultCodes.UnsupportedInteractiveUser, $Support.Reason) + + # The refusal is the one event that leaves no other trace behind, so it is + # written to the log explicitly: reason and result code close the file. + Write-LauncherLog -Path $LogPath -Line @( + '[GATE] Refused: unsupported configuration. Nothing has been installed.' + ("[GATE] Reason : {0}" -f $Support.Reason) + ("[GATE] result : {0}" -f $script:LauncherResultCodes.UnsupportedInteractiveUser) + '[END WINTOOLKIT LAUNCHER]' + ) + + if ($LogPath) { + Write-Host ("[WinToolkit] Log : {0}" -f $LogPath) + } + + # Hold an interactive console open: the window would otherwise close on exit + # and the explanation above would disappear with it. + if (-not [Console]::IsInputRedirected) { + $null = Read-Host -Prompt 'Premi INVIO per chiudere' + } + + exit 0 +} + + function Get-WorkingPwsh { $candidates = @() $command = Get-Command pwsh -ErrorAction SilentlyContinue @@ -37,6 +461,108 @@ function Get-WorkingPwsh { } +function Get-PwshVersion { + <# + .SYNOPSIS + Returns the version of a PowerShell 7 executable, or $null when it cannot be + read. The version is queried from the host itself, not from the file metadata. + #> + param([string]$Path) + + if (-not $Path) { return $null } + try { + $raw = & $Path -NoLogo -NoProfile -NonInteractive -Command '$PSVersionTable.PSVersion.ToString()' 2>$null + if ($LASTEXITCODE -eq 0 -and $raw) { + $value = ([string]$raw[0]).Trim() + if ($value -match '^\d+\.\d+\.\d+') { return [version]$Matches[0] } + } + } + catch { + Write-Verbose "Could not read the PowerShell version from ${Path}: $($_.Exception.Message)" + } + return $null +} + + +function Get-LatestPowerShellVersion { + <# + .SYNOPSIS + Returns the latest stable PowerShell 7 version, or $null when the release feed + cannot be read. "latest" excludes pre-releases, so this is the stable line. + #> + try { + $release = Invoke-RestMethod -Uri 'https://api.github.com/repos/PowerShell/PowerShell/releases/latest' -UseBasicParsing -ErrorAction Stop + $tag = [string]$release.tag_name + if ($tag -match '^v?(\d+\.\d+\.\d+)') { return [version]$Matches[1] } + } + catch { + Write-Verbose "PowerShell release feed unavailable: $($_.Exception.Message)" + } + return $null +} + + +function Sync-PowerShellVersion { + <# + .SYNOPSIS + Brings an ALREADY installed PowerShell 7 up to the latest available release + and returns the executable to use from now on. + + .DESCRIPTION + start.ps1 used to install PowerShell 7 only when it was missing, so a system + with an outdated build kept it forever and every new session greeted the user + with "A new PowerShell stable release is available". WinGet decides on its own + whether an update exists: exit code 0 covers both "updated" and "nothing to + do". When WinGet is unavailable the release feed is used to report the gap + without changing anything. + #> + param([string]$Path) + + $current = Get-PwshVersion -Path $Path + if (-not $current) { + Write-Host '[WinToolkit] PowerShell 7 version unknown, skipping the version check.' -ForegroundColor Yellow + return $Path + } + Write-Host ("[WinToolkit] PowerShell 7 installed: {0}" -f $current) + + $winget = Get-Command winget.exe -ErrorAction SilentlyContinue + if ($winget) { + $process = Start-Process -FilePath $winget.Source -ArgumentList @( + 'upgrade', '--id', 'Microsoft.PowerShell', '--exact', + '--accept-source-agreements', '--accept-package-agreements', + '--silent', '--disable-interactivity' + ) -Wait -PassThru -NoNewWindow + if ($process.ExitCode -eq 0) { + # The running host keeps the bits it already loaded: the new build is + # used from the next session, so the path is resolved again here. + $refreshed = Get-WorkingPwsh + $updated = Get-PwshVersion -Path $refreshed + if ($updated) { + if ($updated -gt $current) { + Write-Host ("[WinToolkit] PowerShell 7 updated: {0} -> {1}. The new build is used from the next session." -f $current, $updated) -ForegroundColor Green + } + else { + Write-Host '[WinToolkit] PowerShell 7 is already up to date.' -ForegroundColor Green + } + if ($refreshed) { return $refreshed } + return $Path + } + } + Write-Host '[WinToolkit] WinGet could not update PowerShell 7, falling back to the release feed.' -ForegroundColor Yellow + } + + $latest = Get-LatestPowerShellVersion + if ($latest -and $latest -gt $current) { + Write-Host ("[WinToolkit] PowerShell 7 {0} is installed but {1} is available." -f $current, $latest) -ForegroundColor Yellow + Write-Host "[WinToolkit] Run: winget upgrade --id Microsoft.PowerShell --exact" + } + else { + Write-Host '[WinToolkit] PowerShell 7 is already up to date.' -ForegroundColor Green + } + return $Path +} + + function Install-Pwsh { $winget = Get-Command winget.exe -ErrorAction SilentlyContinue if ($winget) { @@ -121,27 +647,51 @@ function Request-DefenderPause { Write-Warning 'Windows Defender e ancora attivo: si prosegue comunque. Alcune installazioni potrebbero fallire.' } -$env:WTOOLKIT_LANGUAGE = $Language -Request-DefenderPause +# --------------------------------------------------------------------------- +# Launcher diagnostic log. +# +# Created BEFORE the gate, so a refused run leaves a file behind too: that is +# exactly the case that used to leave the user with an empty screen and no +# evidence at all. Best-effort throughout: a machine where the folder cannot be +# created must still run the launcher. +# --------------------------------------------------------------------------- +$launcherLogPath = Get-LauncherLogPath -# Capture the INTERACTIVE user context BEFORE elevating. UAC may switch the process -# to a different administrator account; without this, every user-scoped artifact -# (Documents\PowerShell profile, Oh My Posh theme, desktop shortcut) would be -# written to that other account and the user would see nothing on their own desktop -# even though the log reported every step as successful. -function Get-InteractiveUserContext { - $desktop = '' - $documents = '' - try { $desktop = [Environment]::GetFolderPath('Desktop', [Environment+SpecialFolderOption]::Create) } catch { } - try { $documents = [Environment]::GetFolderPath('MyDocuments', [Environment+SpecialFolderOption]::Create) } catch { } - return @{ - User = [Security.Principal.WindowsIdentity]::GetCurrent().Name - UserProfile = $env:USERPROFILE - Desktop = $desktop - MyDocuments = $documents +# --------------------------------------------------------------------------- +# Supported configuration gate. +# +# It runs BEFORE anything is installed or changed, Defender included, because +# the toolkit personalizes the account that runs the process: when that account +# is not the account signed in to Windows, every setting would be written to +# the wrong profile and the run would still report success. +# --------------------------------------------------------------------------- +$support = Get-InteractiveUserSupport + +# The header is written here, after the context is known, and on every run: +# refused or successful alike. +Write-LauncherLogHeader -Support $support -Path $launcherLogPath + +if ($PrintContext) { + # Diagnostics only: the context is printed and the launcher exits without + # touching the machine. The Pester suite asserts on this output. + Write-Host ("[WinToolkit] Signed-in user : {0}" -f $support.InteractiveUser) + Write-Host ("[WinToolkit] Running as : {0}" -f $support.CurrentUser) + Write-Host ("[WinToolkit] Local admin : {0}" -f $support.InteractiveIsAdmin) + Write-Host ("[WinToolkit] Same account : {0}" -f $support.SameAccount) + Write-Host ("[WinToolkit] Supported : {0}" -f $support.Supported) + Write-Host ("[WinToolkit] Reason : {0}" -f $(if ($support.Reason) { $support.Reason } else { 'none' })) + Write-Host ("[WinToolkit] result={0}" -f $(if ($support.Supported) { $script:LauncherResultCodes.Ready } else { $script:LauncherResultCodes.UnsupportedInteractiveUser })) + if ($launcherLogPath) { + Write-Host ("[WinToolkit] Log : {0}" -f $launcherLogPath) } + exit 0 } +Write-InteractiveUserSupport -Support $support -LogPath $launcherLogPath + +$env:WTOOLKIT_LANGUAGE = $Language +Request-DefenderPause + if (-not (Test-IsAdministrator)) { # Always relaunch the elevated process on PowerShell 7 (installing it first # if needed). This avoids running the (UTF-8 + emoji) core under Windows @@ -186,7 +736,13 @@ catch { } $pwsh = Get-WorkingPwsh -if (-not $pwsh) { $pwsh = Install-Pwsh } +if ($pwsh) { + # Already installed: make sure it is the latest build, not just any build. + $pwsh = Sync-PowerShellVersion -Path $pwsh +} +else { + $pwsh = Install-Pwsh +} $coreCommand = '$s = irm ''' + $CoreScriptUrl + '''; & ([scriptblock]::Create($s))' $coreProcess = Start-Process -FilePath $pwsh -ArgumentList @(