module git.xetup.x9.cz/x9/xetup

go 1.24.0

require github.com/lxn/walk v0.0.0-20210112085537-c389da54e794

require (
	github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
	golang.org/x/sys v0.36.0 // indirect
	gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect
)
---
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=


Now I have everything I need. Let me implement the state machine architecture across all the relevant files.


Bash

mkdir -p /opt/xetup/internal/state /opt/xetup/internal/prereboot

Create new package directories


(Bash completed with no output)


Write

// Package state manages the persistent xetup deployment state used to resume
// after a system reboot. The state file is written before each reboot and
// deleted once all deployment steps have completed.
package state

import (
	"encoding/json"
	"os"
	"path/filepath"
	"time"

	"git.xetup.x9.cz/x9/xetup/internal/config"
)

// StepResult records the outcome of a single deployment step.
// Results are accumulated across reboot rounds so the final summary
// can show every step regardless of which round it ran in.
type StepResult struct {
	StepID  string        `json:"stepID"`
	Num     string        `json:"num"`
	Name    string        `json:"name"`
	Status  string        `json:"status"`  // "OK", "ERROR", "SKIPPED"
	Elapsed time.Duration `json:"elapsed"`
}

// State holds everything xetup needs to resume after a reboot.
type State struct {
	Config       config.Config `json:"config"`
	ConfigPath   string        `json:"configPath"` // path to original config.json
	LogFile      string        `json:"logFile"`
	PendingSteps []string      `json:"pendingSteps"` // step IDs to run next, in canonical order
	Results      []StepResult  `json:"results"`      // accumulated across all rounds
}

// Load reads the state file. Returns a non-nil error when the file is absent.
func Load() (*State, error) {
	data, err := os.ReadFile(statePath())
	if err != nil {
		return nil, err
	}
	var s State
	if err := json.Unmarshal(data, &s); err != nil {
		return nil, err
	}
	return &s, nil
}

// Save writes the state file, creating parent directories as needed.
func Save(s *State) error {
	p := statePath()
	if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(s, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(p, data, 0644)
}

// Delete removes the state file. Silently ignores not-found.
func Delete() error {
	err := os.Remove(statePath())
	if os.IsNotExist(err) {
		return nil
	}
	return err
}

// Exists reports whether a state file is present on disk.
func Exists() bool {
	_, err := os.Stat(statePath())
	return err == nil
}


File created successfully at: /opt/xetup/internal/state/state.go

// Package state manages the persistent xetup deployment state used to resume
// after a system reboot. The state file is written before each reboot and
// deleted once all deployment steps have completed.
package state

import (
	"encoding/json"
	"os"
	"path/filepath"
	"time"

	"git.xetup.x9.cz/x9/xetup/internal/config"
)

// StepResult records the outcome of a single deployment step.
// Results are accumulated across reboot rounds so the final summary
// can show every step regardless of which round it ran in.
type StepResult struct {
	StepID  string        `json:"stepID"`
	Num     string        `json:"num"`
	Name    string        `json:"name"`
	Status  string        `json:"status"`  // "OK", "ERROR", "SKIPPED"
	Elapsed time.Duration `json:"elapsed"`
}

// State holds everything xetup needs to resume after a reboot.
type State struct {
	Config       config.Config `json:"config"`
	ConfigPath   string        `json:"configPath"` // path to original config.json
	LogFile      string        `json:"logFile"`
	PendingSteps []string      `json:"pendingSteps"` // step IDs to run next, in canonical order
	Results      []StepResult  `json:"results"`      // accumulated across all rounds
}

// Load reads the state file. Returns a non-nil error when the file is absent.
func Load() (*State, error) {
	data, err := os.ReadFile(statePath())
	if err != nil {
		return nil, err
	}
	var s State
	if err := json.Unmarshal(data, &s); err != nil {
		return nil, err
	}
	return &s, nil
}

// Save writes the state file, creating parent directories as needed.
func Save(s *State) error {
	p := statePath()
	if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(s, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(p, data, 0644)
}

// Delete removes the state file. Silently ignores not-found.
func Delete() error {
	err := os.Remove(statePath())
	if os.IsNotExist(err) {
		return nil
	}
	return err
}

// Exists reports whether a state file is present on disk.
func Exists() bool {
	_, err := os.Stat(statePath())
	return err == nil
}


//go:build windows

package state

func statePath() string {
	return `C:\Windows\Setup\Scripts\xetup-state.json`
}


File created successfully at: /opt/xetup/internal/state/path_windows.go

//go:build windows

package state

func statePath() string {
	return `C:\Windows\Setup\Scripts\xetup-state.json`
}


//go:build !windows

package state

import "os"

func statePath() string {
	if h, err := os.UserHomeDir(); err == nil {
		return h + "/xetup-state.json"
	}
	return "/tmp/xetup-state.json"
}


File created successfully at: /opt/xetup/internal/state/path_other.go

//go:build !windows

package state

import "os"

func statePath() string {
	if h, err := os.UserHomeDir(); err == nil {
		return h + "/xetup-state.json"
	}
	return "/tmp/xetup-state.json"
}


//go:build windows

// Package prereboot ensures the reboot-resume infrastructure is in place
// before xetup triggers a system restart mid-deployment.
package prereboot

import (
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"syscall"

	"golang.org/x/sys/windows/registry"
)

// StablePath is where xetup.exe is copied so it survives across reboots.
const StablePath = `C:\Windows\Setup\Scripts\xetup.exe`

// TaskName is the scheduled task that re-launches xetup after each reboot.
const TaskName = "X9-Resume"

const adminUser = "adminx9"
const winlogonKey = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`

// IsAdminx9 reports whether the current process runs as the adminx9 account.
func IsAdminx9() bool {
	return strings.EqualFold(os.Getenv("USERNAME"), adminUser)
}

// Prepare ensures the reboot-resume infrastructure is in place.
// When not running as adminx9 (first reboot trigger):
//   - creates the adminx9 local account if it does not exist
//   - copies the running binary to StablePath
//   - enables autologon for adminx9 (AutoLogonCount=20 safety cap)
//   - registers the X9-Resume scheduled task (AtLogOn adminx9, RunLevel Highest)
//
// When already running as adminx9 (subsequent rounds) all of the above is
// already in place; the function returns immediately without changes.
// Safe to call repeatedly (idempotent).
func Prepare() error {
	if IsAdminx9() {
		return nil
	}
	if err := ensureAdminx9User(); err != nil {
		return fmt.Errorf("adminx9 account: %w", err)
	}
	if err := copySelfTo(StablePath); err != nil {
		return fmt.Errorf("copy binary: %w", err)
	}
	if err := setAutologon(); err != nil {
		return fmt.Errorf("autologon: %w", err)
	}
	if err := registerResumeTask(); err != nil {
		return fmt.Errorf("resume task: %w", err)
	}
	return nil
}

// Cleanup disables autologon and removes the X9-Resume scheduled task.
// Called when all deployment steps have completed successfully.
func Cleanup() {
	_ = disableAutologon()
	_ = unregisterResumeTask()
}

// ensureAdminx9User creates the adminx9 local account if absent.
func ensureAdminx9User() error {
	// "net user adminx9" exits 0 when user exists
	if err := newHiddenCmd("net", "user", adminUser).Run(); err == nil {
		return nil // already exists
	}
	// Create with empty password, no password-change prompt
	if b, err := newHiddenCmd("net", "user", adminUser, "", "/add").CombinedOutput(); err != nil {
		return fmt.Errorf("net user /add: %s: %w", strings.TrimSpace(string(b)), err)
	}
	// Add to local Administrators group
	if b, err := newHiddenCmd("net", "localgroup", "Administrators", adminUser, "/add").CombinedOutput(); err != nil {
		return fmt.Errorf("net localgroup: %s: %w", strings.TrimSpace(string(b)), err)
	}
	// Hide from the Windows login screen
	k, _, err := registry.CreateKey(registry.LOCAL_MACHINE,
		`SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList`,
		registry.SET_VALUE)
	if err != nil {
		return fmt.Errorf("registry hide: %w", err)
	}
	defer k.Close()
	return k.SetDWordValue(adminUser, 0)
}

// copySelfTo copies the running executable to dst.
// Skips the copy when src and dst resolve to the same path.
func copySelfTo(dst string) error {
	src, err := os.Executable()
	if err != nil {
		return err
	}
	if resolved, err := filepath.EvalSymlinks(src); err == nil {
		src = resolved
	}
	if strings.EqualFold(src, dst) {
		return nil
	}
	if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
		return err
	}
	return copyFile(src, dst)
}

func copyFile(src, dst string) error {
	r, err := os.Open(src)
	if err != nil {
		return err
	}
	defer r.Close()
	w, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer w.Close()
	_, err = io.Copy(w, r)
	return err
}

func setAutologon() error {
	k, err := registry.OpenKey(registry.LOCAL_MACHINE, winlogonKey, registry.SET_VALUE)
	if err != nil {
		return err
	}
	defer k.Close()
	for _, kv := range []struct{ name, val string }{
		{"AutoAdminLogon", "1"},
		{"DefaultUserName", adminUser},
		{"DefaultPassword", ""},
		{"DefaultDomainName", "."},
	} {
		if err := k.SetStringValue(kv.name, kv.val); err != nil {
			return fmt.Errorf("set %s: %w", kv.name, err)
		}
	}
	// Safety cap: self-limits even if cleanup task fails to run
	return k.SetDWordValue("AutoLogonCount", 20)
}

func disableAutologon() error {
	k, err := registry.OpenKey(registry.LOCAL_MACHINE, winlogonKey, registry.SET_VALUE)
	if err != nil {
		return err
	}
	defer k.Close()
	_ = k.SetStringValue("AutoAdminLogon", "0")
	_ = k.DeleteValue("DefaultPassword")
	_ = k.DeleteValue("AutoLogonCount")
	return nil
}

func registerResumeTask() error {
	ps := fmt.Sprintf(`
$action    = New-ScheduledTaskAction -Execute '%s' -Argument '--resume'
$trigger   = New-ScheduledTaskTrigger -AtLogOn -User '%s'
$settings  = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 4) -MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal -UserId '%s' -LogonType Interactive -RunLevel Highest
Unregister-ScheduledTask -TaskName 'X9-Resume' -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask -TaskName 'X9-Resume' -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
`, StablePath, adminUser, adminUser)
	return runPS(ps)
}

func unregisterResumeTask() error {
	return runPS(`Unregister-ScheduledTask -TaskName 'X9-Resume' -Confirm:$false -ErrorAction SilentlyContinue`)
}

func runPS(script string) error {
	cmd := newHiddenCmd("powershell.exe",
		"-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
	b, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("%s: %w", strings.TrimSpace(string(b)), err)
	}
	return nil
}

func newHiddenCmd(name string, args ...string) *exec.Cmd {
	cmd := exec.Command(name, args...)
	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
	return cmd
}


File created successfully at: /opt/xetup/internal/prereboot/prereboot_windows.go

//go:build windows

// Package prereboot ensures the reboot-resume infrastructure is in place
// before xetup triggers a system restart mid-deployment.
package prereboot

import (
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"syscall"

	"golang.org/x/sys/windows/registry"
)

// StablePath is where xetup.exe is copied so it survives across reboots.
const StablePath = `C:\Windows\Setup\Scripts\xetup.exe`

// TaskName is the scheduled task that re-launches xetup after each reboot.
const TaskName = "X9-Resume"

const adminUser = "adminx9"
const winlogonKey = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`

// IsAdminx9 reports whether the current process runs as the adminx9 account.
func IsAdminx9() bool {
	return strings.EqualFold(os.Getenv("USERNAME"), adminUser)
}

// Prepare ensures the reboot-resume infrastructure is in place.
// When not running as adminx9 (first reboot trigger):
//   - creates the adminx9 local account if it does not exist
//   - copies the running binary to StablePath
//   - enables autologon for adminx9 (AutoLogonCount=20 safety cap)
//   - registers the X9-Resume scheduled task (AtLogOn adminx9, RunLevel Highest)
//
// When already running as adminx9 (subsequent rounds) all of the above is
// already in place; the function returns immediately without changes.
// Safe to call repeatedly (idempotent).
func Prepare() error {
	if IsAdminx9() {
		return nil
	}
	if err := ensureAdminx9User(); err != nil {
		return fmt.Errorf("adminx9 account: %w", err)
	}
	if err := copySelfTo(StablePath); err != nil {
		return fmt.Errorf("copy binary: %w", err)
	}
	if err := setAutologon(); err != nil {
		return fmt.Errorf("autologon: %w", err)
	}
	if err := registerResumeTask(); err != nil {
		return fmt.Errorf("resume task: %w", err)
	}
	return nil
}

// Cleanup disables autologon and removes the X9-Resume scheduled task.
// Called when all deployment steps have completed successfully.
func Cleanup() {
	_ = disableAutologon()
	_ = unregisterResumeTask()
}

// ensureAdminx9User creates the adminx9 local account if absent.
func ensureAdminx9User() error {
	// "net user adminx9" exits 0 when user exists
	if err := newHiddenCmd("net", "user", adminUser).Run(); err == nil {
		return nil // already exists
	}
	// Create with empty password, no password-change prompt
	if b, err := newHiddenCmd("net", "user", adminUser, "", "/add").CombinedOutput(); err != nil {
		return fmt.Errorf("net user /add: %s: %w", strings.TrimSpace(string(b)), err)
	}
	// Add to local Administrators group
	if b, err := newHiddenCmd("net", "localgroup", "Administrators", adminUser, "/add").CombinedOutput(); err != nil {
		return fmt.Errorf("net localgroup: %s: %w", strings.TrimSpace(string(b)), err)
	}
	// Hide from the Windows login screen
	k, _, err := registry.CreateKey(registry.LOCAL_MACHINE,
		`SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList`,
		registry.SET_VALUE)
	if err != nil {
		return fmt.Errorf("registry hide: %w", err)
	}
	defer k.Close()
	return k.SetDWordValue(adminUser, 0)
}

// copySelfTo copies the running executable to dst.
// Skips the copy when src and dst resolve to the same path.
func copySelfTo(dst string) error {
	src, err := os.Executable()
	if err != nil {
		return err
	}
	if resolved, err := filepath.EvalSymlinks(src); err == nil {
		src = resolved
	}
	if strings.EqualFold(src, dst) {
		return nil
	}
	if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
		return err
	}
	return copyFile(src, dst)
}

