Walk uses Win32 controls directly — works on VMware ESXi, Hyper-V and any VM without GPU. No CGo, no MinGW needed. - internal/gui/gui.go: 3-phase Walk declarative GUI (form → live run → summary) - cmd/xetup/app.manifest: UAC requireAdministrator + ComCtl32 v6 + DPI awareness - CI: remove MinGW, add rsrc generation step, simplified build
461 lines
12 KiB
Go
461 lines
12 KiB
Go
//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"
|
||
"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
|
||
// --------------------------------------------------------------------------
|
||
|
||
func donePhase(results []runner.Result) {
|
||
var mw *walk.MainWindow
|
||
|
||
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)
|
||
|
||
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: 560},
|
||
Layout: VBox{MarginsZero: true},
|
||
Children: rows,
|
||
},
|
||
HSeparator{},
|
||
Label{
|
||
Text: summaryText,
|
||
Alignment: AlignHCenterVNear,
|
||
Font: Font{Bold: true},
|
||
},
|
||
Composite{
|
||
Layout: HBox{MarginsZero: true},
|
||
Children: []Widget{
|
||
HSpacer{},
|
||
PushButton{
|
||
Text: " ZAVRIT ",
|
||
OnClicked: func() {
|
||
mw.Close()
|
||
},
|
||
},
|
||
HSpacer{},
|
||
},
|
||
},
|
||
},
|
||
}.Create()); err != nil {
|
||
return
|
||
}
|
||
|
||
mw.Run()
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// 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
|
||
}
|
||
}
|