// 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 }