func copyFile(src, dst string) error {
	r, err := os.Open(src)
	if err != nil {
		return err
	}
	defer r.Close()
	w, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer w.Close()
	_, err = io.Copy(w, r)
	return err
}

func setAutologon() error {
	k, err := registry.OpenKey(registry.LOCAL_MACHINE, winlogonKey, registry.SET_VALUE)
	if err != nil {
		return err
	}
	defer k.Close()
	for _, kv := range []struct{ name, val string }{
		{"AutoAdminLogon", "1"},
		{"DefaultUserName", adminUser},
		{"DefaultPassword", ""},
		{"DefaultDomainName", "."},
	} {
		if err := k.SetStringValue(kv.name, kv.val); err != nil {
			return fmt.Errorf("set %s: %w", kv.name, err)
		}
	}
	// Safety cap: self-limits even if cleanup task fails to run
	return k.SetDWordValue("AutoLogonCount", 20)
}

func disableAutologon() error {
	k, err := registry.OpenKey(registry.LOCAL_MACHINE, winlogonKey, registry.SET_VALUE)
	if err != nil {
		return err
	}
	defer k.Close()
	_ = k.SetStringValue("AutoAdminLogon", "0")
	_ = k.DeleteValue("DefaultPassword")
	_ = k.DeleteValue("AutoLogonCount")
	return nil
}

func registerResumeTask() error {
	ps := fmt.Sprintf(`
$action    = New-ScheduledTaskAction -Execute '%s' -Argument '--resume'
$trigger   = New-ScheduledTaskTrigger -AtLogOn -User '%s'
$settings  = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 4) -MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal -UserId '%s' -LogonType Interactive -RunLevel Highest
Unregister-ScheduledTask -TaskName 'X9-Resume' -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask -TaskName 'X9-Resume' -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
`, StablePath, adminUser, adminUser)
	return runPS(ps)
}

func unregisterResumeTask() error {
	return runPS(`Unregister-ScheduledTask -TaskName 'X9-Resume' -Confirm:$false -ErrorAction SilentlyContinue`)
}

func runPS(script string) error {
	cmd := newHiddenCmd("powershell.exe",
		"-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
	b, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("%s: %w", strings.TrimSpace(string(b)), err)
	}
	return nil
}

func newHiddenCmd(name string, args ...string) *exec.Cmd {
	cmd := exec.Command(name, args...)
	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
	return cmd
}


//go:build !windows

// Package prereboot ensures the reboot-resume infrastructure is in place
// before xetup triggers a system restart mid-deployment.
package prereboot

// StablePath is the platform stub for the stable binary path.
const StablePath = "/tmp/xetup-resume"

// TaskName is the scheduled task that re-launches xetup after each reboot.
const TaskName = "X9-Resume"

// IsAdminx9 always returns false on non-Windows platforms.
func IsAdminx9() bool { return false }

// Prepare is a no-op on non-Windows platforms.
func Prepare() error { return nil }

// Cleanup is a no-op on non-Windows platforms.
func Cleanup() {}


File created successfully at: /opt/xetup/internal/prereboot/prereboot_other.go

//go:build !windows

// Package prereboot ensures the reboot-resume infrastructure is in place
// before xetup triggers a system restart mid-deployment.
package prereboot

// StablePath is the platform stub for the stable binary path.
const StablePath = "/tmp/xetup-resume"

// TaskName is the scheduled task that re-launches xetup after each reboot.
const TaskName = "X9-Resume"

// IsAdminx9 always returns false on non-Windows platforms.
func IsAdminx9() bool { return false }

// Prepare is a no-op on non-Windows platforms.
func Prepare() error { return nil }

// Cleanup is a no-op on non-Windows platforms.
func Cleanup() {}


Now update `runner.go` to add exit-code-9 detection and helpers:


Edit

// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// ErrNeedsReboot is returned by runScript when a PowerShell script exits with
// code 9, signalling that it completed successfully but requires a system
// reboot before deployment can continue.
var ErrNeedsReboot = errors.New("reboot required")


The file /opt/xetup/internal/runner/runner.go has been updated successfully.

// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// ErrNeedsReboot is returned by runScript when a PowerShell script exits with
// code 9, signalling that it completed successfully but requires a system
// reboot before deployment can continue.
var ErrNeedsReboot = errors.New("reboot required")

"bufio"
"context"
"encoding/json"
+  "errors"
"fmt"
"os"
"os/exec"

+// ErrNeedsReboot is returned by runScript when a PowerShell script exits with

+// code 9, signalling that it completed successfully but requires a system

+// reboot before deployment can continue.

+var ErrNeedsReboot = errors.New("reboot required")

// Step describes a single deployment step.

ID         string // e.g. "adminAccount"


// Result is the outcome of a single step.
type Result struct {
	Step    Step
	Status  string // "OK", "ERROR", "SKIPPED"
	Elapsed time.Duration
}

// Result is the outcome of a single step.
type Result struct {
	Step        Step
	Status      string // "OK", "ERROR", "SKIPPED", "CANCELLED"
	Elapsed     time.Duration
	NeedsReboot bool // true when the script exited with code 9
}


// Result is the outcome of a single step.
type Result struct {
	Step    Step
	Status  string // "OK", "ERROR", "SKIPPED"
	Elapsed time.Duration
}

// Result is the outcome of a single step.
type Result struct {
	Step        Step
	Status      string // "OK", "ERROR", "SKIPPED", "CANCELLED"
	Elapsed     time.Duration
	NeedsReboot bool // true when the script exited with code 9
}

-  Status  string // "OK", "ERROR", "SKIPPED"

+  Status      string // "OK", "ERROR", "SKIPPED", "CANCELLED"

+  NeedsReboot bool // true when the script exited with code 9

// LogLine is a single output line from a running script.


