xetup/internal/gui/gui.go
X9 Dev d30767ef8b
Some checks failed
release / build-and-release (push) Failing after 32s
fix: comprehensive reliability and robustness improvements
Critical fixes:
- Fix resume mode: StepsByIDs returned Enabled=false, all resume steps
  would be SKIPPED (deployment could never resume after reboot)
- Add reboot loop protection: per-step retry counter (max 5) prevents
  infinite reboot cycles when a step always exits with code 9
- Block reboot when state.Save() fails in resumePhase (prevents state
  loss leading to full restart from scratch)
- Atomic state file write (write-to-tmp + rename) prevents JSON
  corruption on BSOD/power loss mid-write
- Script watchdog: kills scripts after 30 min of no output (resets on
  each line, so active long-running scripts are never killed)
- Fix copyFile: check Close() error explicitly instead of deferred
  close that silently drops flush errors (e.g. disk full)

High severity:
- Cleanup() now logs errors instead of silently ignoring them
- Email report: 3 retries with backoff + always saves C:\X9\report.html
- Winget parallel jobs: 10 min timeout, kill hung jobs
- UCPD stop verification: 2s wait + state check before PDF association
- Atera installer: /qn -> /qb so MFA window can appear
- GVLK activation: match by EditionID (registry, not localized) instead
  of fragile OS caption string matching

Medium severity:
- Default profile hive unload: retry loop (5 attempts, increasing delay)
- LayoutModification.xml: UTF-8 without BOM (PS 5.1 Set-Content adds BOM)
- Set-Reg SYSTEM task: try/finally ensures temp file + task cleanup
- Windows Update: @($available).Count for PS 5.1 single-result edge case
- config.json: add missing kmsServer field in activation section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 11:49:43 +02:00

833 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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/preflight"
"git.xetup.x9.cz/x9/xetup/internal/prereboot"
"git.xetup.x9.cz/x9/xetup/internal/report"
"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
// Update runtime config so scripts see the user's GUI selections
_ = config.Save(res.cfg, runCfg.ConfigPath)
results, needsReboot := runPhase(runCfg, res.steps, false)
if needsReboot {
prepareRebootAndRestart(res.cfg, res.steps, results, cfgPath, runCfg)
return
}
donePhase(results, nil)
return
}
}
}
// --------------------------------------------------------------------------
// Resume mode
// --------------------------------------------------------------------------
// maxStepRetries is the maximum number of reboot cycles a single step is
// allowed before it is marked as ERROR and skipped.
const maxStepRetries = 5
func resumePhase(st *state.State, runCfg runner.RunConfig) {
runCfg.LogFile = st.LogFile
// Initialise retry counts (backward-compat with older state files)
if st.RetryCounts == nil {
st.RetryCounts = make(map[string]int)
}
steps := runner.StepsByIDs(st.PendingSteps)
// StepsByIDs returns Enabled=false; resume steps must be enabled.
for i := range steps {
steps[i].Enabled = true
}
// Check retry limits — skip steps that have been retried too many times
for i := range steps {
if st.RetryCounts[steps[i].ID] >= maxStepRetries {
steps[i].Enabled = false
st.Results = append(st.Results, state.StepResult{
StepID: steps[i].ID,
Num: steps[i].Num,
Name: steps[i].Name,
Status: "ERROR",
})
}
}
results, needsReboot := runPhase(runCfg, steps, true)
// Accumulate completed results (NeedsReboot step excluded runs again)
newResults := append(st.Results, toStateResults(results)...) //nolint:gocritic
if needsReboot {
// Increment retry counts for steps that requested reboot
for _, r := range results {
if r.NeedsReboot {
st.RetryCounts[r.Step.ID]++
}
}
// 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,
RetryCounts: st.RetryCounts,
}
if err := state.Save(newSt); err != nil {
walk.MsgBox(nil, "Chyba", "Nelze ulozit stav: "+err.Error(), walk.MsgBoxIconError)
return // do NOT reboot if state was not persisted
}
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
}
// Run pre-flight checks and build status widgets
pfResults := preflight.RunAll()
var pfWidgets []Widget
allOK := true
for _, r := range pfResults {
color := walk.RGB(0, 140, 0)
prefix := "\u2713 "
if !r.OK {
color = walk.RGB(200, 40, 40)
prefix = "\u2717 "
allOK = false
}
text := prefix + r.Name + ": " + r.Detail
lbl := Label{Text: text, Font: Font{PointSize: 9}}
_ = color // applied after window creation
pfWidgets = append(pfWidgets, lbl)
}
_ = allOK
// Collect label pointers to set color after creation
pfLabels := make([]*walk.Label, len(pfResults))
for i := range pfWidgets {
pfWidgets[i] = Label{
AssignTo: &pfLabels[i],
Text: pfWidgets[i].(Label).Text,
Font: Font{PointSize: 9},
}
}
if err := (MainWindow{
AssignTo: &mw,
Title: "xetup \u2014 Windows deployment",
Size: Size{Width: 760, Height: 740},
Layout: VBox{},
Children: []Widget{
// ── Pre-flight checks ────────────────────────────────────────────
Composite{
Layout: VBox{MarginsZero: true},
Children: pfWidgets,
},
HSeparator{},
// ── 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
}
// Apply colors to pre-flight labels (must be done after window creation)
for i, r := range pfResults {
if pfLabels[i] != nil {
if r.OK {
pfLabels[i].SetTextColor(walk.RGB(0, 140, 0))
} else {
pfLabels[i].SetTextColor(walk.RGB(200, 40, 40))
}
}
}
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
var emailRows []report.StepResult
for _, r := range prevResults {
rows = append(rows, displayRow{r.Num, r.Name, r.Status, r.Elapsed})
emailRows = append(emailRows, report.StepResult{Num: r.Num, Name: r.Name, Status: r.Status, Elapsed: r.Elapsed})
}
for _, r := range currentResults {
if r.NeedsReboot {
continue
}
rows = append(rows, displayRow{r.Step.Num, r.Step.Name, r.Status, r.Elapsed})
emailRows = append(emailRows, report.StepResult{Num: r.Step.Num, Name: r.Step.Name, Status: r.Status, Elapsed: 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)
// Send email report (non-blocking; report.Send retries and saves local copy)
go func() {
_ = report.Send(emailRows)
}()
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
}
}