1	<#
2	.SYNOPSIS
3	    Detects Dell hardware, installs Dell Command | Update, and applies all available updates.
4
5	.DESCRIPTION
6	    Checks Win32_ComputerSystem.Manufacturer - if not Dell, the step exits silently without
7	    error so the same deployment script works on any hardware. On Dell machines, installs
8	    Dell Command | Update (Universal) via winget and immediately runs /applyUpdates with
9	    -reboot=disable. This covers drivers, firmware, and BIOS. BIOS and firmware updates are
10	    staged at this point and finalize automatically during the restart that closes the
11	    deployment. The operator does not need to run a separate update pass after setup.
12
13	.ITEMS
14	    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.
15	    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.
16	    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.
17	    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.
18	#>
19	param(
20	    [object]$Config,
21	    [string]$LogFile
22	)
23
24	$ErrorActionPreference = "Continue"
25
26	function Write-Log {
27	    param([string]$Message, [string]$Level = "INFO")
28	    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
29	    $null = New-Item -ItemType Directory -Force -Path (Split-Path $LogFile -Parent) -ErrorAction SilentlyContinue
30	    Add-Content -Path $LogFile -Value $line -Encoding UTF8
31	    switch ($Level) {
32	        "OK"    { Write-Host $line -ForegroundColor Green }
33	        "ERROR" { Write-Host $line -ForegroundColor Red }
34	        "WARN"  { Write-Host $line -ForegroundColor Yellow }
35	        "STEP"  { Write-Host $line -ForegroundColor Cyan }
36	        default { Write-Host $line }
37	    }
38	}
39
40	function Get-Feature {
41	    param([object]$Cfg, [string]$StepID, [string]$FeatureID, [bool]$Default = $true)
42	    try {
43	        if ($null -eq $Cfg) { return $Default }
44	        $stepFeatures = $Cfg.features.$StepID
45	        if ($null -eq $stepFeatures) { return $Default }
46	        $val = $stepFeatures.$FeatureID
47	        if ($null -eq $val) { return $Default }
48	        return [bool]$val
49	    } catch { return $Default }
50	}
51
52	# -----------------------------------------------------------------------
53	# Detect Dell hardware
54	# -----------------------------------------------------------------------
55	Write-Log "Checking hardware manufacturer" -Level INFO
56
57	try {
58	    $cs           = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
59	    $manufacturer = $cs.Manufacturer
60	    $model        = $cs.Model
61	    Write-Log "  Manufacturer: $manufacturer  Model: $model" -Level INFO
62	}
63	catch {
64	    Write-Log "  Failed to query Win32_ComputerSystem: $_" -Level ERROR
65	    return
66	}
67
68	if ($manufacturer -notmatch "Dell") {
69	    Write-Log "Not a Dell machine ($manufacturer) - step skipped" -Level WARN
70	    return
71	}
72
73	Write-Log "Dell confirmed: $model" -Level OK
74
75	# -----------------------------------------------------------------------
76	# Install Dell Command | Update via winget
77	# -----------------------------------------------------------------------
78	Write-Log "Installing Dell Command | Update (Universal)..." -Level STEP
79
80	$wingetArgs = @(
81	    "install",
82	    "--id",   "Dell.CommandUpdate.Universal",
83	    "--silent",
84	    "--accept-package-agreements",
85	    "--accept-source-agreements"
86	)
87
88	$wingetOutput = & winget @wingetArgs 2>&1
89	$wingetExit   = $LASTEXITCODE
90	$wingetOutput | ForEach-Object { Write-Log "  [winget] $_" -Level INFO }
91
92	if ($wingetExit -ne 0 -and $wingetExit -ne 1638) {   # 1638 = already installed
93	    Write-Log "  winget exit code $wingetExit - checking if DCU is already present" -Level WARN
94	}
95
96	# Locate dcu-cli.exe (path is the same for x64 and Universal edition)
97	$dcuCandidates = @(
98	    "C:\Program Files\Dell\CommandUpdate\dcu-cli.exe",
99	    "C:\Program Files (x86)\Dell\CommandUpdate\dcu-cli.exe"
100	)
101	$dcuCli = $dcuCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
102
103	if (-not $dcuCli) {
104	    Write-Log "  dcu-cli.exe not found - cannot run updates" -Level ERROR
105	    return
106	}
107
108	Write-Log "  dcu-cli.exe found: $dcuCli" -Level OK
109
110	# -----------------------------------------------------------------------
111	# Run updates - categories controlled by feature flags
112	# -reboot=disable  -> no mid-deployment reboot; BIOS/firmware staged for next restart
113	# -----------------------------------------------------------------------
114	$runDrivers = Get-Feature $Config "dellUpdate" "drivers"
115	$runBios    = Get-Feature $Config "dellUpdate" "bios"
116
117	if (-not $runDrivers -and -not $runBios) {
118	    Write-Log "Both drivers and bios features disabled - skipping update run" -Level INFO
119	} else {
120	    # Build update type list from enabled features
121	    $updateTypes = @()
122	    if ($runDrivers) {
123	        $updateTypes += "driver"
124	        $updateTypes += "firmware"
125	    }
126	    if ($runBios) {
127	        $updateTypes += "bios"
128	    }
129	    $updateTypeArg = $updateTypes -join ","
130
131	    Write-Log "Running Dell Command | Update (updateType=$updateTypeArg, no auto-reboot)..." -Level STEP
132	    Write-Log "  This may take several minutes depending on available updates" -Level INFO
133
134	    $dcuOutput = & $dcuCli /applyUpdates -silent -reboot=disable "-updateType=$updateTypeArg" 2>&1
135	    $exitCode  = $LASTEXITCODE
136	    $dcuOutput | ForEach-Object { Write-Log "  [DCU] $_" -Level INFO }
137
138	    Write-Log "  DCU exit code: $exitCode" -Level INFO
139
140	    # Dell Command | Update exit codes:
141	    #  0 = completed, no updates required or updates applied (no reboot needed)
142	    #  1 = updates applied, reboot required to finalize BIOS/firmware
143	    #  5 = no applicable updates found for this system
144	    #  others = error or partial failure
145	    switch ($exitCode) {
146	        0       { Write-Log "Dell Command | Update: complete (no reboot required)" -Level OK }
147	        1       { Write-Log "Dell Command | Update: updates staged - BIOS/firmware will finalize on restart" -Level OK }
148	        5       { Write-Log "Dell Command | Update: no applicable updates for this model" -Level OK }
149	        default { Write-Log "Dell Command | Update: exit code $exitCode - review DCU log in C:\ProgramData\Dell\UpdateService\Logs" -Level WARN }
150	    }
151	}
152
153	Write-Log "Step 11 complete" -Level OK