Now update `Run()` to detect NeedsReboot and stop the loop:


		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		if err != nil {
			if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil {
			break
		}

		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		needsReboot := false
		if err != nil {
			if errors.Is(err, ErrNeedsReboot) {
				needsReboot = true
				// status remains "OK": step completed, reboot is just required to continue
			} else if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil || needsReboot {
			break
		}


		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		if err != nil {
			if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil {
			break
		}

		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		needsReboot := false
		if err != nil {
			if errors.Is(err, ErrNeedsReboot) {
				needsReboot = true
				// status remains "OK": step completed, reboot is just required to continue
			} else if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil || needsReboot {
			break
		}

+      if errors.Is(err, ErrNeedsReboot) {

+        // status remains "OK": step completed, reboot is just required to continue

-    res := Result{Step: step, Status: status, Elapsed: elapsed}

+    res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}

+    if ctx.Err() != nil || needsReboot {


Now update `runScript` to detect exit code 9:


	err := cmd.Wait()
	if err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return err


	err := cmd.Wait()
	if err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return err

+    if errors.As(err, &exitErr) && exitErr.ExitCode() == 9 {

// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:


Now add `StepsByIDs` and `StepByID` helpers at the end of runner.go:


// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {

// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,
// preserving the canonical AllSteps() order.
func StepsByIDs(ids []string) []Step {
	want := make(map[string]bool, len(ids))
	for _, id := range ids {
		want[id] = true
	}
	var result []Step
	for _, s := range AllSteps() {
		if want[s.ID] {
			result = append(result, s)
		}
	}
	return result
}

// StepByID returns the Step with the given ID, and true if found.
func StepByID(id string) (Step, bool) {
	for _, s := range AllSteps() {
		if s.ID == id {
			return s, true
		}
	}
	return Step{}, false
}

// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {


// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,
// preserving the canonical AllSteps() order.
func StepsByIDs(ids []string) []Step {
	want := make(map[string]bool, len(ids))
	for _, id := range ids {
		want[id] = true
	}
	var result []Step
	for _, s := range AllSteps() {
		if want[s.ID] {
			result = append(result, s)
		}
	}
	return result
}

// StepByID returns the Step with the given ID, and true if found.
func StepByID(id string) (Step, bool) {
	for _, s := range AllSteps() {
		if s.ID == id {
			return s, true
		}
	}
	return Step{}, false
}

// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {

+// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,

+// preserving the canonical AllSteps() order.

+  want := make(map[string]bool, len(ids))

+// StepByID returns the Step with the given ID, and true if found.

+func StepByID(id string) (Step, bool) {

path := filepath.Join(tmpDir, "config-runtime.json")


Now rewrite `gui.go` with resume mode and pre-reboot flow:


Write

//go:build windows

// Package gui implements the Walk-based graphical interface for xetup.
//
// Three sequential phases, each with its own window:
//  1. Config form  – PC name, product key, profile, step checkboxes,
//                    load/save config buttons for per-client presets
//  2. Live run     – real-time log streamed from PowerShell scripts
//  3. Summary      – per-step OK / ERROR / SKIPPED with elapsed time
//
// Resume mode (--resume or state file present): skips the config form and
// runs only the pending steps recorded in the state file. On completion,
// disables autologon and removes the X9-Resume scheduled task.
package gui

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"sync"
	"time"

	"github.com/lxn/walk"
	. "github.com/lxn/walk/declarative"

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/prereboot"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
	"git.xetup.x9.cz/x9/xetup/internal/state"
)

// Run opens the xetup window and blocks until the user closes it.
func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
	// Resume mode: state file present from a previous interrupted run
	if st, err := state.Load(); err == nil {
		resumePhase(st, runCfg)
		return
	}

	// Normal flow: config form → run → summary
	for {
		res := formPhase(cfg, runCfg, cfgPath)
		switch res.action {
		case "quit":
			return
		case "reload":
			cfg = res.cfg
			cfgPath = res.cfgPath
		case "run":
			runCfg.ProfileType = res.cfg.Deployment.ProfileType
			results, needsReboot := runPhase(runCfg, res.steps, false)
			if needsReboot {
				prepareRebootAndRestart(res.cfg, res.steps, results, cfgPath, runCfg)
				return
			}
			donePhase(results, nil)
			return
		}
	}
}

// --------------------------------------------------------------------------
// Resume mode
// --------------------------------------------------------------------------

func resumePhase(st *state.State, runCfg runner.RunConfig) {
	runCfg.LogFile = st.LogFile

	steps := runner.StepsByIDs(st.PendingSteps)
	results, needsReboot := runPhase(runCfg, steps, true)

	// Accumulate results (exclude NeedsReboot step – it will run again)
	newResults := append(st.Results, toStateResults(results)...) //nolint:gocritic

	if needsReboot {
		// Update state and reboot – infrastructure already in place (running as adminx9)
		pending := pendingStepIDs(steps, results)
		newSt := &state.State{
			Config:       st.Config,
			ConfigPath:   st.ConfigPath,
			LogFile:      st.LogFile,
			PendingSteps: pending,
			Results:      newResults,
		}
		if err := state.Save(newSt); err != nil {
			walk.MsgBox(nil, "Chyba", "Nelze ulozit stav: "+err.Error(), walk.MsgBoxIconError)
		}
		reboot()
		return
	}

	// All steps complete
	prereboot.Cleanup()
	_ = state.Delete()
	donePhase(results, st.Results)
}

// --------------------------------------------------------------------------
// Phase 1 – Config form
// --------------------------------------------------------------------------

type formResult struct {
	action  string // "run", "reload", "quit"
	cfg     config.Config
	cfgPath string
	steps   []runner.Step
}

func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formResult {
	var mw *walk.MainWindow
	result := formResult{action: "quit", cfgPath: cfgPath}

	var (
		pcNameLE     *walk.LineEdit
		pcDescLE     *walk.LineEdit
		productKeyLE *walk.LineEdit
		profileCB    *walk.ComboBox
	)

	items := runner.AllSelectableItems()
	checkPtrs := make([]*walk.CheckBox, len(items))
	checkWidgets := make([]Widget, len(items))
	for i, item := range items {
		i := i // capture for closure
		checkWidgets[i] = CheckBox{
			AssignTo: &checkPtrs[i],
			Text:     item.Label,
			Checked:  itemEnabled(cfg, item),
		}
	}

	collectCfg := func() config.Config {
		out := cfg
		out.Deployment.PCName = pcNameLE.Text()
		out.Deployment.PCDescription = pcDescLE.Text()
		out.Activation.ProductKey = productKeyLE.Text()
		profiles := []string{"default", "admin", "user"}
		if idx := profileCB.CurrentIndex(); idx >= 0 && idx < len(profiles) {
			out.Deployment.ProfileType = profiles[idx]
		}
		selected := make(map[string]bool, len(items))
		for i, item := range items {
			selected[item.Key] = checkPtrs[i].Checked()
		}
		_, features := buildStepsAndFeatures(selected)
		out.Features = features
		return out
	}

	if err := (MainWindow{
		AssignTo: &mw,
		Title:    "xetup \u2014 Windows deployment",
		Size:     Size{Width: 760, Height: 740},
		Layout:   VBox{},
		Children: []Widget{
			// ── Form fields ──────────────────────────────────────────────────
			Composite{
				Layout: Grid{Columns: 2},
				Children: []Widget{
					Label{Text: "PC jmeno:"},
					LineEdit{
						AssignTo:  &pcNameLE,
						Text:      cfg.Deployment.PCName,
						CueBanner: "napr. NB-KLIENT-01  (prazdne = neprejmenovat)",
					},
					Label{Text: "Popis PC:"},
					LineEdit{
						AssignTo:  &pcDescLE,
						Text:      cfg.Deployment.PCDescription,
						CueBanner: "napr. PC recepce",
					},
					Label{Text: "Product Key:"},
					LineEdit{
						AssignTo:  &productKeyLE,
						Text:      cfg.Activation.ProductKey,
						CueBanner: "prazdne = OA3 / GVLK fallback",
					},
					Label{Text: "Profil:"},
					ComboBox{
						AssignTo:     &profileCB,
						Model:        []string{"default", "admin", "user"},
						CurrentIndex: profileIndex(cfg.Deployment.ProfileType),
					},
				},
			},
			HSeparator{},
			// ── Step checkboxes ──────────────────────────────────────────────
			Label{Text: "Kroky a nastaveni (odskrtnete co nechcete spustit):"},
			ScrollView{
				MinSize:  Size{Height: 400},
				Layout:   VBox{MarginsZero: true},
				Children: checkWidgets,
			},
			HSeparator{},
			// ── Buttons ──────────────────────────────────────────────────────
			Composite{
				Layout: HBox{MarginsZero: true},
				Children: []Widget{
					PushButton{
						Text: "Nacist config...",
						OnClicked: func() {
							dlg := new(walk.FileDialog)
							dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
							dlg.Title = "Nacist konfiguraci"
							ok, err := dlg.ShowOpen(mw)
							if err != nil || !ok {
								return
							}
							data, err := os.ReadFile(dlg.FilePath)
							if err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
								return
							}
							newCfg := config.DefaultConfig()
							if err := json.Unmarshal(data, &newCfg); err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
								return
							}
							result = formResult{
								action:  "reload",
								cfg:     newCfg,
								cfgPath: dlg.FilePath,
							}
							mw.Close()
						},
					},
					PushButton{
						Text: "Ulozit config...",
						OnClicked: func() {
							dlg := new(walk.FileDialog)
							dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
							dlg.Title = "Ulozit konfiguraci"
							dlg.FilePath = "config.json"
							ok, err := dlg.ShowSave(mw)
							if err != nil || !ok {
								return
							}
							data, err := json.MarshalIndent(collectCfg(), "", "  ")
							if err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
								return
							}
							if err := os.WriteFile(dlg.FilePath, data, 0644); err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
							}
						},
					},
					HSpacer{},
					PushButton{
						Text: "  SPUSTIT  ",
						OnClicked: func() {
							finalCfg := collectCfg()
							selected := make(map[string]bool, len(items))
							for i, item := range items {
								selected[item.Key] = checkPtrs[i].Checked()
							}
							steps, features := buildStepsAndFeatures(selected)
							finalCfg.Features = features
							_ = config.Save(finalCfg, cfgPath)
							result = formResult{
								action:  "run",
								cfg:     finalCfg,
								cfgPath: cfgPath,
								steps:   steps,
							}
							mw.Close()
						},
					},
				},
			},
		},
	}.Create()); err != nil {
		walk.MsgBox(nil, "Chyba", fmt.Sprintf("Nelze vytvorit okno: %v", err), walk.MsgBoxIconError)
		return result
	}

	mw.Run()
	return result
}

// --------------------------------------------------------------------------
// Phase 2 – Live run view
// --------------------------------------------------------------------------

// stepIndicator tracks a step's label and current status.
type stepIndicator struct {
	label  *walk.Label
	stepID string
	status string // "pending", "running", "ok", "error", "skipped", "cancelled"
}

// runPhase runs steps and streams live log output.
// resuming=true changes the title and initial status message.
// Returns (results, needsReboot).
func runPhase(runCfg runner.RunConfig, steps []runner.Step, resuming bool) ([]runner.Result, bool) {
	var (
		mw        *walk.MainWindow
		statusLbl *walk.Label
		logTE     *walk.TextEdit
		cancelFn  context.CancelFunc
	)

	// Build step indicator labels (one per step, in order)
	indicators := make([]stepIndicator, len(steps))
	stepIndex := make(map[string]int, len(steps))
	indWidgets := make([]Widget, len(steps))
	for i, s := range steps {
		indicators[i] = stepIndicator{stepID: s.ID, status: "pending"}
		stepIndex[s.ID] = i
		indWidgets[i] = Label{
			AssignTo: &indicators[i].label,
			Text:     s.Num + " \u00b7",
			Font:     Font{Family: "Consolas", PointSize: 9},
			MinSize:  Size{Width: 38},
		}
	}

	title := "xetup \u2014 probiha instalace"
	initialStatus := "Spoustim..."
	if resuming {
		title = "xetup \u2014 pokracuji po restartu"
		initialStatus = "Pokracuji po restartu..."
	}

	if err := (MainWindow{
		AssignTo: &mw,
		Title:    title,
		Size:     Size{Width: 760, Height: 740},
		Layout:   VBox{},
		Children: []Widget{
			Label{AssignTo: &statusLbl, Text: initialStatus},
			// Step progress strip
			Composite{
				Layout:   HBox{MarginsZero: true},
				Children: indWidgets,
			},
			HSeparator{},
			TextEdit{
				AssignTo: &logTE,
				ReadOnly: true,
				VScroll:  true,
				Font:     Font{Family: "Consolas", PointSize: 9},
				MinSize:  Size{Height: 530},
			},
			HSeparator{},
			Composite{
				Layout: HBox{MarginsZero: true},
				Children: []Widget{
					HSpacer{},
					PushButton{
						Text: "  ZASTAVIT  ",
						OnClicked: func() {
							if cancelFn != nil {
								cancelFn()
							}
						},
					},
				},
			},
		},
	}.Create()); err != nil {
		return nil, false
	}

	// updateIndicator refreshes a step label's text and color — call inside Synchronize.
	updateIndicator := func(idx int) {
		ind := &indicators[idx]
		s := steps[idx]
		switch ind.status {
		case "running":
			ind.label.SetText(s.Num + " \u25ba") // ►
			ind.label.SetTextColor(walk.RGB(30, 144, 255))
		case "ok":
			ind.label.SetText(s.Num + " \u2713") // ✓
			ind.label.SetTextColor(walk.RGB(0, 180, 0))
		case "error":
			ind.label.SetText(s.Num + " \u2717") // ✗
			ind.label.SetTextColor(walk.RGB(220, 50, 50))
		case "skipped", "cancelled":
			ind.label.SetText(s.Num + " \u2013") // –
			ind.label.SetTextColor(walk.RGB(140, 140, 140))
		default:
			ind.label.SetText(s.Num + " \u00b7") // ·
			ind.label.SetTextColor(walk.RGB(180, 180, 180))
		}
	}

	var (
		mu      sync.Mutex
		results []runner.Result
		done    = make(chan struct{})
	)

	ctx, cancel := context.WithCancel(context.Background())
	cancelFn = cancel

	mw.Closing().Attach(func(_ *bool, _ walk.CloseReason) {
		cancelFn()
	})

	r := runner.New(
		runCfg,
		func(l runner.LogLine) {
			mw.Synchronize(func() {
				logTE.AppendText(l.Text + "\r\n")
			})
		},
		func(step runner.Step) {
			mw.Synchronize(func() {
				statusLbl.SetText(fmt.Sprintf(
					"Krok %s \u2013 %s...", step.Num, step.Name,
				))
				if idx, ok := stepIndex[step.ID]; ok {
					indicators[idx].status = "running"
					updateIndicator(idx)
				}
			})
		},
		func(res runner.Result) {
			mw.Synchronize(func() {
				statusLbl.SetText(fmt.Sprintf(
					"Krok %s \u2013 %s: %s", res.Step.Num, res.Step.Name, res.Status,
				))
				if idx, ok := stepIndex[res.Step.ID]; ok {
					indicators[idx].status = strings.ToLower(res.Status)
					updateIndicator(idx)
				}
			})
		},
	)

	go func() {
		defer close(done)
		res := r.Run(ctx, steps)
		mu.Lock()
		results = res
		mu.Unlock()
		mw.Synchronize(func() {
			mw.Close()
		})
	}()

	mw.Run()
	cancel()
	<-done

	mu.Lock()
	defer mu.Unlock()

	// Determine whether the last result triggered a reboot
	needsReboot := len(results) > 0 && results[len(results)-1].NeedsReboot
	return results, needsReboot
}

// --------------------------------------------------------------------------
// Pre-reboot helper
// --------------------------------------------------------------------------

// prepareRebootAndRestart saves state, sets up resume infrastructure, and reboots.
func prepareRebootAndRestart(
	cfg config.Config,
	allSteps []runner.Step,
	completedResults []runner.Result,
	cfgPath string,
	runCfg runner.RunConfig,
) {
	pending := pendingStepIDs(allSteps, completedResults)
	st := &state.State{
		Config:       cfg,
		ConfigPath:   cfgPath,
		LogFile:      runCfg.LogFile,
		PendingSteps: pending,
		Results:      toStateResults(completedResults),
	}
	if err := state.Save(st); err != nil {
		walk.MsgBox(nil, "Chyba", "Nelze ulozit stav: "+err.Error(), walk.MsgBoxIconError)
		return
	}
	if err := prereboot.Prepare(); err != nil {
		walk.MsgBox(nil, "Chyba", "Prereboot setup selhal: "+err.Error(), walk.MsgBoxIconError)
		return
	}
	reboot()
}

// pendingStepIDs returns IDs of steps that still need to run.
// A step is pending if it has NeedsReboot=true (re-run after reboot) or
// was not yet reached (not present in completedResults at all).
func pendingStepIDs(allSteps []runner.Step, completedResults []runner.Result) []string {
	done := make(map[string]bool, len(completedResults))
	for _, r := range completedResults {
		if !r.NeedsReboot {
			done[r.Step.ID] = true
		}
	}
	var pending []string
	for _, s := range allSteps {
		if !done[s.ID] {
			pending = append(pending, s.ID)
		}
	}
	return pending
}

// toStateResults converts runner results to state.StepResult for persistence.
// Steps with NeedsReboot=true are excluded (they will run again).
func toStateResults(results []runner.Result) []state.StepResult {
	var sr []state.StepResult
	for _, r := range results {
		if r.NeedsReboot {
			continue
		}
		sr = append(sr, state.StepResult{
			StepID:  r.Step.ID,
			Num:     r.Step.Num,
			Name:    r.Step.Name,
			Status:  r.Status,
			Elapsed: r.Elapsed,
		})
	}
	return sr
}

// --------------------------------------------------------------------------
// Phase 3 – Summary with auto-reboot countdown
// --------------------------------------------------------------------------

const rebootCountdown = 60 // seconds before automatic reboot

// donePhase shows the summary screen.
// currentResults are from the final run; prevResults accumulate earlier rounds.
func donePhase(currentResults []runner.Result, prevResults []state.StepResult) {
	var (
		mw           *walk.MainWindow
		countdownLbl *walk.Label
	)

	// Build a combined ordered list: previous rounds first, current round appended
	type displayRow struct {
		Num     string
		Name    string
		Status  string
		Elapsed time.Duration
	}

	var rows []displayRow
	for _, r := range prevResults {
		rows = append(rows, displayRow{r.Num, r.Name, r.Status, r.Elapsed})
	}
	for _, r := range currentResults {
		if r.NeedsReboot {
			continue // shouldn't happen here but guard anyway
		}
		rows = append(rows, displayRow{r.Step.Num, r.Step.Name, r.Status, r.Elapsed})
	}

	ok, errs, skipped := 0, 0, 0
	widgets := make([]Widget, 0, len(rows))
	for _, row := range rows {
		var icon string
		switch row.Status {
		case "OK":
			ok++
			icon = "OK    "
		case "ERROR":
			errs++
			icon = "ERR   "
		default:
			skipped++
			icon = "\u2013     "
		}
		text := icon + row.Num + " \u2013 " + row.Name
		if row.Elapsed > 0 {
			text += fmt.Sprintf("  (%s)", row.Elapsed.Round(time.Second))
		}
		widgets = append(widgets, Label{
			Text: text,
			Font: Font{Family: "Consolas", PointSize: 9},
		})
	}

	summaryText := fmt.Sprintf("OK: %d   CHYBY: %d   PRESKOCENO: %d", ok, errs, skipped)

	cancelReboot := make(chan struct{})

	if err := (MainWindow{
		AssignTo: &mw,
		Title:    "xetup \u2014 hotovo",
		Size:     Size{Width: 760, Height: 740},
		Layout:   VBox{},
		Children: []Widget{
			Label{
				Text:      "Hotovo",
				Alignment: AlignHCenterVNear,
				Font:      Font{Bold: true, PointSize: 12},
			},
			HSeparator{},
			ScrollView{
				MinSize:  Size{Height: 510},
				Layout:   VBox{MarginsZero: true},
				Children: widgets,
			},
			HSeparator{},
			Label{
				Text:      summaryText,
				Alignment: AlignHCenterVNear,
				Font:      Font{Bold: true},
			},
			Label{
				AssignTo:  &countdownLbl,
				Text:      fmt.Sprintf("Restart za %ds...", rebootCountdown),
				Alignment: AlignHCenterVNear,
			},
			Composite{
				Layout: HBox{MarginsZero: true},
				Children: []Widget{
					HSpacer{},
					PushButton{
						Text: "  Restartovat ted  ",
						OnClicked: func() {
							close(cancelReboot)
							reboot()
							mw.Close()
						},
					},
					PushButton{
						Text: "  Zrusit restart  ",
						OnClicked: func() {
							select {
							case <-cancelReboot:
							default:
								close(cancelReboot)
							}
							countdownLbl.SetText("Restart zrusen.")
						},
					},
					HSpacer{},
				},
			},
		},
	}.Create()); err != nil {
		return
	}

	// Countdown goroutine
	go func() {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()
		remaining := rebootCountdown
		for {
			select {
			case <-cancelReboot:
				return
			case <-ticker.C:
				remaining--
				r := remaining
				mw.Synchronize(func() {
					if r > 0 {
						countdownLbl.SetText(fmt.Sprintf("Restart za %ds...", r))
					} else {
						countdownLbl.SetText("Restartuji...")
					}
				})
				if remaining <= 0 {
					reboot()
					mw.Synchronize(func() { mw.Close() })
					return
				}
			}
		}
	}()

	mw.Run()
}

// reboot issues an immediate Windows restart.
func reboot() {
	exec.Command("shutdown", "/r", "/t", "0").Run() //nolint:errcheck
}

// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------

func itemEnabled(cfg config.Config, item runner.SelectableItem) bool {
	if item.FeatureID == "" {
		if v, ok := cfg.Steps[item.StepID]; ok {
			return v
		}
		return true
	}
	if feats, ok := cfg.Features[item.StepID]; ok {
		if v, ok2 := feats[item.FeatureID]; ok2 {
			return v
		}
	}
	return true
}

func buildStepsAndFeatures(selected map[string]bool) ([]runner.Step, config.Features) {
	items := runner.AllSelectableItems()
	features := make(config.Features)
	stepOn := make(map[string]bool)

	for _, item := range items {
		if item.FeatureID == "" {
			stepOn[item.StepID] = selected[item.Key]
		} else {
			if features[item.StepID] == nil {
				features[item.StepID] = make(map[string]bool)
			}
			features[item.StepID][item.FeatureID] = selected[item.Key]
			if selected[item.Key] {
				stepOn[item.StepID] = true
			}
		}
	}

	allSteps := runner.AllSteps()
	steps := make([]runner.Step, len(allSteps))
	for i, s := range allSteps {
		s.Enabled = stepOn[s.ID]
		steps[i] = s
	}
	return steps, features
}

func profileIndex(p string) int {
	switch p {
	case "admin":
		return 1
	case "user":
		return 2
	default:
		return 0
	}
}


The file /opt/xetup/internal/gui/gui.go has been updated successfully.

//go:build windows

// Package gui implements the Walk-based graphical interface for xetup.
//
// Three sequential phases, each with its own window:
//  1. Config form  – PC name, product key, profile, step checkboxes,
//                    load/save config buttons for per-client presets
//  2. Live run     – real-time log streamed from PowerShell scripts
//  3. Summary      – per-step OK / ERROR / SKIPPED with elapsed time
//
// Resume mode (--resume or state file present): skips the config form and
// runs only the pending steps recorded in the state file. On completion,
// disables autologon and removes the X9-Resume scheduled task.
package gui

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"strings"
	"sync"
	"time"

	"github.com/lxn/walk"
	. "github.com/lxn/walk/declarative"

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/prereboot"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
	"git.xetup.x9.cz/x9/xetup/internal/state"
)

