xetup/internal/gui/gui.go
X9 Dev 0cc4779ed6
All checks were successful
release / build-and-release (push) Successful in 8m25s
Replace bubbletea TUI with Fyne GUI
- Drop bubbletea, huh, lipgloss and all their transitive deps
- Add fyne.io/fyne/v2 – native Windows GUI, dark theme
- New internal/gui/gui.go: 3-phase window (form → live run → summary)
  - Form: PC name, product key, profile, per-step checkboxes
  - Load config / Save config buttons for per-client presets
  - SPUSTIT button auto-saves to default config.json
  - Live run: virtualised log list, ZASTAVIT button
  - Summary: per-step status + elapsed time, ZAVRIT button
- cmd/xetup/main.go: pass cfgPath to gui.Run so save/load works
- CI: add mingw-w64-gcc, CGO_ENABLED=1, -H windowsgui flag

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

357 lines
10 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"
"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) {
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
}