All checks were successful
release / build-and-release (push) Successful in 8m25s
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>
363 lines
11 KiB
Go
363 lines
11 KiB
Go
// 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
|
||
}
|