// Run opens the xetup window and blocks until the user closes it.
func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
	// Resume mode: state file present from a previous interrupted run
	if st, err := state.Load(); err == nil {
		resumePhase(st, runCfg)
		return
	}

	// Normal flow: config form → run → summary
	for {
		res := formPhase(cfg, runCfg, cfgPath)
		switch res.action {
		case "quit":
			return
		case "reload":
			cfg = res.cfg
			cfgPath = res.cfgPath
		case "run":
			runCfg.ProfileType = res.cfg.Deployment.ProfileType
			results, needsReboot := runPhase(runCfg, res.steps, false)
			if needsReboot {
				prepareRebootAndRestart(res.cfg, res.steps, results, cfgPath, runCfg)
				return
			}
			donePhase(results, nil)
			return
		}
	}
}

// --------------------------------------------------------------------------
// Resume mode
// --------------------------------------------------------------------------

func resumePhase(st *state.State, runCfg runner.RunConfig) {
	runCfg.LogFile = st.LogFile

	steps := runner.StepsByIDs(st.PendingSteps)
	results, needsReboot := runPhase(runCfg, steps, true)

	// Accumulate results (exclude NeedsReboot step – it will run again)
	newResults := append(st.Results, toStateResults(results)...) //nolint:gocritic

	if needsReboot {
		// Update state and reboot – infrastructure already in place (running as adminx9)
		pending := pendingStepIDs(steps, results)
		newSt := &state.State{
			Config:       st.Config,
			ConfigPath:   st.ConfigPath,
			LogFile:      st.LogFile,
			PendingSteps: pending,
			Results:      newResults,
		}
		if err := state.Save(newSt); err != nil {
			walk.MsgBox(nil, "Chyba", "Nelze ulozit stav: "+err.Error(), walk.MsgBoxIconError)
		}
		reboot()
		return
	}

	// All steps complete
	prereboot.Cleanup()
	_ = state.Delete()
	donePhase(results, st.Results)
}

