xetup/internal/gui/gui.go
X9 Dev d06af1a87f
All checks were successful
release / build-and-release (push) Successful in 22s
gui: auto-reboot after deployment with 60s countdown
Summary screen shows countdown "Restart za Xs..." and reboots automatically.
Buttons: "Restartovat ted" (immediate) and "Zrusit restart" (cancel).

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

521 lines
13 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"
"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
// --------------------------------------------------------------------------
func runPhase(runCfg runner.RunConfig, steps []runner.Step) []runner.Result {
var (
mw *walk.MainWindow
statusLbl *walk.Label
logTE *walk.TextEdit
cancelFn context.CancelFunc
)
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..."},
HSeparator{},
TextEdit{
AssignTo: &logTE,
ReadOnly: true,
VScroll: true,
Font: Font{Family: "Consolas", PointSize: 9},
MinSize: Size{Height: 590},
},
HSeparator{},
Composite{
Layout: HBox{MarginsZero: true},
Children: []Widget{
HSpacer{},
PushButton{
Text: " ZASTAVIT ",
OnClicked: func() {
if cancelFn != nil {
cancelFn()
}
},
},
},
},
},
}.Create()); err != nil {
return nil
}
var (
mu sync.Mutex
results []runner.Result
done = make(chan struct{})
)
ctx, cancel := context.WithCancel(context.Background())
cancelFn = cancel
// Cancel running scripts when user closes the window.
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(res runner.Result) {
mw.Synchronize(func() {
statusLbl.SetText(fmt.Sprintf(
"Krok %s \u2013 %s: %s", res.Step.Num, res.Step.Name, res.Status,
))
})
},
)
go func() {
defer close(done)
res := r.Run(ctx, steps)
mu.Lock()
results = res
mu.Unlock()
mw.Synchronize(func() {
mw.Close()
})
}()
mw.Run()
cancel() // stop scripts if window was closed by user
<-done // wait for goroutine to exit cleanly
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
}
}