xetup/internal/gui/gui.go
X9 Dev cf2037ae7d
All checks were successful
release / build-and-release (push) Successful in 22s
Replace Fyne GUI with Walk (Win32 native, no OpenGL)
Walk uses Win32 controls directly — no GPU/OpenGL dependency — so the app
renders correctly on VMware ESXi, Hyper-V and any other VM. No MinGW needed
in CI; pure cross-compile with GOOS=windows.

- internal/gui/gui.go: rewrite with Walk declarative API (3 phases:
  form → live run → summary); load/save config via native FileDialog;
  Closing event cancels running scripts cleanly
- cmd/xetup/app.manifest: UAC requireAdministrator + ComCtl32 v6 +
  per-monitor DPI awareness (rsrc generates rsrc.syso in CI)
- .forgejo/workflows/release.yml: drop MinGW, add rsrc generation step
- go.mod/go.sum: remove Fyne and all its deps; only Walk (3 deps total)

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

461 lines
12 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"
"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
}
}