// --------------------------------------------------------------------------
// Phase 1 – Config form
// --------------------------------------------------------------------------

type formResult struct {
	action  string // "run", "reload", "quit"
	cfg     config.Config
	cfgPath string
	steps   []runner.Step
}

func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formResult {
	var mw *walk.MainWindow
	result := formResult{action: "quit", cfgPath: cfgPath}

	var (
		pcNameLE     *walk.LineEdit
		pcDescLE     *walk.LineEdit
		productKeyLE *walk.LineEdit
		profileCB    *walk.ComboBox
	)

	items := runner.AllSelectableItems()
	checkPtrs := make([]*walk.CheckBox, len(items))
	checkWidgets := make([]Widget, len(items))
	for i, item := range items {
		i := i // capture for closure
		checkWidgets[i] = CheckBox{
			AssignTo: &checkPtrs[i],
			Text:     item.Label,
			Checked:  itemEnabled(cfg, item),
		}
	}

	collectCfg := func() config.Config {
		out := cfg
		out.Deployment.PCName = pcNameLE.Text()
		out.Deployment.PCDescription = pcDescLE.Text()
		out.Activation.ProductKey = productKeyLE.Text()
		profiles := []string{"default", "admin", "user"}
		if idx := profileCB.CurrentIndex(); idx >= 0 && idx < len(profiles) {
			out.Deployment.ProfileType = profiles[idx]
		}
		selected := make(map[string]bool, len(items))
		for i, item := range items {
			selected[item.Key] = checkPtrs[i].Checked()
		}
		_, features := buildStepsAndFeatures(selected)
		out.Features = features
		return out
	}

	if err := (MainWindow{
		AssignTo: &mw,
		Title:    "xetup \u2014 Windows deployment",
		Size:     Size{Width: 760, Height: 740},
		Layout:   VBox{},
		Children: []Widget{
			// ── Form fields ──────────────────────────────────────────────────
			Composite{
				Layout: Grid{Columns: 2},
				Children: []Widget{
					Label{Text: "PC jmeno:"},
					LineEdit{
						AssignTo:  &pcNameLE,
						Text:      cfg.Deployment.PCName,
						CueBanner: "napr. NB-KLIENT-01  (prazdne = neprejmenovat)",
					},
					Label{Text: "Popis PC:"},
					LineEdit{
						AssignTo:  &pcDescLE,
						Text:      cfg.Deployment.PCDescription,
						CueBanner: "napr. PC recepce",
					},
					Label{Text: "Product Key:"},
					LineEdit{
						AssignTo:  &productKeyLE,
						Text:      cfg.Activation.ProductKey,
						CueBanner: "prazdne = OA3 / GVLK fallback",
					},
					Label{Text: "Profil:"},
					ComboBox{
						AssignTo:     &profileCB,
						Model:        []string{"default", "admin", "user"},
						CurrentIndex: profileIndex(cfg.Deployment.ProfileType),
					},
				},
			},
			HSeparator{},
			// ── Step checkboxes ──────────────────────────────────────────────
			Label{Text: "Kroky a nastaveni (odskrtnete co nechcete spustit):"},
			ScrollView{
				MinSize:  Size{Height: 400},
				Layout:   VBox{MarginsZero: true},
				Children: checkWidgets,
			},
			HSeparator{},
			// ── Buttons ──────────────────────────────────────────────────────
			Composite{
				Layout: HBox{MarginsZero: true},
				Children: []Widget{
					PushButton{
						Text: "Nacist config...",
						OnClicked: func() {
							dlg := new(walk.FileDialog)
							dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
							dlg.Title = "Nacist konfiguraci"
							ok, err := dlg.ShowOpen(mw)
							if err != nil || !ok {
								return
							}
							data, err := os.ReadFile(dlg.FilePath)
							if err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
								return
							}
							newCfg := config.DefaultConfig()
							if err := json.Unmarshal(data, &newCfg); err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
								return
							}
							result = formResult{
								action:  "reload",
								cfg:     newCfg,
								cfgPath: dlg.FilePath,
							}
							mw.Close()
						},
					},
					PushButton{
						Text: "Ulozit config...",
						OnClicked: func() {
							dlg := new(walk.FileDialog)
							dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
							dlg.Title = "Ulozit konfiguraci"
							dlg.FilePath = "config.json"
							ok, err := dlg.ShowSave(mw)
							if err != nil || !ok {
								return
							}
							data, err := json.MarshalIndent(collectCfg(), "", "  ")
							if err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
								return
							}
							if err := os.WriteFile(dlg.FilePath, data, 0644); err != nil {
								walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
							}
						},
					},
					HSpacer{},
					PushButton{
						Text: "  SPUSTIT  ",
						OnClicked: func() {
							finalCfg := collectCfg()
							selected := make(map[string]bool, len(items))
							for i, item := range items {
								selected[item.Key] = checkPtrs[i].Checked()
							}
							steps, features := buildStepsAndFeatures(selected)
							finalCfg.Features = features
							_ = config.Save(finalCfg, cfgPath)
							result = formResult{
								action:  "run",
								cfg:     finalCfg,
								cfgPath: cfgPath,
								steps:   steps,
							}
							mw.Close()
						},
					},
				},
			},
		},
	}.Create()); err != nil {
		walk.MsgBox(nil, "Chyba", fmt.Sprintf("Nelze vytvorit okno: %v", err), walk.MsgBoxIconError)
		return result
	}

	mw.Run()
	return result
}

// --------------------------------------------------------------------------
// Phase 2 – Live run view
// --------------------------------------------------------------------------

// stepIndicator tracks a step's label and current status.
type stepIndicator struct {
	label  *walk.Label
	stepID string
	status string // "pending", "running", "ok", "error", "skipped", "cancelled"
}

// runPhase runs steps and streams live log output.
// resuming=true changes the title and initial status message.
// Returns (results, needsReboot).
func runPhase(runCfg runner.RunConfig, steps []runner.Step, resuming bool) ([]runner.Result, bool) {
	var (
		mw        *walk.MainWindow
		statusLbl *walk.Label
		logTE     *walk.TextEdit
		cancelFn  context.CancelFunc
	)

	// Build step indicator labels (one per step, in order)
	indicators := make([]stepIndicator, len(steps))
	stepIndex := make(map[string]int, len(steps))
	indWidgets := make([]Widget, len(steps))
	for i, s := range steps {
		indicators[i] = stepIndicator{stepID: s.ID, status: "pending"}
		stepIndex[s.ID] = i
		indWidgets[i] = Label{
			AssignTo: &indicators[i].label,
			Text:     s.Num + " \u00b7",
			Font:     Font{Family: "Consolas", PointSize: 9},
			MinSize:  Size{Width: 38},
		}
	}

	title := "xetup \u2014 probiha instalace"
	initialStatus := "Spoustim..."
	if resuming {
		title = "xetup \u2014 pokracuji po restartu"
		initialStatus = "Pokracuji po restartu..."
	}

	if err := (MainWindow{
		AssignTo: &mw,
		Title:    title,
		Size:     Size{Width: 760, Height: 740},
		Layout:   VBox{},
		Children: []Widget{
			Label{AssignTo: &statusLbl, Text: initialStatus},
			// Step progress strip
			Composite{
				Layout:   HBox{MarginsZero: true},
				Children: indWidgets,
			},
			HSeparator{},
			TextEdit{
				AssignTo: &logTE,
				ReadOnly: true,
				VScroll:  true,
				Font:     Font{Family: "Consolas", PointSize: 9},
				MinSize:  Size{Height: 530},
			},
			HSeparator{},
			Composite{
				Layout: HBox{MarginsZero: true},
				Children: []Widget{
					HSpacer{},
					PushButton{
						Text: "  ZASTAVIT  ",
						OnClicked: func() {
							if cancelFn != nil {
								cancelFn()
							}
						},
					},
				},
			},
		},
	}.Create()); err != nil {
		return nil, false
	}

	// updateIndicator refreshes a step label's text and color — call inside Synchronize.
	updateIndicator := func(idx int) {
		ind := &indicators[idx]
		s := steps[idx]
		switch ind.status {
		case "running":
			ind.label.SetText(s.Num + " \u25ba") // ►
			ind.label.SetTextColor(walk.RGB(30, 144, 255))
		case "ok":
			ind.label.SetText(s.Num + " \u2713") // ✓
			ind.label.SetTextColor(walk.RGB(0, 180, 0))
		case "error":
			ind.label.SetText(s.Num + " \u2717") // ✗
			ind.label.SetTextColor(walk.RGB(220, 50, 50))
		case "skipped", "cancelled":
			ind.label.SetText(s.Num + " \u2013") // –
			ind.label.SetTextColor(walk.RGB(140, 140, 140))
		default:
			ind.label.SetText(s.Num + " \u00b7") // ·
			ind.label.SetTextColor(walk.RGB(180, 180, 180))
		}
	}

	var (
		mu      sync.Mutex
		results []runner.Result
		done    = make(chan struct{})
	)

	ctx, cancel := context.WithCancel(context.Background())
	cancelFn = cancel

	mw.Closing().Attach(func(_ *bool, _ walk.CloseReason) {
		cancelFn()
	})

	r := runner.New(
		runCfg,
		func(l runner.LogLine) {
			mw.Synchronize(func() {
				logTE.AppendText(l.Text + "\r\n")
			})
		},
		func(step runner.Step) {
			mw.Synchronize(func() {
				statusLbl.SetText(fmt.Sprintf(
					"Krok %s \u2013 %s...", step.Num, step.Name,
				))
				if idx, ok := stepIndex[step.ID]; ok {
					indicators[idx].status = "running"
					updateIndicator(idx)
				}
			})
		},
		func(res runner.Result) {
			mw.Synchronize(func() {
				statusLbl.SetText(fmt.Sprintf(
					"Krok %s \u2013 %s: %s", res.Step.Num, res.Step.Name, res.Status,
				))
				if idx, ok := stepIndex[res.Step.ID]; ok {
					indicators[idx].status = strings.ToLower(res.Status)
					updateIndicator(idx)
				}
			})
		},
	)

	go func() {
		defer close(done)
		res := r.Run(ctx, steps)
		mu.Lock()
		results = res
		mu.Unlock()
		mw.Synchronize(func() {
			mw.Close()
		})
	}()

	mw.Run()
	cancel()
	<-done

	mu.Lock()
	defer mu.Unlock()

	// Determine whether the last result triggered a reboot
	needsReboot := len(results) > 0 && results[len(results)-1].NeedsReboot
	return results, needsReboot
}

