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