16		"log"
17		"os"
18		"path/filepath"
19
20		xetupembed "git.xetup.x9.cz/x9/xetup"
21		"git.xetup.x9.cz/x9/xetup/internal/config"
22		"git.xetup.x9.cz/x9/xetup/internal/gui"
23		"git.xetup.x9.cz/x9/xetup/internal/runner"
24	)
25
26	func main() {
27		// Load config (falls back to defaults when config.json is missing)
28		cfgPath := config.ConfigPath()
29		cfg, err := config.Load(cfgPath)
30		if err != nil {
31			fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
32		}
33
34		// Temp working directory – cleaned up on exit
35		tmpDir, err := os.MkdirTemp("", "xetup-*")
36		if err != nil {
37			log.Fatalf("Cannot create temp dir: %v", err)
38		}
39		defer os.RemoveAll(tmpDir)
40
41		// Extract embedded scripts and assets
42		if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
43			log.Fatalf("Failed to extract scripts: %v", err)
44		}
45		if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
46			log.Fatalf("Failed to extract assets: %v", err)
47		}
48
49		// Write runtime config JSON so PowerShell scripts can read it
50		cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
51		if err != nil {
52			log.Fatalf("Failed to write runtime config: %v", err)
53		}
54
55		runCfg := runner.RunConfig{
56			ScriptsDir:  filepath.Join(tmpDir, "scripts"),


1	// Package runner executes PowerShell deployment scripts and streams log output.
2	package runner
3
4	import (
5		"bufio"
6		"context"
7		"encoding/json"
8		"fmt"
9		"os"
10		"os/exec"
11		"path"
12		"path/filepath"
13		"strings"
14		"time"
15	)
16
17	// Step describes a single deployment step.
18	type Step struct {
19		ID         string // e.g. "adminAccount"
20		Num        string // display number e.g. "00"
21		Name       string
22		ScriptName string // e.g. "00-admin-account.ps1"
23		Enabled    bool
24	}
25
26	// AllSteps returns the ordered list of deployment steps.
27	func AllSteps() []Step {
28		return []Step{
29			{ID: "adminAccount",    Num: "00", Name: "Admin ucet",              ScriptName: "00-admin-account.ps1"},
30			{ID: "bloatware",       Num: "01", Name: "Bloatware removal",        ScriptName: "01-bloatware.ps1"},
31			{ID: "software",        Num: "02", Name: "Software (winget)",         ScriptName: "02-software.ps1"},
32			{ID: "systemRegistry",  Num: "03", Name: "System Registry (HKLM)",   ScriptName: "03-system-registry.ps1"},
33			{ID: "defaultProfile",  Num: "04", Name: "Default Profile",          ScriptName: "04-default-profile.ps1"},
34			{ID: "personalization", Num: "05", Name: "Personalizace",             ScriptName: "05-personalization.ps1"},
35			{ID: "scheduledTasks",  Num: "06", Name: "Scheduled Tasks",           ScriptName: "06-scheduled-tasks.ps1"},
36			{ID: "backinfo",        Num: "07", Name: "BackInfo",                  ScriptName: "07-backinfo.ps1"},
37			{ID: "activation",      Num: "08", Name: "Windows aktivace",          ScriptName: "08-activation.ps1"},
38			{ID: "pcIdentity",      Num: "09", Name: "PC identita",               ScriptName: "09-pc-identity.ps1"},
39			{ID: "network",         Num: "10", Name: "Network discovery",         ScriptName: "10-network.ps1"},
40			{ID: "dellUpdate",      Num: "11", Name: "Dell Command | Update",      ScriptName: "11-dell-update.ps1"},
41			{ID: "windowsUpdate",   Num: "12", Name: "Windows Update",            ScriptName: "12-windows-update.ps1"},
42		}
43	}
44
45	// Feature is a single toggleable sub-item within a deployment step.
46	type Feature struct {
47		ID    string
48		Label string
49	}
50
51	// StepFeatures returns per-step feature lists. Steps absent from this map
52	// have no sub-features and are controlled at the step level only.
53	func StepFeatures() map[string][]Feature {
54		return map[string][]Feature{
55			"software": {
56				{ID: "wingetInstalls", Label: "Instalace SW ze seznamu (winget)"},
57				{ID: "pdfDefault",     Label: "Adobe Reader jako vychozi PDF"},
58				{ID: "ateraAgent",     Label: "Atera RMM agent"},
59			},
60			"systemRegistry": {
61				{ID: "systemTweaks",      Label: "Windows tweaky (Widgets, GameDVR, Recall...)"},
62				{ID: "edgePolicies",      Label: "Edge policies (tlacitka, vyhledavac, telemetrie)"},
63				{ID: "oneDriveUninstall", Label: "OneDrive uninstall (consumer pre-install)"},
64				{ID: "powercfg",          Label: "Nastaveni napajeni (timeout AC/DC)"},
65				{ID: "proxyDisable",      Label: "Zakaz WPAD proxy auto-detect"},
66			},
67			"defaultProfile": {
68				{ID: "taskbarTweaks",   Label: "Taskbar – zarovnani, tlacitka, layout XML"},
69				{ID: "startMenuTweaks", Label: "Start menu – cisteni pinu, Bing, Copilot"},
70				{ID: "explorerTweaks",  Label: "Explorer – pripony, LaunchTo, ShowRecent"},
71			},
72			"dellUpdate": {
73				{ID: "drivers", Label: "Dell drivery + firmware"},
74				{ID: "bios",    Label: "Dell BIOS update"},
75			},
76		}
77	}
78
79	// SelectableItem is a single toggleable row in the TUI checklist.
80	// It represents either a whole step (FeatureID == "") or a specific feature.
81	type SelectableItem struct {
82		Key       string // "stepID" or "stepID.featureID"
83		StepID    string
84		FeatureID string // empty for step-level items
85		Label     string
86		Num       string
87	}
88
89	// AllSelectableItems returns the flat ordered list of all TUI toggle rows.
90	// Steps with features are expanded to individual feature rows.
91	// Steps without features appear as a single step-level row.
92	func AllSelectableItems() []SelectableItem {
93		steps    := AllSteps()
94		features := StepFeatures()
95		var items []SelectableItem
96		for _, s := range steps {
97			feats, hasFeatures := features[s.ID]
98			if !hasFeatures {
99				items = append(items, SelectableItem{
100					Key:    s.ID,
101					StepID: s.ID,
102					Label:  s.Num + " – " + s.Name,
103					Num:    s.Num,
104				})
105			} else {
106				for _, f := range feats {
107					items = append(items, SelectableItem{
108						Key:       s.ID + "." + f.ID,
109						StepID:    s.ID,
110						FeatureID: f.ID,
111						Label:     s.Num + " – " + f.Label,
112						Num:       s.Num,
113					})
114				}
115			}
116		}
117		return items
118	}
119
120	// RunConfig holds runtime parameters passed to each script.
121	type RunConfig struct {
122		ScriptsDir  string
123		ConfigPath  string
124		LogFile     string
125		ProfileType string
126	}
127
128	// Result is the outcome of a single step.
129	type Result struct {
130		Step    Step
131		Status  string // "OK", "ERROR", "SKIPPED"
132		Elapsed time.Duration
133	}
134
135	// LogLine is a single output line from a running script.
136	type LogLine struct {
137		StepID string
138		Text   string
139		Level  string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
140	}
141
142	// Runner executes deployment steps sequentially.
143	type Runner struct {
144		cfg         RunConfig
145		onLog       func(LogLine)
146		onStepStart func(Step)
147		onResult    func(Result)
148		cancel      context.CancelFunc
149	}
150
151	// New creates a Runner. onLog is called for each output line, onResult after each step.
152	// onStepStart (optional) is called immediately before a step's script is launched.
153	func New(cfg RunConfig, onLog func(LogLine), onStepStart func(Step), onResult func(Result)) *Runner {
154		return &Runner{cfg: cfg, onLog: onLog, onStepStart: onStepStart, onResult: onResult}
155	}
156
157	// Run executes enabled steps sequentially. Blocks until done or context cancelled.
158	func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
159		ctx, cancel := context.WithCancel(ctx)
160		r.cancel = cancel
161		defer cancel()
162
163		// Write config JSON to temp file so scripts can read it
164		cfgArg := r.cfg.ConfigPath
165
166		var results []Result
167		for _, step := range steps {
168			if !step.Enabled {
169				res := Result{Step: step, Status: "SKIPPED"}
170				r.onResult(res)
171				results = append(results, res)
172				continue
173			}
174
175			if r.onStepStart != nil {
176				r.onStepStart(step)
177			}
178			start := time.Now()
179			err := r.runScript(ctx, step, cfgArg)
180			elapsed := time.Since(start)
181
182			status := "OK"
183			if err != nil {
184				if ctx.Err() != nil {
185					status = "CANCELLED"
186				} else {
187					status = "ERROR"
188				}
189			}
190
191			res := Result{Step: step, Status: status, Elapsed: elapsed}
192			r.onResult(res)
193			results = append(results, res)
194
195			if ctx.Err() != nil {
196				break
197			}
198		}
199		return results
200	}
201
202	// Stop cancels the running deployment.
203	func (r *Runner) Stop() {
204		if r.cancel != nil {
205			r.cancel()
206		}
207	}
208
209	func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
210		scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)
211
212		// Build argument list
213		args := []string{
214			"-NonInteractive",
215			"-ExecutionPolicy", "Bypass",
216			"-File", scriptPath,
217			"-LogFile", r.cfg.LogFile,
218		}
219
220		// Pass config object as JSON string (script reads it inline)
221		if cfgArg != "" {
222			args = append(args, "-Config", fmt.Sprintf("(Get-Content '%s' | ConvertFrom-Json)", cfgArg))
223		}
224
225		// ProfileType for step 04
226		if step.ID == "defaultProfile" && r.cfg.ProfileType != "" {
227			args = append(args, "-ProfileType", r.cfg.ProfileType)
228		}
229
230		cmd := exec.CommandContext(ctx, "powershell.exe", args...)
231		hideWindow(cmd) // prevent PS console window from appearing over the GUI
232

