xetup/internal/gui/gui.go
X9 Dev 0462881980
All checks were successful
release / build-and-release (push) Successful in 22s
fix: taskbar pins, Edge NTP, black bg, step progress strip
- 04-default-profile: default profile now pins Explorer+Edge (was empty),
  preventing MS Store and other defaults from appearing in taskbar
- 03-system-registry: disable Edge new tab page quick links, background,
  content feed (NewTabPageQuickLinksEnabled/BackgroundEnabled/AllowedBackgroundTypes)
- 05-personalization: set Wallpaper="" in default hive so new user accounts
  get solid-color background instead of black fallback
- runner: add onStepStart callback, fires before each script launch
- gui: step progress strip in run phase — color-coded labels per step
  (pending gray · / running blue ► / ok green ✓ / error red ✗ / skipped gray –)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:28:38 +02:00

586 lines
15 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
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/runner"
)
// Run opens the xetup window and blocks until the user closes it.
func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
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 := runPhase(runCfg, res.steps)
donePhase(results)
return
}
}
}
// --------------------------------------------------------------------------
// 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 blink state.
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 {
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)) // stepID → index
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},
}
}
if err := (MainWindow{
AssignTo: &mw,
Title: "xetup \u2014 probiha instalace",
Size: Size{Width: 760, Height: 740},
Layout: VBox{},
Children: []Widget{
Label{AssignTo: &statusLbl, Text: "Spoustim..."},
// 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
}
// updateIndicator refreshes label 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
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()
return results
}
// --------------------------------------------------------------------------
// Phase 3 Summary with auto-reboot countdown
// --------------------------------------------------------------------------
const rebootCountdown = 60 // seconds before automatic reboot
func donePhase(results []runner.Result) {
var (
mw *walk.MainWindow
countdownLbl *walk.Label
)
ok, errs, skipped := 0, 0, 0
rows := make([]Widget, 0, len(results))
for _, res := range results {
var icon string
switch res.Status {
case "OK":
ok++
icon = "OK "
case "ERROR":
errs++
icon = "ERR "
default:
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))
}
rows = append(rows, Label{
Text: text,
Font: Font{Family: "Consolas", PointSize: 9},
})
}
summaryText := fmt.Sprintf("OK: %d CHYBY: %d PRESKOCENO: %d", ok, errs, skipped)
// cancelled by "Zrusit restart" button
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: rows,
},
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) // signal goroutine to stop ticker
reboot()
mw.Close()
},
},
PushButton{
Text: " Zrusit restart ",
OnClicked: func() {
select {
case <-cancelReboot: // already closed
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
}
}