diff --git a/docs/src/content/docs/guides/win11creator.mdx b/docs/src/content/docs/guides/win11creator.mdx index 9139f240..e31db79a 100644 --- a/docs/src/content/docs/guides/win11creator.mdx +++ b/docs/src/content/docs/guides/win11creator.mdx @@ -134,6 +134,10 @@ When you install Windows 11 from your modified ISO: --- +### First-login restart + +After installation, WinUtil restarts Windows only after both the system and user customization scripts finish. If user customization has not signaled completion within five minutes after system customization finishes, the automatic restart is skipped. Check `C:\Windows\Setup\Scripts\FirstLogon.log` and `%TEMP%\UserOnce.log` before restarting manually. + ### Troubleshooting | Problem | Fix | diff --git a/pester/first-logon.Tests.ps1 b/pester/first-logon.Tests.ps1 new file mode 100644 index 00000000..74cb83c6 --- /dev/null +++ b/pester/first-logon.Tests.ps1 @@ -0,0 +1,78 @@ +BeforeAll { + [xml]$unattend = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../tools/autounattend.xml') -Raw + function Get-CompletionTail { + param([string]$Name, [string]$Variable) + $text = $unattend.SelectSingleNode("//*[local-name()='File' and @path='C:\Windows\Setup\Scripts\$Name.ps1']").InnerText + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($text, [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw ($errors | Out-String) } + $start = @($ast.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $_.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + $_.Left.VariablePath.UserPath -eq $Variable + }) + if ($start.Count -ne 1) { throw "Expected one $Variable assignment in $Name." } + # Execute only completion coordination, never setup commands, downloads, or installers. + [scriptblock]::Create($text.Substring($start[0].Extent.StartOffset)) + } + $script:publishCompletion = Get-CompletionTail 'UserOnce' 'completionDir' + $script:awaitCompletion = Get-CompletionTail 'FirstLogon' 'completionPath' + # Shadow the real restart command even on Windows; a missing mock must never reboot a test host. + function Restart-Computer { param([switch]$Force) throw 'Restart must be mocked.' } +} + +Describe 'First-logon restart coordination' { + BeforeEach { + $script:savedLocalAppData = $env:LOCALAPPDATA + $env:LOCALAPPDATA = Join-Path $TestDrive ('profile[1]-' + [guid]::NewGuid()) + $script:completionPath = Join-Path $env:LOCALAPPDATA 'WinUtil/UserOnce.complete' + $script:now = [datetime]'2026-01-01T00:00:00' + $script:sleeps = 0 + $script:messages = [System.Collections.Generic.List[string]]::new() + Mock Get-Date { $script:now } + Mock Start-Sleep { $script:sleeps++; $script:now = $script:now.AddMinutes(1) } + Mock Out-File { $script:messages.Add([string]$InputObject) } + Mock Restart-Computer { } + } + + AfterEach { + $env:LOCALAPPDATA = $script:savedLocalAppData + } + + It 'restarts immediately when user configuration finished first' { + & $script:publishCompletion + Test-Path -LiteralPath $script:completionPath | Should -BeTrue + & $script:awaitCompletion + Should -Invoke Restart-Computer -Times 1 -Exactly -ParameterFilter { $Force } + Should -Invoke Start-Sleep -Times 0 -Exactly + Test-Path -LiteralPath $script:completionPath | Should -BeTrue + } + + It 'waits for delayed user configuration before restarting' { + Mock Start-Sleep { + $script:sleeps++ + if ($script:sleeps -eq 2) { & $script:publishCompletion } + } + Mock Restart-Computer { + $script:sleeps | Should -Be 2 + Test-Path -LiteralPath $script:completionPath | Should -BeTrue + } + & $script:awaitCompletion + Should -Invoke Start-Sleep -Times 2 -Exactly + Should -Invoke Restart-Computer -Times 1 -Exactly + } + + It 'logs the timeout and leaves the machine running when completion never arrives' { + & $script:awaitCompletion + Should -Invoke Start-Sleep -Times 5 -Exactly + Should -Invoke Restart-Computer -Times 0 -Exactly + ($script:messages -join '\n') | Should -Match 'Timed out.*Automatic restart skipped' + } + + It 'does not restart if the completion check fails' { + Mock Test-Path { throw 'Cannot read completion marker' } + { & $script:awaitCompletion } | Should -Throw '*Cannot read completion marker*' + Should -Invoke Restart-Computer -Times 0 -Exactly + } +} diff --git a/tools/autounattend.xml b/tools/autounattend.xml index dde53ae7..748aa9fc 100644 --- a/tools/autounattend.xml +++ b/tools/autounattend.xml @@ -352,9 +352,6 @@ $scripts = @( reg.exe add "HKCU\Software\Microsoft\Windows\CurrentVersion\Start" /v ShowRecentList /t REG_DWORD /d 0 /f; reg.exe add "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" /v Start_TrackDocs /t REG_DWORD /d 0 /f; }; - { - Restart-Computer -Force; - }; ); & { @@ -378,6 +375,11 @@ $scripts = @( $complete += $increment; } } *>&1 | Out-String -Width 1KB -Stream >> "$env:TEMP\UserOnce.log"; + +# RunOnce and FirstLogonCommands can overlap. Publish completion after the log is flushed. +$completionDir = Join-Path $env:LOCALAPPDATA 'WinUtil'; +[System.IO.Directory]::CreateDirectory($completionDir) | Out-Null; +[System.IO.File]::WriteAllText((Join-Path $completionDir 'UserOnce.complete'), ''); $scripts = @( @@ -470,7 +472,7 @@ $scripts = @( { $recallFeature = Get-WindowsOptionalFeature -Online -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' -and $_.FeatureName -like 'Recall' }; if( $recallFeature ) { - Disable-WindowsOptionalFeature -Online -FeatureName 'Recall' -Remove -ErrorAction SilentlyContinue; + Disable-WindowsOptionalFeature -Online -FeatureName 'Recall' -Remove -NoRestart -ErrorAction SilentlyContinue; } }; { @@ -483,7 +485,7 @@ $scripts = @( Remove-Item -Path $viveDir -Recurse -Force; }; { - Start-Process C:\Windows\System32\OneDriveSetup.exe -ArgumentList /uninstall + Start-Process C:\Windows\System32\OneDriveSetup.exe -ArgumentList /uninstall -Wait }; { if( (Get-BitLockerVolume -MountPoint $Env:SystemDrive).ProtectionStatus -eq 'On' ) { @@ -518,6 +520,23 @@ $scripts = @( $complete += $increment; } } *>&1 | Out-String -Width 1KB -Stream >> "C:\Windows\Setup\Scripts\FirstLogon.log"; + +# UserOnce may finish first, so keep any completion flag it has already written. +$completionPath = Join-Path $env:LOCALAPPDATA 'WinUtil\UserOnce.complete'; +$deadline = (Get-Date).AddMinutes(5); +'WinUtil: Waiting for user configuration to finish before restarting (up to 5 minutes).' | Out-File -LiteralPath 'C:\Windows\Setup\Scripts\FirstLogon.log' -Append -ErrorAction Stop; +while( -not (Test-Path -LiteralPath $completionPath -PathType Leaf) ) { + if( (Get-Date) -ge $deadline ) { + $message = 'WinUtil: Timed out waiting for user configuration. Automatic restart skipped. Check %TEMP%\UserOnce.log.'; + $message | Out-File -LiteralPath 'C:\Windows\Setup\Scripts\FirstLogon.log' -Append -ErrorAction Stop; + return; + } + Start-Sleep -Seconds 1; +} + +$message = 'WinUtil: First-logon customization complete. Restarting the computer.'; +$message | Out-File -LiteralPath 'C:\Windows\Setup\Scripts\FirstLogon.log' -Append -ErrorAction Stop; +Restart-Computer -Force;