xetup/internal/gui/gui.go
X9 Dev af41dde33c fix: workflow audit - config parsing, step ordering, cleanup
Root cause fix: runner.go passed config as unevaluated PS expression
via -File mode - scripts received a literal string instead of parsed
object. Changed to -ConfigPath; scripts load JSON themselves via
shared common.ps1 (Write-Log, Get-Feature, Load-Config).

GUI now regenerates runtime config before run so user selections
actually reach the scripts.

Merged 04-default-profile + 05-personalization into single script
(one hive load/unload, no Explorer restart, no hive contention).

Deleted Deploy-Windows.ps1 (xetup.exe is sole entry point),
06-scheduled-tasks.ps1 (tasks caused more harm than good),
07-desktop-info.ps1 (replaced by BackInfo long ago).

Step ordering: activation moved early, pcIdentity before WU
(exit 9 on rename only when rename actually happened).

Edge policies split into mandatory (telemetry, first-run) vs
recommended (UI preferences user can override).

Atera install uses Start-Process -Wait instead of fragile sleep.
Updated config.json, tests, DefaultConfig to match current state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 12:21:41 +02:00

741 lines
19 KiB
Go
Raw 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/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
// 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
// --------------------------------------------------------------------------
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
}
}