... [142 lines truncated] ...


1	//go:build windows
2
3	// Package gui implements the Walk-based graphical interface for xetup.
4	//
5	// Three sequential phases, each with its own window:
6	//  1. Config form  – PC name, product key, profile, step checkboxes,
7	//                    load/save config buttons for per-client presets
8	//  2. Live run     – real-time log streamed from PowerShell scripts
9	//  3. Summary      – per-step OK / ERROR / SKIPPED with elapsed time
10	package gui
11
12	import (
13		"context"
14		"encoding/json"
15		"fmt"
16		"os"
17		"os/exec"
18		"strings"
19		"sync"
20		"time"
21
22		"github.com/lxn/walk"
23		. "github.com/lxn/walk/declarative"
24
25		"git.xetup.x9.cz/x9/xetup/internal/config"
26		"git.xetup.x9.cz/x9/xetup/internal/runner"
27	)
28
29	// Run opens the xetup window and blocks until the user closes it.
30	func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
31		for {
32			res := formPhase(cfg, runCfg, cfgPath)
33			switch res.action {
34			case "quit":
35				return
36			case "reload":
37				cfg = res.cfg
38				cfgPath = res.cfgPath
39			case "run":
40				runCfg.ProfileType = res.cfg.Deployment.ProfileType
41				results := runPhase(runCfg, res.steps)
42				donePhase(results)
43				return
44			}
45		}
46	}
47
48	// --------------------------------------------------------------------------
49	// Phase 1 – Config form
50	// --------------------------------------------------------------------------
51
52	type formResult struct {
53		action  string // "run", "reload", "quit"
54		cfg     config.Config
55		cfgPath string
56		steps   []runner.Step
57	}
58
59	func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formResult {
60		var mw *walk.MainWindow
61		result := formResult{action: "quit", cfgPath: cfgPath}
62
63		var (
64			pcNameLE     *walk.LineEdit
65			pcDescLE     *walk.LineEdit
66			productKeyLE *walk.LineEdit
67			profileCB    *walk.ComboBox
68		)
69
70		items := runner.AllSelectableItems()
71		checkPtrs := make([]*walk.CheckBox, len(items))
72		checkWidgets := make([]Widget, len(items))
73		for i, item := range items {
74			i := i // capture for closure
75			checkWidgets[i] = CheckBox{
76				AssignTo: &checkPtrs[i],
77				Text:     item.Label,
78				Checked:  itemEnabled(cfg, item),
79			}
80		}
81
82		collectCfg := func() config.Config {
83			out := cfg
84			out.Deployment.PCName = pcNameLE.Text()
85			out.Deployment.PCDescription = pcDescLE.Text()
86			out.Activation.ProductKey = productKeyLE.Text()
87			profiles := []string{"default", "admin", "user"}
88			if idx := profileCB.CurrentIndex(); idx >= 0 && idx < len(profiles) {
89				out.Deployment.ProfileType = profiles[idx]
90			}
91			selected := make(map[string]bool, len(items))
92			for i, item := range items {
93				selected[item.Key] = checkPtrs[i].Checked()
94			}
95			_, features := buildStepsAndFeatures(selected)
96			out.Features = features
97			return out
98		}
99
100		if err := (MainWindow{
101			AssignTo: &mw,
102			Title:    "xetup \u2014 Windows deployment",
103			Size:     Size{Width: 760, Height: 740},
104			Layout:   VBox{},
105			Children: []Widget{
106				// ── Form fields ──────────────────────────────────────────────────
107				Composite{
108					Layout: Grid{Columns: 2},
109					Children: []Widget{
110						Label{Text: "PC jmeno:"},
111						LineEdit{
112							AssignTo:  &pcNameLE,
113							Text:      cfg.Deployment.PCName,
114							CueBanner: "napr. NB-KLIENT-01  (prazdne = neprejmenovat)",
115						},
116						Label{Text: "Popis PC:"},
117						LineEdit{
118							AssignTo:  &pcDescLE,
119							Text:      cfg.Deployment.PCDescription,
120							CueBanner: "napr. PC recepce",
121						},
122						Label{Text: "Product Key:"},
123						LineEdit{
124							AssignTo:  &productKeyLE,
125							Text:      cfg.Activation.ProductKey,
126							CueBanner: "prazdne = OA3 / GVLK fallback",
127						},
128						Label{Text: "Profil:"},
129						ComboBox{
130							AssignTo:     &profileCB,
131							Model:        []string{"default", "admin", "user"},
132							CurrentIndex: profileIndex(cfg.Deployment.ProfileType),
133						},
134					},
135				},
136				HSeparator{},
137				// ── Step checkboxes ──────────────────────────────────────────────
138				Label{Text: "Kroky a nastaveni (odskrtnete co nechcete spustit):"},
139				ScrollView{
140					MinSize:  Size{Height: 400},
141					Layout:   VBox{MarginsZero: true},
142					Children: checkWidgets,
143				},
144				HSeparator{},
145				// ── Buttons ──────────────────────────────────────────────────────
146				Composite{
147					Layout: HBox{MarginsZero: true},
148					Children: []Widget{
149						PushButton{
150							Text: "Nacist config...",
151							OnClicked: func() {
152								dlg := new(walk.FileDialog)
153								dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
154								dlg.Title = "Nacist konfiguraci"
155								ok, err := dlg.ShowOpen(mw)
156								if err != nil || !ok {
157									return
158								}
159								data, err := os.ReadFile(dlg.FilePath)
160								if err != nil {
161									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
162									return
163								}
164								newCfg := config.DefaultConfig()
165								if err := json.Unmarshal(data, &newCfg); err != nil {
166									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
167									return
168								}
169								result = formResult{
170									action:  "reload",
171									cfg:     newCfg,
172									cfgPath: dlg.FilePath,
173								}
174								mw.Close()
175							},
176						},
177						PushButton{
178							Text: "Ulozit config...",
179							OnClicked: func() {
180								dlg := new(walk.FileDialog)
181								dlg.Filter = "JSON soubory (*.json)|*.json|Vsechny soubory (*.*)|*.*"
182								dlg.Title = "Ulozit konfiguraci"
183								dlg.FilePath = "config.json"
184								ok, err := dlg.ShowSave(mw)
185								if err != nil || !ok {
186									return
187								}
188								data, err := json.MarshalIndent(collectCfg(), "", "  ")
189								if err != nil {
190									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
191									return
192								}
193								if err := os.WriteFile(dlg.FilePath, data, 0644); err != nil {
194									walk.MsgBox(mw, "Chyba", err.Error(), walk.MsgBoxIconError)
195								}
196							},
197						},
198						HSpacer{},
199						PushButton{
200							Text: "  SPUSTIT  ",
201							OnClicked: func() {
202								finalCfg := collectCfg()
203								selected := make(map[string]bool, len(items))
204								for i, item := range items {
205									selected[item.Key] = checkPtrs[i].Checked()
206								}
207								steps, features := buildStepsAndFeatures(selected)
208								finalCfg.Features = features
209								_ = config.Save(finalCfg, cfgPath)
210								result = formResult{
211									action:  "run",
212									cfg:     finalCfg,
213									cfgPath: cfgPath,
214									steps:   steps,
215								}
216								mw.Close()
217							},
218						},
219					},
220				},
221			},
222		}.Create()); err != nil {
223			walk.MsgBox(nil, "Chyba", fmt.Sprintf("Nelze vytvorit okno: %v", err), walk.MsgBoxIconError)
224			return result
225		}
226
227		mw.Run()
228		return result
229	}
230
231	// --------------------------------------------------------------------------
232	// Phase 2 – Live run view
233	// --------------------------------------------------------------------------
234
235	// stepIndicator tracks a step's label and blink state.
236	type stepIndicator struct {
237		label  *walk.Label
238		stepID string
239		status string // "pending", "running", "ok", "error", "skipped", "cancelled"
240	}
241
242	func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
243		var (
244			mw        *walk.MainWindow
245			statusLbl *walk.Label
246			logTE     *walk.TextEdit
247			cancelFn  context.CancelFunc
248		)
249
250		// Build step indicator labels (one per step, in order)
251		indicators := make([]stepIndicator, len(steps))
252		stepIndex  := make(map[string]int, len(steps)) // stepID → index
253		indWidgets := make([]Widget, len(steps))
254		for i, s := range steps {

... [333 lines truncated] ...


Now I'm on `rebase-onto-remote` at the remote's HEAD (9feb7ba). Let me cherry-pick my state machine commit:


Bash

Cherry-pick state machine commit onto remote HEAD


Error: Exit code 1
Auto-merging internal/gui/gui.go
CONFLICT (content): Merge conflict in internal/gui/gui.go
Auto-merging internal/runner/runner.go
Auto-merging scripts/12-windows-update.ps1
CONFLICT (content): Merge conflict in scripts/12-windows-update.ps1
error: could not apply 25881ab... feat: state machine for reboot-resume across Windows Update cycles
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".
hint: You can instead skip this commit with "git cherry-pick --skip".
hint: To abort and get back to the state before "git cherry-pick",
hint: run "git cherry-pick --abort".


Abort cherry-pick


(Bash completed with no output)


The remote's gui.go already has the step indicator strip. Let me look at what it actually has now:


grep -n "stepIndicator\|runPhase\|donePhase\|prereboot\|state\.\|resumePhase\|strings\." /opt/xetup/internal/gui/gui.go | head -30

Check current gui.go state on rebase branch


41:			results := runPhase(runCfg, res.steps)
42:			donePhase(results)
235:// stepIndicator tracks a step's label and blink state.
236:type stepIndicator struct {
242:func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
251:	indicators := make([]stepIndicator, len(steps))
255:		indicators[i] = stepIndicator{stepID: s.ID, status: "pending"}
365:					indicators[idx].status = strings.ToLower(res.Status)
398:func donePhase(results []runner.Result) {


grep -n "NeedsReboot\|ErrNeedsReboot\|errors\." /opt/xetup/internal/runner/runner.go | head -10

Check runner.go state


Now I apply all changes to the remote's clean state. First let me check what runner.go currently looks like:


1	// Package runner executes PowerShell deployment scripts and streams log output.
2	package runner
3
4	import (
5		"bufio"
6		"context"
7		"encoding/json"
8		"fmt"
9		"os"
10		"os/exec"
11		"path"
12		"path/filepath"
13		"strings"
14		"time"
15	)
16
17	// Step describes a single deployment step.
18	type Step struct {
19		ID         string // e.g. "adminAccount"
20		Num        string // display number e.g. "00"
21		Name       string
22		ScriptName string // e.g. "00-admin-account.ps1"
23		Enabled    bool
24	}
25
26	// AllSteps returns the ordered list of deployment steps.
27	func AllSteps() []Step {
28		return []Step{
29			{ID: "adminAccount",    Num: "00", Name: "Admin ucet",              ScriptName: "00-admin-account.ps1"},
30			{ID: "bloatware",       Num: "01", Name: "Bloatware removal",        ScriptName: "01-bloatware.ps1"},

// 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"
)

// Step describes a single deployment step.
type Step struct {
	ID         string // e.g. "adminAccount"
	Num        string // display number e.g. "00"
	Name       string
	ScriptName string // e.g. "00-admin-account.ps1"
	Enabled    bool
}

// AllSteps returns the ordered list of deployment steps.
func AllSteps() []Step {
	return []Step{
		{ID: "adminAccount",    Num: "00", Name: "Admin ucet",              ScriptName: "00-admin-account.ps1"},
		{ID: "bloatware",       Num: "01", Name: "Bloatware removal",        ScriptName: "01-bloatware.ps1"},


120	// RunConfig holds runtime parameters passed to each script.
121	type RunConfig struct {
122		ScriptsDir  string
123		ConfigPath  string
124		LogFile     string
125		ProfileType string
126	}
127
128	// Result is the outcome of a single step.
129	type Result struct {
130		Step    Step
131		Status  string // "OK", "ERROR", "SKIPPED"
132		Elapsed time.Duration
133	}
134
135	// LogLine is a single output line from a running script.
136	type LogLine struct {
137		StepID string
138		Text   string
139		Level  string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
140	}
141
142	// Runner executes deployment steps sequentially.
143	type Runner struct {
144		cfg         RunConfig
145		onLog       func(LogLine)
146		onStepStart func(Step)
147		onResult    func(Result)
148		cancel      context.CancelFunc
149	}
150
151	// New creates a Runner. onLog is called for each output line, onResult after each step.
152	// onStepStart (optional) is called immediately before a step's script is launched.
153	func New(cfg RunConfig, onLog func(LogLine), onStepStart func(Step), onResult func(Result)) *Runner {
154		return &Runner{cfg: cfg, onLog: onLog, onStepStart: onStepStart, onResult: onResult}
155	}
156
157	// Run executes enabled steps sequentially. Blocks until done or context cancelled.
158	func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
159		ctx, cancel := context.WithCancel(ctx)
160		r.cancel = cancel
161		defer cancel()
162
163		// Write config JSON to temp file so scripts can read it
164		cfgArg := r.cfg.ConfigPath
165
166		var results []Result
167		for _, step := range steps {
168			if !step.Enabled {
169				res := Result{Step: step, Status: "SKIPPED"}
170				r.onResult(res)
171				results = append(results, res)
172				continue
173			}
174
175			if r.onStepStart != nil {
176				r.onStepStart(step)
177			}
178			start := time.Now()
179			err := r.runScript(ctx, step, cfgArg)
180			elapsed := time.Since(start)
181
182			status := "OK"
183			if err != nil {
184				if ctx.Err() != nil {
185					status = "CANCELLED"
186				} else {
187					status = "ERROR"
188				}
189			}
190
191			res := Result{Step: step, Status: status, Elapsed: elapsed}
192			r.onResult(res)
193			results = append(results, res)
194
195			if ctx.Err() != nil {
196				break
197			}
198		}
199		return results
200	}
201
202	// Stop cancels the running deployment.
203	func (r *Runner) Stop() {
204		if r.cancel != nil {
205			r.cancel()
206		}
207	}
208
209	func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
210		scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)
211
212		// Build argument list
213		args := []string{
214			"-NonInteractive",
215			"-ExecutionPolicy", "Bypass",
216			"-File", scriptPath,
217			"-LogFile", r.cfg.LogFile,
218		}
219

// RunConfig holds runtime parameters passed to each script.
type RunConfig struct {
	ScriptsDir  string
	ConfigPath  string
	LogFile     string
	ProfileType string
}

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

// LogLine is a single output line from a running script.
type LogLine struct {
	StepID string
	Text   string
	Level  string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
}

// Runner executes deployment steps sequentially.
type Runner struct {
	cfg         RunConfig
	onLog       func(LogLine)
	onStepStart func(Step)
	onResult    func(Result)
	cancel      context.CancelFunc
}

// New creates a Runner. onLog is called for each output line, onResult after each step.
// onStepStart (optional) is called immediately before a step's script is launched.
func New(cfg RunConfig, onLog func(LogLine), onStepStart func(Step), onResult func(Result)) *Runner {
	return &Runner{cfg: cfg, onLog: onLog, onStepStart: onStepStart, onResult: onResult}
}

// Run executes enabled steps sequentially. Blocks until done or context cancelled.
func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
	ctx, cancel := context.WithCancel(ctx)
	r.cancel = cancel
	defer cancel()

	// Write config JSON to temp file so scripts can read it
	cfgArg := r.cfg.ConfigPath

	var results []Result
	for _, step := range steps {
		if !step.Enabled {
			res := Result{Step: step, Status: "SKIPPED"}
			r.onResult(res)
			results = append(results, res)
			continue
		}

		if r.onStepStart != nil {
			r.onStepStart(step)
		}
		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
		}
	}
	return results
}

// Stop cancels the running deployment.
func (r *Runner) Stop() {
	if r.cancel != nil {
		r.cancel()
	}
}

func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
	scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)

	// Build argument list
	args := []string{
		"-NonInteractive",
		"-ExecutionPolicy", "Bypass",
		"-File", scriptPath,
		"-LogFile", r.cfg.LogFile,
	}


Good, the remote already has `onStepStart`. Now apply all the state machine changes cleanly:


Edit

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

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.

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

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.


		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 {


Bash

grep -n "return cmd.Wait()" /opt/xetup/internal/runner/runner.go

Find the cmd.Wait line


267:	return cmd.Wait()


Edit

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


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

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

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


Bash

grep -n "^// WriteConfig" /opt/xetup/internal/runner/runner.go

Find WriteConfig location


383:// WriteConfig serialises cfg to a temp JSON file and returns its path.


Edit

// WriteConfig serialises cfg to a temp JSON file and returns its path.

// 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.


// 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.

+// 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) {

func WriteConfig(cfg interface{}, tmpDir string) (string, error) {

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


Now apply gui.go and main.go (already written correctly, just need to write them to the current branch files):


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 (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 completed results (NeedsReboot step excluded – runs 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()

	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.
// Steps with NeedsReboot=true (re-run after reboot) and steps not yet
// reached (absent from completedResults) are both included.
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 next round).
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 final summary screen.
// currentResults are from the last run; prevResults accumulate earlier rounds.
func donePhase(currentResults []runner.Result, prevResults []state.StepResult) {
	var (
		mw           *walk.MainWindow
		countdownLbl *walk.Label
	)

	// Combined ordered list: previous rounds first, then current round
	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
		}
		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 (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 completed results (NeedsReboot step excluded – runs 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()

	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.
// Steps with NeedsReboot=true (re-run after reboot) and steps not yet
// reached (absent from completedResults) are both included.
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 next round).
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 final summary screen.
// currentResults are from the last run; prevResults accumulate earlier rounds.
func donePhase(currentResults []runner.Result, prevResults []state.StepResult) {
	var (
		mw           *walk.MainWindow
		countdownLbl *walk.Label
	)

	// Combined ordered list: previous rounds first, then current round
	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
		}
		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 (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 completed results (NeedsReboot step excluded – runs 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") // ✓

+  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.

+// Steps with NeedsReboot=true (re-run after reboot) and steps not yet

+// reached (absent from completedResults) are both included.

+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 next round).

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

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

+// donePhase shows the final summary screen.

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

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

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

+  // Combined ordered list: previous rounds first, then current round

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

+    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()


// 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 apply the script changes. The remote's 12-windows-update.ps1 has the old scheduled task code, replace entirely:


<#
.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