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](https://github.com/magnetarman) | 2534 | 69 |
-| 2 |
[pomodori92](https://github.com/pomodori92) | 26 | 26 |
+| Rank | Contributor | Commits | PRs |
+| :--- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ | :-- |
+| 1 |
[Magnetarman](https://github.com/magnetarman) | 2534 | 69 |
+| 2 |
[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 @(