// --------------------------------------------------------------------------
// Pre-reboot helper
// --------------------------------------------------------------------------

// prepareRebootAndRestart saves state, sets up resume infrastructure, and reboots.
func prepareRebootAndRestart(
	cfg config.Config,
	allSteps []runner.Step,
	completedResults []runner.Result,
	cfgPath string,
	runCfg runner.RunConfig,
) {
	pending := pendingStepIDs(allSteps, completedResults)
	st := &state.State{
		Config:       cfg,
		ConfigPath:   cfgPath,
		LogFile:      runCfg.LogFile,
		PendingSteps: pending,
		Results:      toStateResults(completedResults),
	}
	if err := state.Save(st); err != nil {
		walk.MsgBox(nil, "Chyba", "Nelze ulozit stav: "+err.Error(), walk.MsgBoxIconError)
		return
	}
	if err := prereboot.Prepare(); err != nil {
		walk.MsgBox(nil, "Chyba", "Prereboot setup selhal: "+err.Error(), walk.MsgBoxIconError)
		return
	}
	reboot()
}

// pendingStepIDs returns IDs of steps that still need to run.
// A step is pending if it has NeedsReboot=true (re-run after reboot) or
// was not yet reached (not present in completedResults at all).
func pendingStepIDs(allSteps []runner.Step, completedResults []runner.Result) []string {
	done := make(map[string]bool, len(completedResults))
	for _, r := range completedResults {
		if !r.NeedsReboot {
			done[r.Step.ID] = true
		}
	}
	var pending []string
	for _, s := range allSteps {
		if !done[s.ID] {
			pending = append(pending, s.ID)
		}
	}
	return pending
}

// toStateResults converts runner results to state.StepResult for persistence.
// Steps with NeedsReboot=true are excluded (they will run again).
func toStateResults(results []runner.Result) []state.StepResult {
	var sr []state.StepResult
	for _, r := range results {
		if r.NeedsReboot {
			continue
		}
		sr = append(sr, state.StepResult{
			StepID:  r.Step.ID,
			Num:     r.Step.Num,
			Name:    r.Step.Name,
			Status:  r.Status,
			Elapsed: r.Elapsed,
		})
	}
	return sr
}

// --------------------------------------------------------------------------
// Phase 3 – Summary with auto-reboot countdown
// --------------------------------------------------------------------------

const rebootCountdown = 60 // seconds before automatic reboot

// donePhase shows the summary screen.
// currentResults are from the final run; prevResults accumulate earlier rounds.
func donePhase(currentResults []runner.Result, prevResults []state.StepResult) {
	var (
		mw           *walk.MainWindow
		countdownLbl *walk.Label
	)

	// Build a combined ordered list: previous rounds first, current round appended
	type displayRow struct {
		Num     string
		Name    string
		Status  string
		Elapsed time.Duration
	}

	var rows []displayRow
	for _, r := range prevResults {
		rows = append(rows, displayRow{r.Num, r.Name, r.Status, r.Elapsed})
	}
	for _, r := range currentResults {
		if r.NeedsReboot {
			continue // shouldn't happen here but guard anyway
		}
		rows = append(rows, displayRow{r.Step.Num, r.Step.Name, r.Status, r.Elapsed})
	}

	ok, errs, skipped := 0, 0, 0
	widgets := make([]Widget, 0, len(rows))
	for _, row := range rows {
		var icon string
		switch row.Status {
		case "OK":
			ok++
			icon = "OK    "
		case "ERROR":
			errs++
			icon = "ERR   "
		default:
			skipped++
			icon = "\u2013     "
		}
		text := icon + row.Num + " \u2013 " + row.Name
		if row.Elapsed > 0 {
			text += fmt.Sprintf("  (%s)", row.Elapsed.Round(time.Second))
		}
		widgets = append(widgets, Label{
			Text: text,
			Font: Font{Family: "Consolas", PointSize: 9},
		})
	}

	summaryText := fmt.Sprintf("OK: %d   CHYBY: %d   PRESKOCENO: %d", ok, errs, skipped)

	cancelReboot := make(chan struct{})

	if err := (MainWindow{
		AssignTo: &mw,
		Title:    "xetup \u2014 hotovo",
		Size:     Size{Width: 760, Height: 740},
		Layout:   VBox{},
		Children: []Widget{
			Label{
				Text:      "Hotovo",
				Alignment: AlignHCenterVNear,
				Font:      Font{Bold: true, PointSize: 12},
			},
			HSeparator{},
			ScrollView{
				MinSize:  Size{Height: 510},
				Layout:   VBox{MarginsZero: true},
				Children: widgets,
			},
			HSeparator{},
			Label{
				Text:      summaryText,
				Alignment: AlignHCenterVNear,
				Font:      Font{Bold: true},
			},
			Label{
				AssignTo:  &countdownLbl,
				Text:      fmt.Sprintf("Restart za %ds...", rebootCountdown),
				Alignment: AlignHCenterVNear,
			},
			Composite{
				Layout: HBox{MarginsZero: true},
				Children: []Widget{
					HSpacer{},
					PushButton{
						Text: "  Restartovat ted  ",
						OnClicked: func() {
							close(cancelReboot)
							reboot()
							mw.Close()
						},
					},
					PushButton{
						Text: "  Zrusit restart  ",
						OnClicked: func() {
							select {
							case <-cancelReboot:
							default:
								close(cancelReboot)
							}
							countdownLbl.SetText("Restart zrusen.")
						},
					},
					HSpacer{},
				},
			},
		},
	}.Create()); err != nil {
		return
	}

	// Countdown goroutine
	go func() {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()
		remaining := rebootCountdown
		for {
			select {
			case <-cancelReboot:
				return
			case <-ticker.C:
				remaining--
				r := remaining
				mw.Synchronize(func() {
					if r > 0 {
						countdownLbl.SetText(fmt.Sprintf("Restart za %ds...", r))
					} else {
						countdownLbl.SetText("Restartuji...")
					}
				})
				if remaining <= 0 {
					reboot()
					mw.Synchronize(func() { mw.Close() })
					return
				}
			}
		}
	}()

	mw.Run()
}

// reboot issues an immediate Windows restart.
func reboot() {
	exec.Command("shutdown", "/r", "/t", "0").Run() //nolint:errcheck
}

// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------

func itemEnabled(cfg config.Config, item runner.SelectableItem) bool {
	if item.FeatureID == "" {
		if v, ok := cfg.Steps[item.StepID]; ok {
			return v
		}
		return true
	}
	if feats, ok := cfg.Features[item.StepID]; ok {
		if v, ok2 := feats[item.FeatureID]; ok2 {
			return v
		}
	}
	return true
}

func buildStepsAndFeatures(selected map[string]bool) ([]runner.Step, config.Features) {
	items := runner.AllSelectableItems()
	features := make(config.Features)
	stepOn := make(map[string]bool)

	for _, item := range items {
		if item.FeatureID == "" {
			stepOn[item.StepID] = selected[item.Key]
		} else {
			if features[item.StepID] == nil {
				features[item.StepID] = make(map[string]bool)
			}
			features[item.StepID][item.FeatureID] = selected[item.Key]
			if selected[item.Key] {
				stepOn[item.StepID] = true
			}
		}
	}

	allSteps := runner.AllSteps()
	steps := make([]runner.Step, len(allSteps))
	for i, s := range allSteps {
		s.Enabled = stepOn[s.ID]
		steps[i] = s
	}
	return steps, features
}

func profileIndex(p string) int {
	switch p {
	case "admin":
		return 1
	case "user":
		return 2
	default:
		return 0
	}
}

+// Resume mode (--resume or state file present): skips the config form and

+// runs only the pending steps recorded in the state file. On completion,

+// disables autologon and removes the X9-Resume scheduled task.

+  // Resume mode: state file present from a previous interrupted run

