xetup/internal/gui/gui.go
X9 Dev 54ea9a1b0d
All checks were successful
release / build-and-release (push) Successful in 8m25s
gui: force software renderer for VM compatibility
VMware SVGA II (ESXi) and similar virtual GPUs don't support the OpenGL
version Fyne needs - window opens but stays blank. Force FYNE_RENDERER=software
so Fyne uses CPU/GDI rendering instead. No visible difference for this UI.

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

363 lines
11 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.

// Package gui implements the Fyne-based graphical interface for xetup.
//
// Three phases, one window:
// 1. Config form PC name, product key, profile, step selection,
// 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"
"io"
"os"
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"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.
// cfgPath is the default config.json path (next to the exe).
func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
// Force software (CPU/GDI) rendering so the app works on VMs and machines
// without proper OpenGL support (VMware SVGA, Hyper-V basic display, etc.).
// The UI is simple enough that GPU acceleration gives no benefit.
os.Setenv("FYNE_RENDERER", "software") //nolint:errcheck
a := app.New()
a.Settings().SetTheme(theme.DarkTheme())
w := a.NewWindow("xetup — Windows deployment")
w.Resize(fyne.NewSize(740, 680))
w.SetMaster() // closing this window quits the app
showForm(w, cfg, runCfg, cfgPath)
w.ShowAndRun()
}
// --------------------------------------------------------------------------
// Phase 1 Config form
// --------------------------------------------------------------------------
func showForm(w fyne.Window, cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
// ── Text inputs ─────────────────────────────────────────────────────────
pcName := widget.NewEntry()
pcName.SetPlaceHolder("napr. NB-KLIENT-01 (prazdne = neprejmenovat)")
pcName.SetText(cfg.Deployment.PCName)
pcDesc := widget.NewEntry()
pcDesc.SetPlaceHolder("napr. PC recepce")
pcDesc.SetText(cfg.Deployment.PCDescription)
productKey := widget.NewEntry()
productKey.SetPlaceHolder("prazdne = OA3 / GVLK fallback")
productKey.SetText(cfg.Activation.ProductKey)
profileSel := widget.NewSelect([]string{"default", "admin", "user"}, nil)
profileSel.SetSelected(cfg.Deployment.ProfileType)
// ── Step checkboxes ─────────────────────────────────────────────────────
items := runner.AllSelectableItems()
checks := make([]*widget.Check, len(items))
checkObjs := make([]fyne.CanvasObject, len(items))
for i, item := range items {
c := widget.NewCheck(item.Label, nil)
c.SetChecked(itemEnabled(cfg, item))
checks[i] = c
checkObjs[i] = c
}
stepsScroll := container.NewVScroll(container.NewVBox(checkObjs...))
stepsScroll.SetMinSize(fyne.NewSize(0, 290))
// ── collectCfg reads current form state into a Config ───────────────────
collectCfg := func() config.Config {
out := cfg // start from loaded config (preserves fields not shown in form)
out.Deployment.PCName = pcName.Text
out.Deployment.PCDescription = pcDesc.Text
out.Activation.ProductKey = productKey.Text
out.Deployment.ProfileType = profileSel.Selected
selected := make(map[string]bool, len(items))
for i, item := range items {
selected[item.Key] = checks[i].Checked
}
_, features := buildStepsAndFeatures(selected)
out.Features = features
return out
}
// ── Toolbar: Load / Save config ─────────────────────────────────────────
jsonFilter := storage.NewExtensionFileFilter([]string{".json"})
loadBtn := widget.NewButton("Nacist config...", func() {
d := dialog.NewFileOpen(func(rc fyne.URIReadCloser, err error) {
if err != nil || rc == nil {
return
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
dialog.ShowError(err, w)
return
}
newCfg := config.DefaultConfig()
if err := json.Unmarshal(data, &newCfg); err != nil {
dialog.ShowError(err, w)
return
}
// Reload the entire form with the new config
showForm(w, newCfg, runCfg, rc.URI().Path())
}, w)
d.SetFilter(jsonFilter)
d.Show()
})
saveBtn := widget.NewButton("Ulozit config...", func() {
d := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) {
if err != nil || wc == nil {
return
}
defer wc.Close()
data, err := json.MarshalIndent(collectCfg(), "", " ")
if err != nil {
dialog.ShowError(err, w)
return
}
if _, err := wc.Write(data); err != nil {
dialog.ShowError(err, w)
}
}, w)
d.SetFilter(jsonFilter)
d.SetFileName("config.json")
d.Show()
})
// ── SPUSTIT ─────────────────────────────────────────────────────────────
startBtn := widget.NewButton(" SPUSTIT ", func() {
finalCfg := collectCfg()
runCfg.ProfileType = finalCfg.Deployment.ProfileType
selected := make(map[string]bool, len(items))
for i, item := range items {
selected[item.Key] = checks[i].Checked
}
steps, features := buildStepsAndFeatures(selected)
finalCfg.Features = features
_ = config.Save(finalCfg, cfgPath) // auto-save to default path
showRun(w, runCfg, steps)
})
startBtn.Importance = widget.HighImportance
// ── Layout ───────────────────────────────────────────────────────────────
form := widget.NewForm(
widget.NewFormItem("PC jmeno", pcName),
widget.NewFormItem("Popis PC", pcDesc),
widget.NewFormItem("Product Key", productKey),
widget.NewFormItem("Profil", profileSel),
)
toolbar := container.NewHBox(loadBtn, saveBtn)
w.SetContent(container.NewBorder(
form,
container.NewVBox(
widget.NewSeparator(),
container.NewBorder(nil, nil, toolbar, container.NewCenter(startBtn)),
),
nil, nil,
container.NewVBox(
widget.NewSeparator(),
widget.NewLabel("Kroky a nastaveni (odskrtnete co nechcete spustit):"),
stepsScroll,
),
))
}
// --------------------------------------------------------------------------
// Phase 2 Live run view
// --------------------------------------------------------------------------
func showRun(w fyne.Window, runCfg runner.RunConfig, steps []runner.Step) {
statusLabel := widget.NewLabel("Spoustim...")
// Virtualised list efficient for thousands of log lines
var (
mu sync.Mutex
logLines []string
)
logList := widget.NewList(
func() int {
mu.Lock()
defer mu.Unlock()
return len(logLines)
},
func() fyne.CanvasObject {
return widget.NewLabel("")
},
func(id widget.ListItemID, obj fyne.CanvasObject) {
mu.Lock()
defer mu.Unlock()
if id < len(logLines) {
obj.(*widget.Label).SetText(logLines[id])
}
},
)
var cancelFn context.CancelFunc
stopBtn := widget.NewButton(" ZASTAVIT ", func() {
if cancelFn != nil {
cancelFn()
}
})
stopBtn.Importance = widget.DangerImportance
w.SetContent(container.NewBorder(
container.NewVBox(statusLabel, widget.NewSeparator()),
container.NewCenter(container.NewPadded(stopBtn)),
nil, nil,
logList,
))
ctx, cancel := context.WithCancel(context.Background())
cancelFn = cancel
r := runner.New(
runCfg,
func(l runner.LogLine) {
mu.Lock()
logLines = append(logLines, l.Text)
mu.Unlock()
logList.Refresh()
logList.ScrollToBottom()
},
func(res runner.Result) {
statusLabel.SetText(fmt.Sprintf(
"Krok %s %s: %s", res.Step.Num, res.Step.Name, res.Status,
))
},
)
go func() {
results := r.Run(ctx, steps)
showDone(w, results)
}()
}
// --------------------------------------------------------------------------
// Phase 3 Summary
// --------------------------------------------------------------------------
func showDone(w fyne.Window, results []runner.Result) {
ok, errs, skipped := 0, 0, 0
rows := make([]fyne.CanvasObject, 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 = " "
}
text := icon + res.Step.Num + " " + res.Step.Name
if res.Elapsed > 0 {
text += fmt.Sprintf(" (%s)", res.Elapsed.Round(time.Second))
}
rows = append(rows, widget.NewLabel(text))
}
summary := widget.NewLabel(fmt.Sprintf(
"OK: %d CHYBY: %d PRESKOCENO: %d", ok, errs, skipped,
))
summary.TextStyle = fyne.TextStyle{Bold: true}
closeBtn := widget.NewButton(" ZAVRIT ", func() {
fyne.CurrentApp().Quit()
})
w.SetContent(container.NewBorder(
widget.NewLabelWithStyle("Hotovo", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
container.NewVBox(
widget.NewSeparator(),
container.NewCenter(summary),
container.NewCenter(container.NewPadded(closeBtn)),
),
nil, nil,
container.NewVScroll(container.NewVBox(rows...)),
))
}
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
// itemEnabled returns the initial checked state for a checkbox row,
// reading from the loaded config (defaults to true / enabled when absent).
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
}
// buildStepsAndFeatures converts the flat checkbox map into the structures
// that runner.Runner and config.Config expect.
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 == "" {
// Simple step: enabled iff its own checkbox is checked
stepOn[item.StepID] = selected[item.Key]
} else {
// Feature checkbox: at least one checked feature enables the step
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
}