feat: state machine for reboot-resume across Windows Update cycles
All checks were successful
release / build-and-release (push) Successful in 24s

xetup.exe now acts as an orchestrator across system reboots:
- PS scripts exit 9 to signal "reboot needed, re-run me" (WU) or
  "done but reboot needed to finalize" (Dell BIOS)
- On exit 9: xetup saves state.json, ensures adminx9 account,
  copies itself to stable path, enables autologon, registers
  X9-Resume scheduled task (AtLogOn adminx9, RunLevel Highest)
- On resume: loads pending steps from state, continues seamlessly
  with "Pokracuji po restartu..." label in the run window
- On completion: disables autologon, removes X9-Resume task,
  deletes state file, shows summary with accumulated results
  across all reboot rounds

New packages: internal/state, internal/prereboot
Script 12: simplified to exit 0 (done) or exit 9 (reboot needed)
Script 11: exit 9 when DCU exit code 1 (BIOS staged, reboot needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
X9 Dev 2026-04-16 17:09:53 +02:00
parent 9feb7ba4e1
commit 5e01826a49
10 changed files with 600 additions and 150 deletions

View file

@ -21,16 +21,10 @@ import (
"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() {
// 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 {
@ -38,7 +32,7 @@ func main() {
}
defer os.RemoveAll(tmpDir)
// Extract embedded scripts and assets
// 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)
}
@ -46,6 +40,24 @@ func main() {
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 {

View file

@ -7,6 +7,10 @@
// 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 (
@ -23,11 +27,20 @@ import (
. "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 {
@ -38,13 +51,53 @@ func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
cfgPath = res.cfgPath
case "run":
runCfg.ProfileType = res.cfg.Deployment.ProfileType
results := runPhase(runCfg, res.steps)
donePhase(results)
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
// --------------------------------------------------------------------------
@ -232,14 +285,17 @@ func formPhase(cfg config.Config, runCfg runner.RunConfig, cfgPath string) formR
// Phase 2 Live run view
// --------------------------------------------------------------------------
// stepIndicator tracks a step's label and blink state.
// 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"
}
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.
// Returns (results, needsReboot).
func runPhase(runCfg runner.RunConfig, steps []runner.Step, resuming bool) ([]runner.Result, bool) {
var (
mw *walk.MainWindow
statusLbl *walk.Label
@ -249,7 +305,7 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
// Build step indicator labels (one per step, in order)
indicators := make([]stepIndicator, len(steps))
stepIndex := make(map[string]int, len(steps)) // stepID → index
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"}
@ -262,13 +318,20 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
}
}
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: "xetup \u2014 probiha instalace",
Title: title,
Size: Size{Width: 760, Height: 740},
Layout: VBox{},
Children: []Widget{
Label{AssignTo: &statusLbl, Text: "Spoustim..."},
Label{AssignTo: &statusLbl, Text: initialStatus},
// Step progress strip
Composite{
Layout: HBox{MarginsZero: true},
@ -299,17 +362,17 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
},
},
}.Create()); err != nil {
return nil
return nil, false
}
// updateIndicator refreshes label text and color — call inside Synchronize.
// 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)) // dodger blue
ind.label.SetTextColor(walk.RGB(30, 144, 255))
case "ok":
ind.label.SetText(s.Num + " \u2713") // ✓
ind.label.SetTextColor(walk.RGB(0, 180, 0))
@ -386,7 +449,78 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
mu.Lock()
defer mu.Unlock()
return results
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
}
// --------------------------------------------------------------------------
@ -395,18 +529,38 @@ func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
const rebootCountdown = 60 // seconds before automatic reboot
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) {
var (
mw *walk.MainWindow
mw *walk.MainWindow
countdownLbl *walk.Label
)
ok, errs, skipped := 0, 0, 0
rows := make([]Widget, 0, len(results))
// Combined ordered list: previous rounds first, then current round
type displayRow struct {
Num string
Name string
Status string
Elapsed time.Duration
}
for _, res := range results {
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 res.Status {
switch row.Status {
case "OK":
ok++
icon = "OK "
@ -417,11 +571,11 @@ func donePhase(results []runner.Result) {
skipped++
icon = "\u2013 "
}
text := icon + res.Step.Num + " \u2013 " + res.Step.Name
if res.Elapsed > 0 {
text += fmt.Sprintf(" (%s)", res.Elapsed.Round(time.Second))
text := icon + row.Num + " \u2013 " + row.Name
if row.Elapsed > 0 {
text += fmt.Sprintf(" (%s)", row.Elapsed.Round(time.Second))
}
rows = append(rows, Label{
widgets = append(widgets, Label{
Text: text,
Font: Font{Family: "Consolas", PointSize: 9},
})
@ -429,7 +583,6 @@ func donePhase(results []runner.Result) {
summaryText := fmt.Sprintf("OK: %d CHYBY: %d PRESKOCENO: %d", ok, errs, skipped)
// cancelled by "Zrusit restart" button
cancelReboot := make(chan struct{})
if err := (MainWindow{
@ -447,7 +600,7 @@ func donePhase(results []runner.Result) {
ScrollView{
MinSize: Size{Height: 510},
Layout: VBox{MarginsZero: true},
Children: rows,
Children: widgets,
},
HSeparator{},
Label{
@ -467,7 +620,7 @@ func donePhase(results []runner.Result) {
PushButton{
Text: " Restartovat ted ",
OnClicked: func() {
close(cancelReboot) // signal goroutine to stop ticker
close(cancelReboot)
reboot()
mw.Close()
},
@ -476,7 +629,7 @@ func donePhase(results []runner.Result) {
Text: " Zrusit restart ",
OnClicked: func() {
select {
case <-cancelReboot: // already closed
case <-cancelReboot:
default:
close(cancelReboot)
}
@ -547,9 +700,9 @@ func itemEnabled(cfg config.Config, item runner.SelectableItem) bool {
}
func buildStepsAndFeatures(selected map[string]bool) ([]runner.Step, config.Features) {
items := runner.AllSelectableItems()
items := runner.AllSelectableItems()
features := make(config.Features)
stepOn := make(map[string]bool)
stepOn := make(map[string]bool)
for _, item := range items {
if item.FeatureID == "" {

View file

@ -0,0 +1,20 @@
//go:build !windows
// Package prereboot ensures the reboot-resume infrastructure is in place
// before xetup triggers a system restart mid-deployment.
package prereboot
// StablePath is the platform stub for the stable binary path.
const StablePath = "/tmp/xetup-resume"
// TaskName is the scheduled task that re-launches xetup after each reboot.
const TaskName = "X9-Resume"
// IsAdminx9 always returns false on non-Windows platforms.
func IsAdminx9() bool { return false }
// Prepare is a no-op on non-Windows platforms.
func Prepare() error { return nil }
// Cleanup is a no-op on non-Windows platforms.
func Cleanup() {}

View file

@ -0,0 +1,190 @@
//go:build windows
// Package prereboot ensures the reboot-resume infrastructure is in place
// before xetup triggers a system restart mid-deployment.
package prereboot
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"golang.org/x/sys/windows/registry"
)
// StablePath is where xetup.exe is copied so it survives across reboots.
const StablePath = `C:\Windows\Setup\Scripts\xetup.exe`
// TaskName is the scheduled task that re-launches xetup after each reboot.
const TaskName = "X9-Resume"
const adminUser = "adminx9"
const winlogonKey = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon`
// IsAdminx9 reports whether the current process runs as the adminx9 account.
func IsAdminx9() bool {
return strings.EqualFold(os.Getenv("USERNAME"), adminUser)
}
// Prepare ensures the reboot-resume infrastructure is in place.
// When not running as adminx9 (first reboot trigger):
// - creates the adminx9 local account if it does not exist
// - copies the running binary to StablePath
// - enables autologon for adminx9 (AutoLogonCount=20 safety cap)
// - registers the X9-Resume scheduled task (AtLogOn adminx9, RunLevel Highest)
//
// When already running as adminx9 (subsequent rounds) all of the above is
// already in place; the function returns immediately without changes.
// Safe to call repeatedly (idempotent).
func Prepare() error {
if IsAdminx9() {
return nil
}
if err := ensureAdminx9User(); err != nil {
return fmt.Errorf("adminx9 account: %w", err)
}
if err := copySelfTo(StablePath); err != nil {
return fmt.Errorf("copy binary: %w", err)
}
if err := setAutologon(); err != nil {
return fmt.Errorf("autologon: %w", err)
}
if err := registerResumeTask(); err != nil {
return fmt.Errorf("resume task: %w", err)
}
return nil
}
// Cleanup disables autologon and removes the X9-Resume scheduled task.
// Called when all deployment steps have completed successfully.
func Cleanup() {
_ = disableAutologon()
_ = unregisterResumeTask()
}
// ensureAdminx9User creates the adminx9 local account if absent.
func ensureAdminx9User() error {
// "net user adminx9" exits 0 when user exists
if err := newHiddenCmd("net", "user", adminUser).Run(); err == nil {
return nil // already exists
}
// Create with empty password
if b, err := newHiddenCmd("net", "user", adminUser, "", "/add").CombinedOutput(); err != nil {
return fmt.Errorf("net user /add: %s: %w", strings.TrimSpace(string(b)), err)
}
// Add to local Administrators group
if b, err := newHiddenCmd("net", "localgroup", "Administrators", adminUser, "/add").CombinedOutput(); err != nil {
return fmt.Errorf("net localgroup: %s: %w", strings.TrimSpace(string(b)), err)
}
// Hide from the Windows login screen
k, _, err := registry.CreateKey(registry.LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList`,
registry.SET_VALUE)
if err != nil {
return fmt.Errorf("registry hide: %w", err)
}
defer k.Close()
return k.SetDWordValue(adminUser, 0)
}
// copySelfTo copies the running executable to dst.
// Skips the copy when src and dst resolve to the same path.
func copySelfTo(dst string) error {
src, err := os.Executable()
if err != nil {
return err
}
if resolved, err := filepath.EvalSymlinks(src); err == nil {
src = resolved
}
if strings.EqualFold(src, dst) {
return nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
return copyFile(src, dst)
}
func copyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return err
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return err
}
defer w.Close()
_, err = io.Copy(w, r)
return err
}
func setAutologon() error {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, winlogonKey, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
for _, kv := range []struct{ name, val string }{
{"AutoAdminLogon", "1"},
{"DefaultUserName", adminUser},
{"DefaultPassword", ""},
{"DefaultDomainName", "."},
} {
if err := k.SetStringValue(kv.name, kv.val); err != nil {
return fmt.Errorf("set %s: %w", kv.name, err)
}
}
// Safety cap: self-limits even if cleanup task fails to run
return k.SetDWordValue("AutoLogonCount", 20)
}
func disableAutologon() error {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, winlogonKey, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
_ = k.SetStringValue("AutoAdminLogon", "0")
_ = k.DeleteValue("DefaultPassword")
_ = k.DeleteValue("AutoLogonCount")
return nil
}
func registerResumeTask() error {
ps := fmt.Sprintf(`
$action = New-ScheduledTaskAction -Execute '%s' -Argument '--resume'
$trigger = New-ScheduledTaskTrigger -AtLogOn -User '%s'
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 4) -MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal -UserId '%s' -LogonType Interactive -RunLevel Highest
Unregister-ScheduledTask -TaskName 'X9-Resume' -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask -TaskName 'X9-Resume' -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
`, StablePath, adminUser, adminUser)
return runPS(ps)
}
func unregisterResumeTask() error {
return runPS(`Unregister-ScheduledTask -TaskName 'X9-Resume' -Confirm:$false -ErrorAction SilentlyContinue`)
}
func runPS(script string) error {
cmd := newHiddenCmd("powershell.exe",
"-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
b, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(string(b)), err)
}
return nil
}
func newHiddenCmd(name string, args ...string) *exec.Cmd {
cmd := exec.Command(name, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd
}

View file

@ -5,6 +5,7 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
@ -14,6 +15,11 @@ import (
"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")
// Step describes a single deployment step.
type Step struct {
ID string // e.g. "adminAccount"
@ -127,9 +133,10 @@ type RunConfig struct {
// Result is the outcome of a single step.
type Result struct {
Step Step
Status string // "OK", "ERROR", "SKIPPED"
Elapsed time.Duration
Step Step
Status string // "OK", "ERROR", "SKIPPED", "CANCELLED"
Elapsed time.Duration
NeedsReboot bool // true when the script exited with code 9
}
// LogLine is a single output line from a running script.
@ -180,19 +187,23 @@ func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
elapsed := time.Since(start)
status := "OK"
needsReboot := false
if err != nil {
if ctx.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}
res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}
r.onResult(res)
results = append(results, res)
if ctx.Err() != nil {
if ctx.Err() != nil || needsReboot {
break
}
}
@ -253,7 +264,14 @@ func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error
})
}
return cmd.Wait()
waitErr := cmd.Wait()
if waitErr != nil {
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == 9 {
return ErrNeedsReboot
}
}
return waitErr
}
// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:
@ -362,6 +380,32 @@ func extractDir(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(s
return nil
}
// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,
// preserving the canonical AllSteps() order.
func StepsByIDs(ids []string) []Step {
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
var result []Step
for _, s := range AllSteps() {
if want[s.ID] {
result = append(result, s)
}
}
return result
}
// StepByID returns the Step with the given ID, and true if found.
func StepByID(id string) (Step, bool) {
for _, s := range AllSteps() {
if s.ID == id {
return s, true
}
}
return Step{}, false
}
// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {
path := filepath.Join(tmpDir, "config-runtime.json")

View file

@ -0,0 +1,12 @@
//go:build !windows
package state
import "os"
func statePath() string {
if h, err := os.UserHomeDir(); err == nil {
return h + "/xetup-state.json"
}
return "/tmp/xetup-state.json"
}

View file

@ -0,0 +1,7 @@
//go:build windows
package state
func statePath() string {
return `C:\Windows\Setup\Scripts\xetup-state.json`
}

74
internal/state/state.go Normal file
View file

@ -0,0 +1,74 @@
// Package state manages the persistent xetup deployment state used to resume
// after a system reboot. The state file is written before each reboot and
// deleted once all deployment steps have completed.
package state
import (
"encoding/json"
"os"
"path/filepath"
"time"
"git.xetup.x9.cz/x9/xetup/internal/config"
)
// StepResult records the outcome of a single deployment step.
// Results are accumulated across reboot rounds so the final summary
// can show every step regardless of which round it ran in.
type StepResult struct {
StepID string `json:"stepID"`
Num string `json:"num"`
Name string `json:"name"`
Status string `json:"status"` // "OK", "ERROR", "SKIPPED"
Elapsed time.Duration `json:"elapsed"`
}
// State holds everything xetup needs to resume after a reboot.
type State struct {
Config config.Config `json:"config"`
ConfigPath string `json:"configPath"` // path to original config.json
LogFile string `json:"logFile"`
PendingSteps []string `json:"pendingSteps"` // step IDs to run next, in canonical order
Results []StepResult `json:"results"` // accumulated across all rounds
}
// Load reads the state file. Returns a non-nil error when the file is absent.
func Load() (*State, error) {
data, err := os.ReadFile(statePath())
if err != nil {
return nil, err
}
var s State
if err := json.Unmarshal(data, &s); err != nil {
return nil, err
}
return &s, nil
}
// Save writes the state file, creating parent directories as needed.
func Save(s *State) error {
p := statePath()
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0644)
}
// Delete removes the state file. Silently ignores not-found.
func Delete() error {
err := os.Remove(statePath())
if os.IsNotExist(err) {
return nil
}
return err
}
// Exists reports whether a state file is present on disk.
func Exists() bool {
_, err := os.Stat(statePath())
return err == nil
}

View file

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

View file

@ -3,21 +3,19 @@
Installs all available Windows Updates via PSWindowsUpdate module.
.DESCRIPTION
First pass: installs all currently available updates without rebooting.
Then registers a scheduled task "X9-WindowsUpdate" that runs on every
logon (as SYSTEM) until no more updates are found - handles the typical
2-3 reboot cycles required on a fresh Windows installation.
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.
Operator workflow:
1. xetup completes all steps
2. Operator reboots manually
3. On each subsequent logon the scheduled task runs another update pass
4. Task removes itself automatically when system is 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-prvni-kolo-windows-update: First update pass without reboot - installs all currently available updates.
registrovat-scheduled-task-pro-dalsi-kola: Registers X9-WindowsUpdate scheduled task that runs on logon, handles post-reboot update rounds, and self-deletes when no more updates are found.
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,
@ -56,102 +54,32 @@ try {
}
# -----------------------------------------------------------------------
# 2. First update pass (no reboot)
# 2. Check and install available updates
# -----------------------------------------------------------------------
Write-Log "Running first Windows Update pass..." -Level INFO
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
$installed = @($result | Where-Object { $_ -match 'KB\d+|Downloaded|Installed' })
if ($installed.Count -gt 0) {
$result | Where-Object { "$_" -match '\S' } | ForEach-Object { Write-Log " $_" -Level INFO }
Write-Log " First pass complete - reboot required for remaining rounds" -Level OK
} else {
Write-Log " System already up to date" -Level OK
}
$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 " First pass failed: $_" -Level ERROR
Write-Log " Update install failed: $_" -Level ERROR
exit 1
}
# -----------------------------------------------------------------------
# 3. Enable autologon for adminx9 (temporary - disabled when updates complete)
# -----------------------------------------------------------------------
Write-Log "Enabling temporary autologon for adminx9..." -Level INFO
$winlogonPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
try {
Set-ItemProperty -Path $winlogonPath -Name "AutoAdminLogon" -Value "1" -Type String -Force
Set-ItemProperty -Path $winlogonPath -Name "DefaultUserName" -Value "adminx9" -Type String -Force
Set-ItemProperty -Path $winlogonPath -Name "DefaultPassword" -Value "" -Type String -Force
Set-ItemProperty -Path $winlogonPath -Name "DefaultDomainName" -Value "." -Type String -Force
# Safety cap: max 10 automatic logons in case the task fails to clean up
Set-ItemProperty -Path $winlogonPath -Name "AutoLogonCount" -Value 10 -Type DWord -Force
Write-Log " Autologon enabled (adminx9, max 10 rounds)" -Level OK
} catch {
Write-Log " Failed to enable autologon: $_" -Level WARN
Write-Log " Windows Update rounds will require manual login after each reboot" -Level WARN
}
# -----------------------------------------------------------------------
# 4. Scheduled task for post-reboot update rounds (self-deleting)
# -----------------------------------------------------------------------
Write-Log "Registering post-reboot update task..." -Level INFO
$taskName = "X9-WindowsUpdate"
# PowerShell block that runs on each logon until no more updates found.
# When done: disables autologon and removes itself.
$updateScript = @'
Import-Module PSWindowsUpdate -Force -ErrorAction Stop
$updates = Get-WindowsUpdate -AcceptAll -IgnoreReboot
if ($updates) {
Install-WindowsUpdate -AcceptAll -IgnoreReboot | Out-File "C:\Windows\Setup\Scripts\wu-pass-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -Encoding UTF8
} else {
# No more updates - disable autologon and clean up
$wl = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
Set-ItemProperty -Path $wl -Name "AutoAdminLogon" -Value "0" -Type String -Force
Remove-ItemProperty -Path $wl -Name "DefaultPassword" -ErrorAction SilentlyContinue
Remove-ItemProperty -Path $wl -Name "AutoLogonCount" -ErrorAction SilentlyContinue
# Leave a visible marker on the shared Desktop so the operator knows it's done
$ts = Get-Date -Format "yyyy-MM-dd HH:mm"
$doneMsg = "Windows Update dokoncen: $ts`r`nStroj je plne aktualizovan a pripraven k predani klientovi."
[System.IO.File]::WriteAllText(
"C:\Users\Public\Desktop\! WU HOTOVO $ts.txt",
$doneMsg,
[System.Text.Encoding]::UTF8
)
# Lock the workstation - login screen = clear visual signal for the operator.
# Runs as adminx9 (interactive session) via a one-shot task, self-deletes after 5 min.
$lockAction = New-ScheduledTaskAction -Execute "rundll32.exe" -Argument "user32.dll,LockWorkStation"
$lockTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(5)
$lockPrincipal = New-ScheduledTaskPrincipal -UserId "adminx9" -LogonType Interactive -RunLevel Limited
$lockSettings = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 5)
Register-ScheduledTask -TaskName "X9-WUDoneLock" -Action $lockAction -Trigger $lockTrigger `
-Principal $lockPrincipal -Settings $lockSettings -Force -ErrorAction SilentlyContinue | Out-Null
Unregister-ScheduledTask -TaskName "X9-WindowsUpdate" -Confirm:$false
}
'@
try {
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command `"$updateScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 2) `
-MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
# Remove existing task first (idempotent)
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Settings $settings -Principal $principal -Force | Out-Null
Write-Log " Task '$taskName' registered - runs on each logon until fully updated" -Level OK
} catch {
Write-Log " Failed to register scheduled task: $_" -Level WARN
Write-Log " Manual Windows Update rounds will be needed after reboot" -Level WARN
}
Write-Log "Step 12 - Windows Update complete" -Level OK
# Signal xetup that a reboot is needed before running this step again
Write-Log "Step 12 - reboot required (exit 9)" -Level OK
exit 9