+  if st, err := state.Load(); err == nil {

+  // Normal flow: config form → run → summary

-      results := runPhase(runCfg, res.steps)

+      results, needsReboot := runPhase(runCfg, res.steps, false)

+        prepareRebootAndRestart(res.cfg, res.steps, results, cfgPath, runCfg)

+func resumePhase(st *state.State, runCfg runner.RunConfig) {

+  steps := runner.StepsByIDs(st.PendingSteps)

+  results, needsReboot := runPhase(runCfg, steps, true)

+  // Accumulate results (exclude NeedsReboot step – it will run again)

+  newResults := append(st.Results, toStateResults(results)...) //nolint:gocritic

+    // Update state and reboot – infrastructure already in place (running as adminx9)

+    pending := pendingStepIDs(steps, results)

+    if err := state.Save(newSt); err != nil {

+      walk.MsgBox(nil, "Chyba", "Nelze ulozit stav: "+err.Error(), walk.MsgBoxIconError)

-// stepIndicator tracks a step's label and blink state.

+// stepIndicator tracks a step's label and current status.

-func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {

+// runPhase runs steps and streams live log output.

+// resuming=true changes the title and initial status message.

+func runPhase(runCfg runner.RunConfig, steps []runner.Step, resuming bool) ([]runner.Result, bool) {

-  stepIndex  := make(map[string]int, len(steps)) // stepID → index

+  stepIndex := make(map[string]int, len(steps))

indWidgets := make([]Widget, len(steps))

+  title := "xetup \u2014 probiha instalace"

+    title = "xetup \u2014 pokracuji po restartu"

+    initialStatus = "Pokracuji po restartu..."

-    Title:    "xetup \u2014 probiha instalace",

Size:     Size{Width: 760, Height: 740},

-      Label{AssignTo: &statusLbl, Text: "Spoustim..."},

+      Label{AssignTo: &statusLbl, Text: initialStatus},

-  // updateIndicator refreshes label text and color — call inside Synchronize.

+  // updateIndicator refreshes a step label's text and color — call inside Synchronize.

ind.label.SetText(s.Num + " \u25ba") // ►

-      ind.label.SetTextColor(walk.RGB(30, 144, 255)) // dodger blue

+      ind.label.SetTextColor(walk.RGB(30, 144, 255))

ind.label.SetText(s.Num + " \u2713") // ✓

+  // Determine whether the last result triggered a reboot

+  needsReboot := len(results) > 0 && results[len(results)-1].NeedsReboot

+// prepareRebootAndRestart saves state, sets up resume infrastructure, and reboots.

+  pending := pendingStepIDs(allSteps, completedResults)

+    Results:      toStateResults(completedResults),

+  if err := state.Save(st); err != nil {

+  if err := prereboot.Prepare(); err != nil {

+    walk.MsgBox(nil, "Chyba", "Prereboot setup selhal: "+err.Error(), walk.MsgBoxIconError)

+// pendingStepIDs returns IDs of steps that still need to run.

+// A step is pending if it has NeedsReboot=true (re-run after reboot) or

+// was not yet reached (not present in completedResults at all).

+func pendingStepIDs(allSteps []runner.Step, completedResults []runner.Result) []string {

+  done := make(map[string]bool, len(completedResults))

+// toStateResults converts runner results to state.StepResult for persistence.

+// Steps with NeedsReboot=true are excluded (they will run again).

+func toStateResults(results []runner.Result) []state.StepResult {

// Phase 3 – Summary with auto-reboot countdown

-func donePhase(results []runner.Result) {

+// currentResults are from the final run; prevResults accumulate earlier rounds.

+func donePhase(currentResults []runner.Result, prevResults []state.StepResult) {

-  rows := make([]Widget, 0, len(results))

+  // Build a combined ordered list: previous rounds first, current round appended

+    rows = append(rows, displayRow{r.Num, r.Name, r.Status, r.Elapsed})

+      continue // shouldn't happen here but guard anyway

+    rows = append(rows, displayRow{r.Step.Num, r.Step.Name, r.Status, r.Elapsed})

+  widgets := make([]Widget, 0, len(rows))

-    text := icon + res.Step.Num + " \u2013 " + res.Step.Name

-      text += fmt.Sprintf("  (%s)", res.Elapsed.Round(time.Second))

+    text := icon + row.Num + " \u2013 " + row.Name

+      text += fmt.Sprintf("  (%s)", row.Elapsed.Round(time.Second))

Font: Font{Family: "Consolas", PointSize: 9},

-  // cancelled by "Zrusit restart" button

-              close(cancelReboot) // signal goroutine to stop ticker

-              case <-cancelReboot: // already closed

-  items    := runner.AllSelectableItems()


Now update `cmd/xetup/main.go` to use state in resume mode:


// Command xetup is the GUI launcher for Windows deployment.
//
// All PowerShell scripts and assets are embedded in the binary and extracted
// to a temp directory at runtime. The GUI collects configuration, streams
// live log output while the scripts run, and shows a final summary.
//
// Cross-compile for Windows (requires MinGW):
//
//	CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
//	  GOOS=windows GOARCH=amd64 \
//	  go build -ldflags="-s -w -H windowsgui" -o xetup.exe ./cmd/xetup
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	xetupembed "git.xetup.x9.cz/x9/xetup"
	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/gui"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
	"git.xetup.x9.cz/x9/xetup/internal/state"
)

func main() {
	// Temp working directory – cleaned up on exit
	tmpDir, err := os.MkdirTemp("", "xetup-*")
	if err != nil {
		log.Fatalf("Cannot create temp dir: %v", err)
	}
	defer os.RemoveAll(tmpDir)

	// Extract embedded scripts and assets (required in both initial and resume mode)
	if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
		log.Fatalf("Failed to extract scripts: %v", err)
	}
	if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
		log.Fatalf("Failed to extract assets: %v", err)
	}

	// Determine config: use stored state in resume mode, load from file otherwise
	cfgPath := config.ConfigPath()
	var cfg config.Config

	if st, err := state.Load(); err == nil {
		// Resume mode: use the config that was active when the run started
		cfg = st.Config
		if st.ConfigPath != "" {
			cfgPath = st.ConfigPath
		}
	} else {
		// Normal mode: load config from disk (falls back to defaults when absent)
		cfg, err = config.Load(cfgPath)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
		}
	}

	// Write runtime config JSON so PowerShell scripts can read it
	cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
	if err != nil {
		log.Fatalf("Failed to write runtime config: %v", err)
	}

	runCfg := runner.RunConfig{
		ScriptsDir:  filepath.Join(tmpDir, "scripts"),
		ConfigPath:  cfgRuntimePath,
		LogFile:     `C:\Windows\Setup\Scripts\Deploy.log`,
		ProfileType: cfg.Deployment.ProfileType,
	}

	gui.Run(cfg, runCfg, cfgPath)
}


The file /opt/xetup/cmd/xetup/main.go has been updated successfully.

// Command xetup is the GUI launcher for Windows deployment.
//
// All PowerShell scripts and assets are embedded in the binary and extracted
// to a temp directory at runtime. The GUI collects configuration, streams
// live log output while the scripts run, and shows a final summary.
//
// Cross-compile for Windows (requires MinGW):
//
//	CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
//	  GOOS=windows GOARCH=amd64 \
//	  go build -ldflags="-s -w -H windowsgui" -o xetup.exe ./cmd/xetup
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	xetupembed "git.xetup.x9.cz/x9/xetup"
	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/gui"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
	"git.xetup.x9.cz/x9/xetup/internal/state"
)

func main() {
	// Temp working directory – cleaned up on exit
	tmpDir, err := os.MkdirTemp("", "xetup-*")
	if err != nil {
		log.Fatalf("Cannot create temp dir: %v", err)
	}
	defer os.RemoveAll(tmpDir)

	// Extract embedded scripts and assets (required in both initial and resume mode)
	if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
		log.Fatalf("Failed to extract scripts: %v", err)
	}
	if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
		log.Fatalf("Failed to extract assets: %v", err)
	}

	// Determine config: use stored state in resume mode, load from file otherwise
	cfgPath := config.ConfigPath()
	var cfg config.Config

	if st, err := state.Load(); err == nil {
		// Resume mode: use the config that was active when the run started
		cfg = st.Config
		if st.ConfigPath != "" {
			cfgPath = st.ConfigPath
		}
	} else {
		// Normal mode: load config from disk (falls back to defaults when absent)
		cfg, err = config.Load(cfgPath)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
		}
	}

	// Write runtime config JSON so PowerShell scripts can read it
	cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
	if err != nil {
		log.Fatalf("Failed to write runtime config: %v", err)
	}

	runCfg := runner.RunConfig{
		ScriptsDir:  filepath.Join(tmpDir, "scripts"),
		ConfigPath:  cfgRuntimePath,
		LogFile:     `C:\Windows\Setup\Scripts\Deploy.log`,
		ProfileType: cfg.Deployment.ProfileType,
	}

	gui.Run(cfg, runCfg, cfgPath)
}

-  // Load config (falls back to defaults when config.json is missing)

-    fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)

-  // Extract embedded scripts and assets

+  // Extract embedded scripts and assets (required in both initial and resume mode)

+  // Determine config: use stored state in resume mode, load from file otherwise

+  if st, err := state.Load(); err == nil {

+    // Resume mode: use the config that was active when the run started

+    // Normal mode: load config from disk (falls back to defaults when absent)

+      fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)

// Command xetup is the GUI launcher for Windows deployment.
//
// All PowerShell scripts and assets are embedded in the binary and extracted
// to a temp directory at runtime. The GUI collects configuration, streams
// live log output while the scripts run, and shows a final summary.
//
// Cross-compile for Windows (requires MinGW):
//
//	CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
//	  GOOS=windows GOARCH=amd64 \
//	  go build -ldflags="-s -w -H windowsgui" -o xetup.exe ./cmd/xetup
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	xetupembed "git.xetup.x9.cz/x9/xetup"
	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/gui"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
)

func main() {
	// Load config (falls back to defaults when config.json is missing)
	cfgPath := config.ConfigPath()
	cfg, err := config.Load(cfgPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
	}

	// Temp working directory – cleaned up on exit
	tmpDir, err := os.MkdirTemp("", "xetup-*")
	if err != nil {
		log.Fatalf("Cannot create temp dir: %v", err)
	}
	defer os.RemoveAll(tmpDir)

	// Extract embedded scripts and assets
	if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
		log.Fatalf("Failed to extract scripts: %v", err)
	}
	if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
		log.Fatalf("Failed to extract assets: %v", err)
	}

	// Write runtime config JSON so PowerShell scripts can read it
	cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
	if err != nil {
		log.Fatalf("Failed to write runtime config: %v", err)
	}

	runCfg := runner.RunConfig{
		ScriptsDir:  filepath.Join(tmpDir, "scripts"),
		ConfigPath:  cfgRuntimePath,
		LogFile:     `C:\Windows\Setup\Scripts\Deploy.log`,
		ProfileType: cfg.Deployment.ProfileType,
	}

	gui.Run(cfg, runCfg, cfgPath)
}


Now simplify the PowerShell scripts to use exit code 9. First Dell Update:


Edit

    Write-Log "  DCU exit code: $exitCode" -Level INFO

    # Dell Command | Update exit codes:
    #  0 = completed, no updates required or updates applied (no reboot needed)
    #  1 = updates applied, reboot required to finalize BIOS/firmware
    #  5 = no applicable updates found for this system
    #  others = error or partial failure
    switch ($exitCode) {
        0       { Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK }
        1       { Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK }
        5       { Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK }
        default { Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN }
    }
}

Write-Log "Step 11 complete" -Level OK

    Write-Log "  DCU exit code: $exitCode" -Level INFO

    # Dell Command | Update exit codes:
    #  0 = completed, no updates required or updates applied (no reboot needed)
    #  1 = updates applied, reboot required to finalize BIOS/firmware
    #  5 = no applicable updates found for this system
    #  others = error or partial failure
    switch ($exitCode) {
        0 {
            Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK
        }
        1 {
            Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK
            Write-Log "Step 11 complete - signalling reboot needed (exit 9)" -Level OK
            exit 9
        }
        5 {
            Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK
        }
        default {
            Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN
        }
    }
}

Write-Log "Step 11 complete" -Level OK


The file /opt/xetup/scripts/11-dell-update.ps1 has been updated successfully.

    Write-Log "  DCU exit code: $exitCode" -Level INFO

    # Dell Command | Update exit codes:
    #  0 = completed, no updates required or updates applied (no reboot needed)
    #  1 = updates applied, reboot required to finalize BIOS/firmware
    #  5 = no applicable updates found for this system
    #  others = error or partial failure
    switch ($exitCode) {
        0 {
            Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK
        }
        1 {
            Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK
            Write-Log "Step 11 complete - signalling reboot needed (exit 9)" -Level OK
            exit 9
        }
        5 {
            Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK
        }
        default {
            Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN
        }
    }
}

Write-Log "Step 11 complete" -Level OK

<#
.SYNOPSIS
    Detects Dell hardware, installs Dell Command | Update, and applies all available updates.

.DESCRIPTION
    Checks Win32_ComputerSystem.Manufacturer - if not Dell, the step exits silently without
    error so the same deployment script works on any hardware. On Dell machines, installs
    Dell Command | Update (Universal) via winget and immediately runs /applyUpdates with
    -reboot=disable. This covers drivers, firmware, and BIOS. BIOS and firmware updates are
    staged at this point and finalize automatically during the restart that closes the
    deployment. The operator does not need to run a separate update pass after setup.

.ITEMS
    detekce-dell-hw-win32-computersystem: Reads Win32_ComputerSystem.Manufacturer. If the string does not contain "Dell", the entire step is skipped without error. The deployment continues normally on HP, Lenovo, or any other brand.
    instalace-dell-command-update-via-winget: Installs Dell.CommandUpdate.Universal silently via winget. This is the current DCU generation (v5+) that supports Latitude, OptiPlex, Precision, Vostro, and XPS on Win10 and Win11.
    spusteni-vsech-aktualizaci-drivery-firmware-bios: Runs dcu-cli.exe /applyUpdates -silent -reboot=disable. Covers driver, firmware, and BIOS categories in a single pass. The -reboot=disable flag prevents DCU from rebooting mid-deployment.
    bios-firmware-staging-reboot: BIOS and firmware updates are staged by DCU and finalize on the next system restart. The deployment already ends with a restart (step 09 - computer rename), so no extra reboot is needed.
#>
param(
    [object]$Config,
    [string]$LogFile
)

$ErrorActionPreference = "Continue"

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    $null = New-Item -ItemType Directory -Force -Path (Split-Path $LogFile -Parent) -ErrorAction SilentlyContinue
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
    switch ($Level) {
        "OK"    { Write-Host $line -ForegroundColor Green }
        "ERROR" { Write-Host $line -ForegroundColor Red }
        "WARN"  { Write-Host $line -ForegroundColor Yellow }
        "STEP"  { Write-Host $line -ForegroundColor Cyan }
        default { Write-Host $line }
    }
}

function Get-Feature {
    param([object]$Cfg, [string]$StepID, [string]$FeatureID, [bool]$Default = $true)
    try {
        if ($null -eq $Cfg) { return $Default }
        $stepFeatures = $Cfg.features.$StepID
        if ($null -eq $stepFeatures) { return $Default }
        $val = $stepFeatures.$FeatureID
        if ($null -eq $val) { return $Default }
        return [bool]$val
    } catch { return $Default }
}

# -----------------------------------------------------------------------
# Detect Dell hardware
# -----------------------------------------------------------------------
Write-Log "Checking hardware manufacturer" -Level INFO

try {
    $cs           = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
    $manufacturer = $cs.Manufacturer
    $model        = $cs.Model
    Write-Log "  Manufacturer: $manufacturer  Model: $model" -Level INFO
}
catch {
    Write-Log "  Failed to query Win32_ComputerSystem: $_" -Level ERROR
    return
}

if ($manufacturer -notmatch "Dell") {
    Write-Log "Not a Dell machine ($manufacturer) - step skipped" -Level WARN
    return
}

Write-Log "Dell confirmed: $model" -Level OK

# -----------------------------------------------------------------------
# Install Dell Command | Update via winget
# -----------------------------------------------------------------------
Write-Log "Installing Dell Command | Update (Universal)..." -Level STEP

$wingetArgs = @(
    "install",
    "--id",   "Dell.CommandUpdate.Universal",
    "--silent",
    "--accept-package-agreements",
    "--accept-source-agreements"
)

$wingetOutput = & winget @wingetArgs 2>&1
$wingetExit   = $LASTEXITCODE
$wingetOutput | ForEach-Object { Write-Log "  [winget] $_" -Level INFO }

if ($wingetExit -ne 0 -and $wingetExit -ne 1638) {   # 1638 = already installed
    Write-Log "  winget exit code $wingetExit - checking if DCU is already present" -Level WARN
}

# Locate dcu-cli.exe (path is the same for x64 and Universal edition)
$dcuCandidates = @(
    "C:\Program Files\Dell\CommandUpdate\dcu-cli.exe",
    "C:\Program Files (x86)\Dell\CommandUpdate\dcu-cli.exe"
)
$dcuCli = $dcuCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1

if (-not $dcuCli) {
    Write-Log "  dcu-cli.exe not found - cannot run updates" -Level ERROR
    return
}

Write-Log "  dcu-cli.exe found: $dcuCli" -Level OK

# -----------------------------------------------------------------------
# Run updates - categories controlled by feature flags
# -reboot=disable  -> no mid-deployment reboot; BIOS/firmware staged for next restart
# -----------------------------------------------------------------------
$runDrivers = Get-Feature $Config "dellUpdate" "drivers"
$runBios    = Get-Feature $Config "dellUpdate" "bios"

if (-not $runDrivers -and -not $runBios) {
    Write-Log "Both drivers and bios features disabled - skipping update run" -Level INFO
} else {
    # Build update type list from enabled features
    $updateTypes = @()
    if ($runDrivers) {
        $updateTypes += "driver"
        $updateTypes += "firmware"
    }
    if ($runBios) {
        $updateTypes += "bios"
    }
    $updateTypeArg = $updateTypes -join ","

    Write-Log "Running Dell Command | Update (updateType=$updateTypeArg, no auto-reboot)..." -Level STEP
    Write-Log "  This may take several minutes depending on available updates" -Level INFO

    $dcuOutput = & $dcuCli /applyUpdates -silent -reboot=disable "-updateType=$updateTypeArg" 2>&1
    $exitCode  = $LASTEXITCODE
    $dcuOutput | ForEach-Object { Write-Log "  [DCU] $_" -Level INFO }

    Write-Log "  DCU exit code: $exitCode" -Level INFO

    # Dell Command | Update exit codes:
    #  0 = completed, no updates required or updates applied (no reboot needed)
    #  1 = updates applied, reboot required to finalize BIOS/firmware
    #  5 = no applicable updates found for this system
    #  others = error or partial failure
    switch ($exitCode) {
        0       { Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK }
        1       { Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK }
        5       { Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK }
        default { Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN }
    }
}

Write-Log "Step 11 complete" -Level OK

-        0       { Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK }

-        1       { Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK }

-        5       { Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK }

-        default { Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN }

+            Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK

+            Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK

+            Write-Log "Step 11 complete - signalling reboot needed (exit 9)" -Level OK

+            Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK

+            Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN


Now rewrite `12-windows-update.ps1` to use exit code 9 instead of managing its own scheduled task and autologon:


Write

<#
.SYNOPSIS
    Installs all available Windows Updates via PSWindowsUpdate module.

.DESCRIPTION
    Installs all currently available updates without rebooting.
    Exits with code 9 when updates were installed (reboot required for
    further rounds) or code 0 when the system is already fully up to date.

    The xetup.exe state machine handles the reboot cycle:
      - exit 9  ->  xetup saves state, sets autologon + X9-Resume task, reboots
      - on each subsequent logon X9-Resume launches xetup --resume
      - xetup re-runs this step until it exits 0 (no more updates)
      - then disables autologon, removes X9-Resume, shows the summary screen

.ITEMS
    nainstalovat-pswindowsupdate-modul: Installs NuGet provider and PSWindowsUpdate module from PSGallery.
    spustit-kolo-windows-update: One update pass without reboot. Exits 9 when updates were applied (more rounds needed). Exits 0 when system is fully up to date.
#>
param(
    [object]$Config,
    [string]$LogFile
)

$ErrorActionPreference = "Continue"

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    $null = New-Item -ItemType Directory -Force -Path (Split-Path $LogFile -Parent) -ErrorAction SilentlyContinue
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
    Write-Output $line
}

Write-Log "=== Step 12 - Windows Update ===" -Level STEP

# -----------------------------------------------------------------------
# 1. NuGet provider + PSWindowsUpdate module
# -----------------------------------------------------------------------
Write-Log "Setting up PSWindowsUpdate module..." -Level INFO
try {
    Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null
    $existing = Get-Module -ListAvailable -Name PSWindowsUpdate | Select-Object -First 1
    if ($existing) {
        Write-Log "  PSWindowsUpdate $($existing.Version) already installed" -Level INFO
    } else {
        Install-Module -Name PSWindowsUpdate -Force -Scope AllUsers -AllowClobber | Out-Null
        Write-Log "  PSWindowsUpdate installed" -Level OK
    }
    Import-Module PSWindowsUpdate -Force
} catch {
    Write-Log "  Module setup failed: $_" -Level ERROR
    exit 1
}

# -----------------------------------------------------------------------
# 2. Check and install available updates
# -----------------------------------------------------------------------
Write-Log "Checking for available Windows Updates..." -Level INFO
try {
    $available = Get-WindowsUpdate -AcceptAll -IgnoreReboot -ErrorAction Stop
} catch {
    Write-Log "  Failed to check for updates: $_" -Level ERROR
    exit 1
}

if (-not $available -or $available.Count -eq 0) {
    Write-Log "  System is fully up to date" -Level OK
    Write-Log "Step 12 complete" -Level OK
    exit 0
}

Write-Log "  Found $($available.Count) update(s) - installing..." -Level INFO
try {
    $result = Install-WindowsUpdate -AcceptAll -IgnoreReboot -Verbose 2>&1
    $result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log "  $_" -Level INFO }
    Write-Log "  Update pass complete - reboot required for next round" -Level OK
} catch {
    Write-Log "  Update install failed: $_" -Level ERROR
    exit 1
}

# Signal xetup that a reboot is needed before running this step again
Write-Log "Step 12 - reboot required (exit 9)" -Level OK
exit 9