package huh

import (
	"cmp"
	"fmt"
	"io"
	"os"
	"slices"
	"strings"
	"time"

	"github.com/charmbracelet/bubbles/key"
	"github.com/charmbracelet/bubbles/spinner"
	"github.com/charmbracelet/bubbles/textinput"
	"github.com/charmbracelet/bubbles/viewport"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/huh/internal/accessibility"
	"github.com/charmbracelet/lipgloss"
)

// MultiSelect is a form multi-select field.
type MultiSelect[T comparable] struct {
	accessor Accessor[[]T]
	key      string
	id       int

	// customization
	title           Eval[string]
	description     Eval[string]
	options         Eval[[]Option[T]]
	filterable      bool
	filteredOptions []Option[T]
	limit           int
	height          int

	// error handling
	validate func([]T) error
	err      error

	// state
	cursor    int
	focused   bool
	filtering bool
	filter    textinput.Model
	viewport  viewport.Model
	spinner   spinner.Model

	// options
	width      int
	accessible bool // Deprecated: use RunAccessible instead.
	theme      *Theme
	keymap     MultiSelectKeyMap
}

// NewMultiSelect returns a new multi-select field.
func NewMultiSelect[T comparable]() *MultiSelect[T] {
	filter := textinput.New()
	filter.Prompt = "/"

	s := spinner.New(spinner.WithSpinner(spinner.Line))

	return &MultiSelect[T]{
		accessor:    &EmbeddedAccessor[[]T]{},
		validate:    func([]T) error { return nil },
		filtering:   false,
		filter:      filter,
		id:          nextID(),
		options:     Eval[[]Option[T]]{cache: make(map[uint64][]Option[T])},
		title:       Eval[string]{cache: make(map[uint64]string)},
		description: Eval[string]{cache: make(map[uint64]string)},
		spinner:     s,
		filterable:  true,
	}
}


Bash

sed -n '530,545p' $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go

Read updateValue function


func (m *MultiSelect[T]) updateValue() {
	value := make([]T, 0)
	for _, option := range m.options.val {
		if option.selected {
			value = append(value, option.Value)
		}
	}
	m.accessor.Set(value)
	m.err = m.validate(m.accessor.Get())
}

func (m *MultiSelect[T]) activeStyles() *FieldStyles {
	theme := m.theme
	if theme == nil {
		theme = ThemeCharm()
	}


grep -n "updateValue\|Finish\|StateCompleted\|huh.StateCompleted" \
  $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go \
  $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/form.go | head -30

Find where updateValue is called and form completion


/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go:225:	m.updateValue()
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go:232:	m.updateValue()
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go:320:				m.updateValue()
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go:354:			m.updateValue()
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go:425:			m.updateValue()
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go:446:			m.updateValue()
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go:448:			m.updateValue()
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go:455:			m.updateValue()
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go:530:func (m *MultiSelect[T]) updateValue() {
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go:729:			m.updateValue()
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/form.go:43:	// StateCompleted is when the user has completed the form.
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/form.go:44:	StateCompleted
/home/dev/go/pkg/mod/github.com/charmbracelet/huh@v1.0.0/form.go:577:			f.State = StateCompleted


sed -n '215,240p' $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go

Check when updateValue is called during form init/focus


	return false
}

// Zoom returns whether the multiselect should be zoomed.
func (*MultiSelect[T]) Zoom() bool {
	return false
}

// Focus focuses the multi-select field.
func (m *MultiSelect[T]) Focus() tea.Cmd {
	m.updateValue()
	m.focused = true
	return nil
}

// Blur blurs the multi-select field.
func (m *MultiSelect[T]) Blur() tea.Cmd {
	m.updateValue()
	m.focused = false
	return nil
}

// Hovered returns the value of the option under the cursor, and a bool
// indicating whether one was found. If there are no visible options, returns
// a zero-valued T and false.
func (m *MultiSelect[T]) Hovered() (T, bool) {


ani tam nikde není možnost uložit nastavení


Našel jsem problém. Je to klasická past bubbletea + huh — pointer binding.

V `NewModel` se vytvoří lokální `m := Model{...}`, pak `buildForm(&m)` uloží `&m.selectedItemKeys` do form accessoru. Vrátí se **kopie** `m`. Form ale pořád píše do té heap-alokované lokální proměnné z `NewModel`, ne do kopie která se pak používá v TUI. Takže `m.selectedItemKeys` v modelu bubbletea je vždy prázdné.

Opravím to — místo pointer bindingu inicializuji `selectedItemKeys` se všemi klíči předem a form je bude podle potřeby odebírat:


grep -n "GetValue\|GetString\|GetInt\|Get(" \
  $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/form.go | head -20

Check huh form value retrieval API


190:	// GetValue returns the field's value.
191:	GetValue() any
444:func (f *Form) Get(key string) any {
448:// GetString returns a result as a string from the form.
449:func (f *Form) GetString(key string) string {
457:// GetInt returns a result as a int from the form.
458:func (f *Form) GetInt(key string) int {
568:		f.results[field.GetKey()] = field.GetValue()
586:			if !f.isGroupHidden(f.selector.Get(i)) {
605:			if !f.isGroupHidden(f.selector.Get(i)) {


Prompt: ani tam nikde není možnost uložit nastavení


sed -n '555,590p' $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/form.go

Read form completion logic


	case tea.KeyMsg:
		switch {
		case key.Matches(msg, f.keymap.Quit):
			f.aborted = true
			f.quitting = true
			f.State = StateAborted
			return f, f.CancelCmd
		}

	case nextFieldMsg:
		// Form is progressing to the next field, let's save the value of the current field.
		field := group.selector.Selected()
		f.results[field.GetKey()] = field.GetValue()

	case nextGroupMsg:
		if len(group.Errors()) > 0 {
			return f, nil
		}

		submit := func() (tea.Model, tea.Cmd) {
			f.quitting = true
			f.State = StateCompleted
			return f, f.SubmitCmd
		}

		if f.selector.OnLast() {
			return submit()
		}

		for i := f.selector.Index() + 1; i < f.selector.Total(); i++ {
			if !f.isGroupHidden(f.selector.Get(i)) {
				f.selector.SetIndex(i)
				break
			}
			// all subsequent groups are hidden, so we must act as


sed -n '565,600p' $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/form.go

Check if last field value is saved on form completion


	case nextFieldMsg:
		// Form is progressing to the next field, let's save the value of the current field.
		field := group.selector.Selected()
		f.results[field.GetKey()] = field.GetValue()

	case nextGroupMsg:
		if len(group.Errors()) > 0 {
			return f, nil
		}

		submit := func() (tea.Model, tea.Cmd) {
			f.quitting = true
			f.State = StateCompleted
			return f, f.SubmitCmd
		}

		if f.selector.OnLast() {
			return submit()
		}

		for i := f.selector.Index() + 1; i < f.selector.Total(); i++ {
			if !f.isGroupHidden(f.selector.Get(i)) {
				f.selector.SetIndex(i)
				break
			}
			// all subsequent groups are hidden, so we must act as
			// if we were in the last one.
			if i == f.selector.Total()-1 {
				return submit()
			}
		}
		f.selector.Selected().active = true
		return f, f.selector.Selected().Init()

	case prevGroupMsg:
		if len(group.Errors()) > 0 {


sed -n '390,460p' $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/field_multiselect.go

Check what message MultiSelect sends on Enter/submit


			m.cursor = min(m.cursor+1, len(m.filteredOptions)-1)
			if m.cursor >= m.viewport.YOffset+m.viewport.Height {
				m.viewport.ScrollDown(1)
			}
		case key.Matches(msg, m.keymap.GotoTop):
			if m.filtering {
				break
			}
			m.cursor = 0
			m.viewport.GotoTop()
		case key.Matches(msg, m.keymap.GotoBottom):
			if m.filtering {
				break
			}
			m.cursor = len(m.filteredOptions) - 1
			m.viewport.GotoBottom()
		case key.Matches(msg, m.keymap.HalfPageUp):
			m.cursor = max(m.cursor-m.viewport.Height/2, 0)
			m.viewport.HalfPageUp()
		case key.Matches(msg, m.keymap.HalfPageDown):
			m.cursor = min(m.cursor+m.viewport.Height/2, len(m.filteredOptions)-1)
			m.viewport.HalfPageDown()
		case key.Matches(msg, m.keymap.Toggle) && !m.filtering:
			for i, option := range m.options.val {
				if option.Key == m.filteredOptions[m.cursor].Key {
					if !m.options.val[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit {
						break
					}
					selected := m.options.val[i].selected
					m.options.val[i].selected = !selected
					m.filteredOptions[m.cursor].selected = !selected
				}
			}
			m.setSelectAllHelp()
			m.updateValue()
		case key.Matches(msg, m.keymap.SelectAll, m.keymap.SelectNone) && m.limit <= 0:
			selected := false

			for _, option := range m.filteredOptions {
				if !option.selected {
					selected = true
					break
				}
			}

			for i, option := range m.options.val {
				for j := range m.filteredOptions {
					if option.Key == m.filteredOptions[j].Key {
						m.options.val[i].selected = selected
						m.filteredOptions[j].selected = selected
						break
					}
				}
			}
			m.setSelectAllHelp()
			m.updateValue()
		case key.Matches(msg, m.keymap.Prev):
			m.updateValue()
			m.err = m.validate(m.accessor.Get())
			if m.err != nil {
				return m, nil
			}
			return m, PrevField
		case key.Matches(msg, m.keymap.Next, m.keymap.Submit):
			m.updateValue()
			m.err = m.validate(m.accessor.Get())
			if m.err != nil {
				return m, nil
			}
			return m, NextField


grep -n "nextFieldMsg\|NextField\|nextGroupMsg\|NextGroup" \
  $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/group.go | head -30

Check group nextFieldMsg handling


172:// nextFieldMsg is a message to move to the next field,
176:type nextFieldMsg struct{}
184:// NextField is the command to move to the next field.
185:func NextField() tea.Msg {
186:	return nextFieldMsg{}
277:	case nextFieldMsg:


sed -n '277,320p' $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/group.go

Check what happens when nextFieldMsg arrives at group level


	case nextFieldMsg:
		cmds = append(cmds, g.nextField()...)
	case prevFieldMsg:
		cmds = append(cmds, g.prevField()...)
	}

	g.buildView()

	return g, tea.Batch(cmds...)
}

func (g *Group) getTheme() *Theme {
	if theme := g.theme; theme != nil {
		return theme
	}
	return ThemeCharm()
}

func (g *Group) styles() GroupStyles { return g.getTheme().Group }

func (g *Group) getContent() (int, string) {
	var fields strings.Builder
	offset := 0

	gap := g.getTheme().FieldSeparator.Render()

	// if the focused field is requesting it be zoomed, only show that field.
	if g.selector.Selected().Zoom() {
		g.selector.Selected().WithHeight(g.height)
		fields.WriteString(g.selector.Selected().View())
	} else {
		g.selector.Range(func(i int, field Field) bool {
			fields.WriteString(field.View())
			if i == g.selector.Index() {
				offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View())
			}
			if i < g.selector.Total()-1 {
				fields.WriteString(gap)
			}
			return true
		})
	}

	return offset, fields.String()


grep -n "func.*nextField\b\|NextGroup\|nextGroupMsg" \
  $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/group.go | head -20

Find nextField and NextGroup in group.go


218:func (g *Group) nextField() []tea.Cmd {


sed -n '218,260p' $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/group.go

Read nextField group logic


func (g *Group) nextField() []tea.Cmd {
	blurCmd := g.selector.Selected().Blur()
	if g.selector.OnLast() {
		return []tea.Cmd{blurCmd, nextGroup}
	}
	g.selector.Next()
	for g.selector.Selected().Skip() {
		if g.selector.OnLast() {
			return []tea.Cmd{blurCmd, nextGroup}
		}
		g.selector.Next()
	}
	focusCmd := g.selector.Selected().Focus()
	return []tea.Cmd{blurCmd, focusCmd}
}

// prevField moves to the previous field.
func (g *Group) prevField() []tea.Cmd {
	blurCmd := g.selector.Selected().Blur()
	if g.selector.OnFirst() {
		return []tea.Cmd{blurCmd, prevGroup}
	}
	g.selector.Prev()
	for g.selector.Selected().Skip() {
		if g.selector.OnFirst() {
			return []tea.Cmd{blurCmd, prevGroup}
		}
		g.selector.Prev()
	}
	focusCmd := g.selector.Selected().Focus()
	return []tea.Cmd{blurCmd, focusCmd}
}

// Update updates the group.
func (g *Group) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmds []tea.Cmd

	// Update all the fields in the group.
	g.selector.Range(func(i int, field Field) bool {
		switch msg := msg.(type) {
		case tea.KeyMsg:
			break
		default:


hele - ono to rozhraní klidně může být grafický, nemusí to být takhle old-school


sed -n '520,575p' $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/form.go

Read full form Update to understand message flow


	return tea.Sequence(cmds...)
}

// Update updates the form.
func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	// If the form is aborted or completed there's no need to update it.
	if f.State != StateNormal {
		return f, nil
	}

	group := f.selector.Selected()

	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		if f.width == 0 {
			f.selector.Range(func(_ int, group *Group) bool {
				width := f.layout.GroupWidth(f, group, msg.Width)
				group.WithWidth(width)
				return true
			})
		}
		if f.height == 0 {
			// calculate the needed height, which is the height of the
			// heightest group, accounting for the width, wraps, etc.
			neededHeight := 0
			f.selector.Range(func(_ int, group *Group) bool {
				neededHeight = max(neededHeight, group.rawHeight())
				return true
			})

			f.selector.Range(func(_ int, group *Group) bool {
				group.WithHeight(min(neededHeight, msg.Height))
				return true
			})
		}

	case tea.KeyMsg:
		switch {
		case key.Matches(msg, f.keymap.Quit):
			f.aborted = true
			f.quitting = true
			f.State = StateAborted
			return f, f.CancelCmd
		}

	case nextFieldMsg:
		// Form is progressing to the next field, let's save the value of the current field.
		field := group.selector.Selected()
		f.results[field.GetKey()] = field.GetValue()

	case nextGroupMsg:
		if len(group.Errors()) > 0 {
			return f, nil
		}

		submit := func() (tea.Model, tea.Cmd) {


Prompt: hele - ono to rozhraní klidně může být grafický, nemusí to být takhle old-school


sed -n '600,640p' $(go env GOPATH)/pkg/mod/github.com/charmbracelet/huh@v1.0.0/form.go

Read form Update tail - does it pass msg to group


		if len(group.Errors()) > 0 {
			return f, nil
		}

		for i := f.selector.Index() - 1; i >= 0; i-- {
			if !f.isGroupHidden(f.selector.Get(i)) {
				f.selector.SetIndex(i)
				break
			}
		}

		f.selector.Selected().active = true
		return f, f.selector.Selected().Init()
	}

	m, cmd := group.Update(msg)
	f.selector.Set(f.selector.Index(), m.(*Group))

	// A user input a key, this could hide or show other groups,
	// let's update all of their positions.
	switch msg.(type) {
	case tea.KeyMsg:
		f.UpdateFieldPositions()
	}

	return f, cmd
}

func (f *Form) isGroupHidden(group *Group) bool {
	hide := group.hide
	if hide == nil {
		return false
	}
	return hide()
}

func (f *Form) getTheme() *Theme {
	if f.theme != nil {
		return f.theme
	}
	return ThemeCharm()


Jasná diagnóza. Je to klasická past bubbletea + huh:

`buildForm(&m)` uloží pointer `&m.selectedItemKeys` z *lokální proměnné* v `NewModel`. Go ji sice heap-alokuje, ale po `return m` se používá KOPIE modelu — a form pořád píše do originálu. Takže `m.selectedItemKeys` v TUI modelu je vždy prázdné → všechno SKIPPED.

Opravím to tak, že `selectedItemKeys` bude sdílený heap pointer (`*[]string`), předpopulovaný všemi klíči:

A zároveň přidám explicitní krok "Spustit" — teď na to uživatel nepřijde, jak zmiňuješ. GUI architekturu probereme potom.


44
45	// Model is the top-level bubbletea model.
46	type Model struct {
47		phase  phase
48		form   *huh.Form
49		runCfg runner.RunConfig
50		cfg    config.Config
51
52		// form-bound values (huh writes here via pointer)
53		pcName           string
54		pcDesc           string
55		productKey       string
56		profileType      string
57		selectedItemKeys []string
58
59		// runner state
60		r      *runner.Runner
61		cancel context.CancelFunc
62		msgCh  chan tea.Msg
63
64		logs    []runner.LogLine
65		results []runner.Result
66
67		// terminal dimensions
68		width  int
69		height int
70	}
71
72	// NewModel returns the initial model pre-populated from cfg.
73	func NewModel(cfg config.Config, runCfg runner.RunConfig) Model {
74		m := Model{
75			cfg:         cfg,
76			runCfg:      runCfg,
77			pcName:      cfg.Deployment.PCName,
78			pcDesc:      cfg.Deployment.PCDescription,
79			productKey:  cfg.Activation.ProductKey,
80			profileType: cfg.Deployment.ProfileType,
81			width:       80,
82			height:      24,
83		}
84		m.form = buildForm(&m)
85		return m
86	}
87
88	// --------------------------------------------------------------------------
89	// Form construction
90	// --------------------------------------------------------------------------
91
92	func buildForm(m *Model) *huh.Form {
93		allItems := runner.AllSelectableItems()
94		opts := make([]huh.Option[string], len(allItems))
95		for i, item := range allItems {
96			opts[i] = huh.NewOption(item.Label, item.Key).Selected(true)
97		}
98
99		return huh.NewForm(
100			huh.NewGroup(
101				huh.NewInput().
102					Title("Jmeno pocitace").
103					Placeholder("napr. NB-KLIENT-01  (prazdne = neprejmenovat)").
104					Value(&m.pcName),
105				huh.NewInput().
106					Title("Popis pocitace").
107					Placeholder("napr. PC recepce").
108					Value(&m.pcDesc),
109				huh.NewInput().
110					Title("Product Key").
111					Placeholder("prazdne = OA3 / GVLK fallback").
112					Value(&m.productKey),
113				huh.NewSelect[string]().
114					Title("Profil").
115					Options(
116						huh.NewOption("Default", "default"),
117						huh.NewOption("Admin (Explorer, PS, Edge)", "admin"),
118						huh.NewOption("User  (Explorer, Edge)", "user"),
119					).
120					Value(&m.profileType),
121			),
122			huh.NewGroup(
123				huh.NewMultiSelect[string]().
124					Title("Kroky a nastaveni (mezernikem odzaskrtnout)").
125					Options(opts...).
126					Value(&m.selectedItemKeys),
127			),
128		)
129	}
130
131	// --------------------------------------------------------------------------
132	// bubbletea interface
133	// --------------------------------------------------------------------------

// Model is the top-level bubbletea model.
type Model struct {
	phase  phase
	form   *huh.Form
	runCfg runner.RunConfig
	cfg    config.Config

	// form-bound values (huh writes here via pointer)
	pcName           string
	pcDesc           string
	productKey       string
	profileType      string
	selectedItemKeys []string

	// runner state
	r      *runner.Runner
	cancel context.CancelFunc
	msgCh  chan tea.Msg

	logs    []runner.LogLine
	results []runner.Result

	// terminal dimensions
	width  int
	height int
}

// NewModel returns the initial model pre-populated from cfg.
func NewModel(cfg config.Config, runCfg runner.RunConfig) Model {
	m := Model{
		cfg:         cfg,
		runCfg:      runCfg,
		pcName:      cfg.Deployment.PCName,
		pcDesc:      cfg.Deployment.PCDescription,
		productKey:  cfg.Activation.ProductKey,
		profileType: cfg.Deployment.ProfileType,
		width:       80,
		height:      24,
	}
	m.form = buildForm(&m)
	return m
}

// --------------------------------------------------------------------------
// Form construction
// --------------------------------------------------------------------------

func buildForm(m *Model) *huh.Form {
	allItems := runner.AllSelectableItems()
	opts := make([]huh.Option[string], len(allItems))
	for i, item := range allItems {
		opts[i] = huh.NewOption(item.Label, item.Key).Selected(true)
	}

	return huh.NewForm(
		huh.NewGroup(
			huh.NewInput().
				Title("Jmeno pocitace").
				Placeholder("napr. NB-KLIENT-01  (prazdne = neprejmenovat)").
				Value(&m.pcName),
			huh.NewInput().
				Title("Popis pocitace").
				Placeholder("napr. PC recepce").
				Value(&m.pcDesc),
			huh.NewInput().
				Title("Product Key").
				Placeholder("prazdne = OA3 / GVLK fallback").
				Value(&m.productKey),
			huh.NewSelect[string]().
				Title("Profil").
				Options(
					huh.NewOption("Default", "default"),
					huh.NewOption("Admin (Explorer, PS, Edge)", "admin"),
					huh.NewOption("User  (Explorer, Edge)", "user"),
				).
				Value(&m.profileType),
		),
		huh.NewGroup(
			huh.NewMultiSelect[string]().
				Title("Kroky a nastaveni (mezernikem odzaskrtnout)").
				Options(opts...).
				Value(&m.selectedItemKeys),
		),
	)
}

// --------------------------------------------------------------------------
// bubbletea interface
// --------------------------------------------------------------------------


[Request interrupted by user]


nechceš tomu udělat nějaký normálnější grafický/webový rozhraní?


Dá se to udělat hezky. Hlavní možnosti:

**1. Embedded web UI** (moje doporučení)
`xetup.exe` spustí lokální HTTP server, otevře prohlížeč. UI = HTML/CSS/JS — stejný styl jako landing page. Log se streamuje přes SSE. Po dokončení se server zavře.
- Výhody: čisté Go stdlib, žádné extra závislosti, vypadá moderně, snadno upravitelné
- Nevýhody: vyžaduje prohlížeč (Win10/11 ho vždy má)

**2. Wails**
Go + HTML/CSS/JS zkompilované do jednoho `.exe` s embedded WebView2 (Edge). Vypadá jako nativní Windows app, žádný viditelný prohlížeč.
- Výhody: nativní look, žádný viditelný browser
- Nevýhody: cross-compile z Linuxu (CI) je komplikovaný, WebView2 runtime dependency

**3. Fyne**
Nativní Go GUI framework. Tlačítka, checkboxy, progress bar — nativní Windows widgety.
- Výhody: opravdu nativní, bez browseru
- Nevýhody: vypadá trochu genericky, cross-compile je složitý

---

Já bych šel do **embedded web UI** — nejmenší přírůstek závislostí, nejsnazší CI build (pořád jen `GOOS=windows go build`), a UI může vypadat přesně jako chceš. Formulář → tlačítko Spustit → live log → summary.

Co říkáš?


víš co - mám dojem, že integrací různých nástrojů (forgejo, buibbletea atd.) jsme si přidělali víc komplikací, než kdybychom na z čista napsali :-(


Máš pravdu a je to fér zpětná vazba. Upřímně:

**Co zbytečně zkomplikovalo život:**
- `bubbletea + huh` — krásné knihovny, ale pointer semantika v bubbletea je zákeřná. Výsledek: záhadný bug, hodiny debugování.
- CI pipeline — 10 failujících runů než to fungovalo, jen kvůli Alpine vs bash, Go verzi, Docker network...
- Forgejo runner setup — docker socket permissions, registrace, restart aby se načetl config...

**Co naopak dává smysl:**
- Forgejo samotné — máš vlastní git, releases, issue tracker. To je OK.
- CI jako takové — auto-build na push je fajn. Teď když funguje, nestojí žádnou práci.
- Embedded scripty — Go exe s PS skripty uvnitř je správná architektura.

---

**Co bych navrhoval přepsat od nuly:**

Zahodit `bubbletea + huh`, napsat `embedded web UI` — čistý Go stdlib, žádné frameworky:

```
xetup.exe
  → spustí http://localhost:PORT
  → otevře prohlížeč
  → HTML formulář (PC jméno, kroky...)
  → klik "Spustit" → SSE stream logu
  → hotovo → summary
```

Build zůstane stejný (`GOOS=windows go build`), CI pipeline se nezmění, ale kód bude 10× jednodušší a bez zákeřných bugů.

Chceš to takhle přepsat?


s tím otevřením prohlížeče je to blbý .. bývá tam edge, který chce proklikat úvodní setup a to je děs, to nechci


Dobrý bod. Čerstvý Edge na nové mašině je opravdu utrpení.

Takže realita je:
- Browser → Edge setup wizard = ne
- bubbletea → zákeřné bugy = frustrující
- Wails/webview → cross-compile z Linuxu je bolest

Upřímně — zamysli se nad tím, co ten nástroj reálně dělá:

> Technik přijde, spustí `xetup.exe`, nakliká pár věcí, dá start, počká.

Možnosti od nejjednodušší:

**A) Opravit bubbletea TUI** — bug je 5minutová oprava. Přidat Spustit tlačítko. Hotovo za hodinu. Funguje bez browseru, bez Edge, bez ničeho.

**B) Fyne** — nativní Windows GUI v Go, cross-compile funguje. Checkboxy, tlačítko, textové pole pro log. Vypadá jako normální Windows appka. Ale ~3-4h práce.

**C) Zjednodušit radikálně** — žádné interaktivní UI. `xetup.exe` načte `config.json`, vypíše co udělá, zeptá se "Spustit? [Y/n]", jede. UI = config.json editovaný předem. Nejjednodušší kód.

Já bych šel do **A** — bug je triviální k opravě, TUI pak funguje. Pokud tě terminál fakt štve, pak **B** (Fyne). **C** jen pokud chceš absolutní jednoduchost.

Co preferuješ?


B. Do toho. A zjednodušujme, jak to jde - musí to být ne tak, že te'd tady něco opravíme pro tuhle chvíli, ale že vytvoříme rock-solid řešení na roky dopředu - které samozřejmě pomocí komentářů, požadavků na nové věci atd bude mít svůj život a změny


Dobrý přístup. Takhle to uděláme:

**Co zahodíme:**
- `internal/tui/` — celý adresář (bubbletea + huh = zdroj bugů)
- Dependencies: bubbletea, huh, lipgloss, bubbles

**Co přidáme:**
- `fyne.io/fyne/v2` — jeden framework, nativní Windows okno, dark theme, cross-compile z Linuxu přes MinGW

**Co zůstane beze změny:**
- `internal/runner/` — solidní, beze změny
- `internal/config/` — beze změny
- `scripts/`, `assets/`, `embed.go` — beze změny
- CI pipeline — přidáme jen `mingw-w64-gcc` a `CGO_ENABLED=1`

**Výsledek:** 3 fáze v jednom okně — formulář → live log → summary. Tlačítko SPUSTIT, save config po spuštění.

Jdeme na to:


ls /opt/xetup/internal/ && cat /opt/xetup/go.mod

Check current structure and dependencies


config
runner
tui
module git.xetup.x9.cz/x9/xetup

go 1.24.0

require (
	github.com/atotto/clipboard v0.1.4 // indirect
	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
	github.com/catppuccin/go v0.3.0 // indirect
	github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
	github.com/charmbracelet/bubbletea v1.3.10 // indirect
	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
	github.com/charmbracelet/huh v1.0.0 // indirect
	github.com/charmbracelet/lipgloss v1.1.0 // indirect
	github.com/charmbracelet/x/ansi v0.10.1 // indirect
	github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
	github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
	github.com/charmbracelet/x/term v0.2.1 // indirect
	github.com/dustin/go-humanize v1.0.1 // indirect
	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mattn/go-localereader v0.0.1 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
	github.com/muesli/cancelreader v0.2.2 // indirect
	github.com/muesli/termenv v0.16.0 // indirect
	github.com/rivo/uniseg v0.4.7 // indirect
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
	golang.org/x/sys v0.36.0 // indirect
	golang.org/x/text v0.23.0 // indirect
)


Check current main.go


// Command xetup is the interactive TUI launcher for Windows deployment.
// It embeds all PowerShell scripts and assets into a single self-contained
// binary, extracts them to a temp directory at runtime, collects configuration
// via an interactive form, and streams live log output while the scripts run.
//
// Cross-compile for Windows:
//
//	GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o xetup.exe ./cmd/xetup
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	tea "github.com/charmbracelet/bubbletea"

	xetupembed "git.xetup.x9.cz/x9/xetup"
	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
	"git.xetup.x9.cz/x9/xetup/internal/tui"
)

func main() {
	// Load config (silently falls back to defaults when missing)
	cfgPath := config.ConfigPath()
	cfg, err := config.Load(cfgPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
	}

	// Create a temp working directory; cleaned up on exit
	tmpDir, err := os.MkdirTemp("", "xetup-*")
	if err != nil {
		log.Fatalf("Cannot create temp dir: %v", err)
	}
	defer os.RemoveAll(tmpDir)

	// Extract embedded scripts
	if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
		log.Fatalf("Failed to extract scripts: %v", err)
	}

	// Extract embedded assets
	if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
		log.Fatalf("Failed to extract assets: %v", err)
	}

	// Write runtime config JSON so PowerShell scripts can read it
	cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
	if err != nil {
		log.Fatalf("Failed to write runtime config: %v", err)
	}

	runCfg := runner.RunConfig{
		ScriptsDir:  filepath.Join(tmpDir, "scripts"),
		ConfigPath:  cfgRuntimePath,
		LogFile:     `C:\Windows\Setup\Scripts\Deploy.log`,
		ProfileType: cfg.Deployment.ProfileType,
	}

	// Launch the TUI
	m := tui.NewModel(cfg, runCfg)
	p := tea.NewProgram(m, tea.WithAltScreen())
	if _, err := p.Run(); err != nil {
		log.Fatalf("TUI error: %v", err)
	}
}
// build 1776332628


Check config structure


package config

import (
	"encoding/json"
	"os"
	"path/filepath"
)

// Config mirrors config.json structure.
type Config struct {
	Deployment    Deployment             `json:"deployment"`
	AdminAccount  AdminAccount           `json:"adminAccount"`
	Activation    Activation             `json:"activation"`
	Software      Software               `json:"software"`
	Steps         map[string]bool        `json:"steps"`
	Features      Features               `json:"features"`
}

type Deployment struct {
	PCName        string `json:"pcName"`
	PCDescription string `json:"pcDescription"`
	Timezone      string `json:"timezone"`
	ProfileType   string `json:"profileType"` // default | admin | user
}

type AdminAccount struct {
	Username string `json:"username"`
}

type Activation struct {
	ProductKey string `json:"productKey"`
	KMSServer  string `json:"kmsServer"`
}

type SoftwareItem struct {
	Name     string `json:"name"`
	WingetID string `json:"wingetId"`
}

type Software struct {
	Install []SoftwareItem `json:"install"`
}

// Features holds per-step, per-feature toggle flags.
// Keys: stepID -> featureID -> enabled.
// A missing key defaults to true (feature enabled).
type Features map[string]map[string]bool

// DefaultConfig returns a config with sensible defaults.
func DefaultConfig() Config {
	return Config{
		Deployment: Deployment{
			Timezone:    "Central Europe Standard Time",
			ProfileType: "default",
		},
		AdminAccount: AdminAccount{
			Username: "adminx9",
		},
		Activation: Activation{
			ProductKey: "",
		},
		Software: Software{
			Install: []SoftwareItem{
				{Name: "7-Zip", WingetID: "7zip.7zip"},
				{Name: "Adobe Acrobat Reader 64-bit", WingetID: "Adobe.Acrobat.Reader.64-bit"},
				{Name: "OpenVPN Connect", WingetID: "OpenVPNTechnologies.OpenVPNConnect"},
			},
		},
		Steps: map[string]bool{
			"adminAccount":    true,
			"bloatware":       true,
			"software":        true,
			"systemRegistry":  true,
			"defaultProfile":  true,
			"personalization": true,
			"scheduledTasks":  true,
			"backinfo":        true,
			"activation":      true,
			"dellUpdate":      true,
			"network":         true,
			"pcIdentity":      true,
		},
		Features: Features{
			"software": {
				"wingetInstalls": true,
				"pdfDefault":     true,
				"ateraAgent":     true,
			},
			"systemRegistry": {
				"systemTweaks":      true,
				"edgePolicies":      true,
				"oneDriveUninstall": true,
				"powercfg":          true,
				"proxyDisable":      true,
			},
			"defaultProfile": {
				"taskbarTweaks":   true,
				"startMenuTweaks": true,
				"explorerTweaks":  true,
			},
			"dellUpdate": {
				"drivers": true,
				"bios":    true,
			},
		},
	}
}

// Load reads config.json from the given path.
// If the file does not exist, returns DefaultConfig without error.
func Load(path string) (Config, error) {
	cfg := DefaultConfig()
	data, err := os.ReadFile(path)
	if os.IsNotExist(err) {
		return cfg, nil
	}
	if err != nil {
		return cfg, err
	}
	if err := json.Unmarshal(data, &cfg); err != nil {
		return cfg, err
	}
	return cfg, nil
}

// Save writes config to the given path (creates directories if needed).
func Save(cfg Config, path string) error {
	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0644)
}

// ConfigPath returns the default config.json path (next to the executable).
func ConfigPath() string {
	exe, err := os.Executable()
	if err != nil {
		return "config.json"
	}
	return filepath.Join(filepath.Dir(exe), "config.json")
}


Jasně. Tady je přesný plán co zahodíme a co vytvoříme — a rovnou do toho jdeme:

**Zahazujeme:** `internal/tui/`, bubbletea, huh, lipgloss (13 závislostí)
**Přidáváme:** `fyne.io/fyne/v2`, MinGW do CI
**Kód:** `internal/gui/gui.go` — 3 fáze, žádný pointer binding, žádné triky


go get fyne.io/fyne/v2@latest 2>&1 | tail -5

Add Fyne dependency


go: downloading fyne.io/fyne/v2 v2.7.3
go: downloading fyne.io/fyne v1.4.3
go: added fyne.io/fyne/v2 v2.7.3


Create gui directory


(Bash completed with no output)


Write

// Package gui implements the Fyne-based graphical interface for xetup.
//
// Three phases, one window:
//  1. Config form  – PC name, product key, profile, step selection
//  2. Live run     – real-time log streamed from PowerShell scripts
//  3. Summary      – per-step OK / ERROR / SKIPPED with elapsed time
package gui

import (
	"context"
	"fmt"
	"sync"
	"time"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/container"
	"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 used to persist the config after the form is submitted.
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(720, 640))
	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 pre-filled from loaded config
	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)

	// One checkbox per selectable item (flat list, same order as runner.AllSelectableItems)
	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, 310))

	startBtn := widget.NewButton("  SPUSTIT  ", func() {
		// Read form values
		cfg.Deployment.PCName = pcName.Text
		cfg.Deployment.PCDescription = pcDesc.Text
		cfg.Activation.ProductKey = productKey.Text
		cfg.Deployment.ProfileType = profileSel.Selected
		runCfg.ProfileType = profileSel.Selected

		// Translate checkbox state → steps + features
		selected := make(map[string]bool, len(items))
		for i, item := range items {
			selected[item.Key] = checks[i].Checked
		}
		steps, features := buildStepsAndFeatures(selected)
		cfg.Features = features

		_ = config.Save(cfg, cfgPath) // best-effort; errors are non-fatal

		showRun(w, runCfg, steps)
	})
	startBtn.Importance = widget.HighImportance

	form := widget.NewForm(
		widget.NewFormItem("PC jmeno", pcName),
		widget.NewFormItem("Popis PC", pcDesc),
		widget.NewFormItem("Product Key", productKey),
		widget.NewFormItem("Profil", profileSel),
	)

	w.SetContent(container.NewBorder(
		form,
		container.NewCenter(container.NewPadded(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...")

	// Log is a virtualised list – efficient even for thousands of 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 and defaulting to true (enabled).
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 == "" {
			// Step without features: enabled iff its 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
}


File created successfully at: /opt/xetup/internal/gui/gui.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
//  2. Live run     – real-time log streamed from PowerShell scripts
//  3. Summary      – per-step OK / ERROR / SKIPPED with elapsed time
package gui

import (
	"context"
	"fmt"
	"sync"
	"time"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/container"
	"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 used to persist the config after the form is submitted.
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(720, 640))
	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 pre-filled from loaded config
	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)

	// One checkbox per selectable item (flat list, same order as runner.AllSelectableItems)
	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, 310))

	startBtn := widget.NewButton("  SPUSTIT  ", func() {
		// Read form values
		cfg.Deployment.PCName = pcName.Text
		cfg.Deployment.PCDescription = pcDesc.Text
		cfg.Activation.ProductKey = productKey.Text
		cfg.Deployment.ProfileType = profileSel.Selected
		runCfg.ProfileType = profileSel.Selected

		// Translate checkbox state → steps + features
		selected := make(map[string]bool, len(items))
		for i, item := range items {
			selected[item.Key] = checks[i].Checked
		}
		steps, features := buildStepsAndFeatures(selected)
		cfg.Features = features

		_ = config.Save(cfg, cfgPath) // best-effort; errors are non-fatal

		showRun(w, runCfg, steps)
	})
	startBtn.Importance = widget.HighImportance

	form := widget.NewForm(
		widget.NewFormItem("PC jmeno", pcName),
		widget.NewFormItem("Popis PC", pcDesc),
		widget.NewFormItem("Product Key", productKey),
		widget.NewFormItem("Profil", profileSel),
	)

	w.SetContent(container.NewBorder(
		form,
		container.NewCenter(container.NewPadded(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...")

	// Log is a virtualised list – efficient even for thousands of 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 and defaulting to true (enabled).
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 == "" {
			// Step without features: enabled iff its 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
}


Teď nový `main.go` — čistý, bez bubbletea:


// Command xetup is the GUI launcher for Windows deployment.
//
// All PowerShell scripts and assets are embedded in the binary and extracted
// to a temp directory at runtime. The GUI collects configuration, streams
// live log output while the scripts run, and shows a final summary.
//
// Cross-compile for Windows (requires MinGW):
//
//	CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
//	  GOOS=windows GOARCH=amd64 \
//	  go build -ldflags="-s -w -H windowsgui" -o xetup.exe ./cmd/xetup
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	xetupembed "git.xetup.x9.cz/x9/xetup"
	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/gui"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
)

func main() {
	// Load config (falls back to defaults when config.json is missing)
	cfgPath := config.ConfigPath()
	cfg, err := config.Load(cfgPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
	}

	// Temp working directory – cleaned up on exit
	tmpDir, err := os.MkdirTemp("", "xetup-*")
	if err != nil {
		log.Fatalf("Cannot create temp dir: %v", err)
	}
	defer os.RemoveAll(tmpDir)

	// Extract embedded scripts and assets
	if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
		log.Fatalf("Failed to extract scripts: %v", err)
	}
	if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
		log.Fatalf("Failed to extract assets: %v", err)
	}

	// Write runtime config JSON so PowerShell scripts can read it
	cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
	if err != nil {
		log.Fatalf("Failed to write runtime config: %v", err)
	}

	runCfg := runner.RunConfig{
		ScriptsDir:  filepath.Join(tmpDir, "scripts"),
		ConfigPath:  cfgRuntimePath,
		LogFile:     `C:\Windows\Setup\Scripts\Deploy.log`,
		ProfileType: cfg.Deployment.ProfileType,
	}

	gui.Run(cfg, runCfg, cfgPath)
}


The file /opt/xetup/cmd/xetup/main.go has been updated successfully.

// Command xetup is the GUI launcher for Windows deployment.
//
// All PowerShell scripts and assets are embedded in the binary and extracted
// to a temp directory at runtime. The GUI collects configuration, streams
// live log output while the scripts run, and shows a final summary.
//
// Cross-compile for Windows (requires MinGW):
//
//	CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
//	  GOOS=windows GOARCH=amd64 \
//	  go build -ldflags="-s -w -H windowsgui" -o xetup.exe ./cmd/xetup
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	xetupembed "git.xetup.x9.cz/x9/xetup"
	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/gui"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
)

func main() {
	// Load config (falls back to defaults when config.json is missing)
	cfgPath := config.ConfigPath()
	cfg, err := config.Load(cfgPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
	}

	// Temp working directory – cleaned up on exit
	tmpDir, err := os.MkdirTemp("", "xetup-*")
	if err != nil {
		log.Fatalf("Cannot create temp dir: %v", err)
	}
	defer os.RemoveAll(tmpDir)

	// Extract embedded scripts and assets
	if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
		log.Fatalf("Failed to extract scripts: %v", err)
	}
	if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
		log.Fatalf("Failed to extract assets: %v", err)
	}

	// Write runtime config JSON so PowerShell scripts can read it
	cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
	if err != nil {
		log.Fatalf("Failed to write runtime config: %v", err)
	}

	runCfg := runner.RunConfig{
		ScriptsDir:  filepath.Join(tmpDir, "scripts"),
		ConfigPath:  cfgRuntimePath,
		LogFile:     `C:\Windows\Setup\Scripts\Deploy.log`,
		ProfileType: cfg.Deployment.ProfileType,
	}

	gui.Run(cfg, runCfg, cfgPath)
}

-// Command xetup is the interactive TUI launcher for Windows deployment.

-// It embeds all PowerShell scripts and assets into a single self-contained

-// binary, extracts them to a temp directory at runtime, collects configuration

-// via an interactive form, and streams live log output while the scripts run.

+// Command xetup is the GUI launcher for Windows deployment.

+// All PowerShell scripts and assets are embedded in the binary and extracted

+// to a temp directory at runtime. The GUI collects configuration, streams

+// live log output while the scripts run, and shows a final summary.

-//	GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o xetup.exe ./cmd/xetup

+// Cross-compile for Windows (requires MinGW):

+//	CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \

+//	  go build -ldflags="-s -w -H windowsgui" -o xetup.exe ./cmd/xetup

-  tea "github.com/charmbracelet/bubbletea"

-  // Load config (silently falls back to defaults when missing)

+  // Load config (falls back to defaults when config.json is missing)

-  // Create a temp working directory; cleaned up on exit

+  // Temp working directory – cleaned up on exit

+  // Extract embedded scripts and assets

-  p := tea.NewProgram(m, tea.WithAltScreen())

// Command xetup is the interactive TUI launcher for Windows deployment.
// It embeds all PowerShell scripts and assets into a single self-contained
// binary, extracts them to a temp directory at runtime, collects configuration
// via an interactive form, and streams live log output while the scripts run.
//
// Cross-compile for Windows:
//
//	GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o xetup.exe ./cmd/xetup
package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	tea "github.com/charmbracelet/bubbletea"

	xetupembed "git.xetup.x9.cz/x9/xetup"
	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
	"git.xetup.x9.cz/x9/xetup/internal/tui"
)

func main() {
	// Load config (silently falls back to defaults when missing)
	cfgPath := config.ConfigPath()
	cfg, err := config.Load(cfgPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Warning: config load failed (%v), using defaults\n", err)
	}

	// Create a temp working directory; cleaned up on exit
	tmpDir, err := os.MkdirTemp("", "xetup-*")
	if err != nil {
		log.Fatalf("Cannot create temp dir: %v", err)
	}
	defer os.RemoveAll(tmpDir)

	// Extract embedded scripts
	if err := runner.ExtractScripts(xetupembed.Scripts, tmpDir); err != nil {
		log.Fatalf("Failed to extract scripts: %v", err)
	}

	// Extract embedded assets
	if err := runner.ExtractAssets(xetupembed.Assets, tmpDir); err != nil {
		log.Fatalf("Failed to extract assets: %v", err)
	}

	// Write runtime config JSON so PowerShell scripts can read it
	cfgRuntimePath, err := runner.WriteConfig(cfg, tmpDir)
	if err != nil {
		log.Fatalf("Failed to write runtime config: %v", err)
	}

	runCfg := runner.RunConfig{
		ScriptsDir:  filepath.Join(tmpDir, "scripts"),
		ConfigPath:  cfgRuntimePath,
		LogFile:     `C:\Windows\Setup\Scripts\Deploy.log`,
		ProfileType: cfg.Deployment.ProfileType,
	}

	// Launch the TUI
	m := tui.NewModel(cfg, runCfg)
	p := tea.NewProgram(m, tea.WithAltScreen())
	if _, err := p.Run(); err != nil {
		log.Fatalf("TUI error: %v", err)
	}
}
// build 1776332628


Bash

rm /opt/xetup/internal/tui/tui.go && rmdir /opt/xetup/internal/tui

Remove tui package


save config musí bý i ručně, nejen spuštěním. Aby si člověk mohl vytvářet různé configy


Remove old deps, add new ones


go: downloading github.com/stretchr/testify v1.11.1
go: downloading github.com/go-text/typesetting v0.3.3
go: downloading github.com/yuin/goldmark v1.7.8
go: downloading golang.org/x/image v0.24.0
go: downloading github.com/fsnotify/fsnotify v1.9.0
go: downloading github.com/godbus/dbus/v5 v5.1.0
go: downloading github.com/rymdport/portal v0.4.2
go: downloading github.com/fyne-io/gl-js v0.2.0
go: downloading github.com/go-text/render v0.2.0
go: downloading github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
go: downloading github.com/fyne-io/oksvg v0.2.0
go: downloading github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade
go: downloading github.com/nicksnyder/go-i18n/v2 v2.5.1
go: downloading github.com/davecgh/go-spew v1.1.1
go: downloading github.com/pmezard/go-difflib v1.0.0
go: downloading fyne.io/systray v1.12.0
go: downloading github.com/fyne-io/glfw-js v0.3.0
go: downloading github.com/fyne-io/image v0.1.1
go: downloading github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a
go: downloading github.com/BurntSushi/toml v1.5.0
go: downloading github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
go: downloading github.com/hack-pad/go-indexeddb v0.3.2
go: downloading github.com/fredbi/uri v1.1.1
go: downloading github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71
go: downloading golang.org/x/net v0.35.0
go: downloading github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
go: downloading gopkg.in/yaml.v3 v3.0.1
go: downloading github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25
go: downloading github.com/hack-pad/safejs v0.1.0
go: downloading gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f
go: downloading github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e
go: downloading github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8
go: downloading github.com/pkg/profile v1.7.0
go: downloading github.com/felixge/fgprof v0.9.3
go: downloading github.com/google/pprof v0.0.0-20211214055906-6f57359322fd
go: finding module for package github.com/kr/text
go: downloading github.com/kr/text v0.2.0
go: found github.com/kr/text in github.com/kr/text v0.2.0


Prompt: save config musí bý i ručně, nejen spuštěním. Aby si člověk mohl vytvářet různé configy


Check compilation errors


Error: Exit code 1
# github.com/go-gl/gl/v2.1/gl
# [pkg-config --cflags  -- gl gl]
Package gl was not found in the pkg-config search path.
Perhaps you should add the directory containing `gl.pc'
to the PKG_CONFIG_PATH environment variable
Package 'gl', required by 'virtual:world', not found
Package 'gl', required by 'virtual:world', not found
# github.com/go-gl/glfw/v3.3/glfw
In file included from ./glfw/src/internal.h:188,
                 from ./glfw/src/x11_window.c:32,
                 from /home/dev/go/pkg/mod/github.com/go-gl/glfw/v3.3/glfw@v0.0.0-20240506104042-037f3cc74f2a/c_glfw_lin.go:19:
./glfw/src/x11_platform.h:33:10: fatal error: X11/Xlib.h: No such file or directory
   33 | #include <X11/Xlib.h>
      |          ^~~~~~~~~~~~
compilation terminated.


Linux nemá X11 headers — to je OK, kompilujeme pro Windows. Ověřím Go kód bez CGo:


CGO_ENABLED=0 GOOS=windows go build ./... 2>&1

Type-check Go code for Windows target without CGo


Error: Exit code 1
package git.xetup.x9.cz/x9/xetup/cmd/xetup
	imports git.xetup.x9.cz/x9/xetup/internal/gui
	imports fyne.io/fyne/v2/app
	imports fyne.io/fyne/v2/internal/driver/glfw
	imports fyne.io/fyne/v2/internal/driver/common
	imports fyne.io/fyne/v2/internal/painter/gl
	imports github.com/go-gl/gl/v2.1/gl: build constraints exclude all Go files in /home/dev/go/pkg/mod/github.com/go-gl/gl@v0.0.0-20231021071112-07e5d0ea2e71/v2.1/gl


# Check just our packages (not Fyne internals) for Go errors
CGO_ENABLED=0 GOOS=windows go vet git.xetup.x9.cz/x9/xetup/internal/runner \
  git.xetup.x9.cz/x9/xetup/internal/config 2>&1
# Also check gui.go syntax by parsing only
go tool vet -printfuncs="" /opt/xetup/internal/gui/gui.go 2>&1 || true

Check our own code for errors


invalid value "" for flag -printfuncs: empty string
vet is a tool for static analysis of Go programs.

Usage of vet:
	vet unit.cfg	# execute analysis specified by config file
	vet help    	# general help, including listing analyzers and flags
	vet help name	# help on specific analyzer and its flags


CGO_ENABLED=0 GOOS=windows go vet \
  git.xetup.x9.cz/x9/xetup/internal/runner \
  git.xetup.x9.cz/x9/xetup/internal/config \
  git.xetup.x9.cz/x9/xetup 2>&1

Vet our packages (runner, config, embed root)


# Check gui.go has correct imports and syntax - parse as Go source
go doc git.xetup.x9.cz/x9/xetup/internal/gui 2>&1 || true
# Alternative: just check there are no obvious issues
grep -n "func \|import\|package" /opt/xetup/internal/gui/gui.go

Check gui package structure


package gui // import "git.xetup.x9.cz/x9/xetup/internal/gui"

Package gui implements the Fyne-based graphical interface for xetup.

Three phases, one window:
 1. Config form – PC name, product key, profile, step selection
 2. Live run – real-time log streamed from PowerShell scripts
 3. Summary – per-step OK / ERROR / SKIPPED with elapsed time

func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string)
7:package gui
9:import (
27:func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
43:func showForm(w fyne.Window, cfg config.Config, runCfg runner.RunConfig, cfgPath string) {
119:func showRun(w fyne.Window, runCfg runner.RunConfig, steps []runner.Step) {
191:func showDone(w fyne.Window, results []runner.Result) {
243:func itemEnabled(cfg config.Config, item runner.SelectableItem) bool {
260:func buildStepsAndFeatures(selected map[string]bool) ([]runner.Step, config.Features) {


Go doc funguje — kód je syntakticky správný. Teď přidám **Save/Load config** tlačítka (jak žádáš) a aktualizuji CI workflow:


Write

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

// Ensure strings import is used (used in future log formatting if needed).
var _ = strings.Builder{}


The file /opt/xetup/internal/gui/gui.go has been updated successfully.

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

// Ensure strings import is used (used in future log formatting if needed).
var _ = strings.Builder{}

-//  1. Config form  – PC name, product key, profile, step selection

+//  1. Config form  – PC name, product key, profile, step selection,

+//                    load/save config buttons for per-client presets

-// cfgPath is used to persist the config after the form is submitted.

+// cfgPath is the default config.json path (next to the exe).

-  // Text inputs pre-filled from loaded config

+  // ── Text inputs ─────────────────────────────────────────────────────────

-  // One checkbox per selectable item (flat list, same order as runner.AllSelectableItems)

+  // ── Step checkboxes ─────────────────────────────────────────────────────

checks := make([]*widget.Check, len(items))

-  stepsScroll.SetMinSize(fyne.NewSize(0, 310))

+  stepsScroll.SetMinSize(fyne.NewSize(0, 290))

+  // ── collectCfg reads current form state into a 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))

+      selected[item.Key] = checks[i].Checked

+    _, features := buildStepsAndFeatures(selected)

+  // ── 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 := json.Unmarshal(data, &newCfg); err != nil {

+      // Reload the entire form with the new config

+      showForm(w, newCfg, runCfg, rc.URI().Path())

+  saveBtn := widget.NewButton("Ulozit config...", func() {

+    d := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) {

+      data, err := json.MarshalIndent(collectCfg(), "", "  ")

+      if _, err := wc.Write(data); err != nil {

+  // ── SPUSTIT ─────────────────────────────────────────────────────────────

-    cfg.Deployment.PCName = pcName.Text

-    cfg.Deployment.PCDescription = pcDesc.Text

-    cfg.Activation.ProductKey = productKey.Text

-    cfg.Deployment.ProfileType = profileSel.Selected

-    runCfg.ProfileType = profileSel.Selected

+    runCfg.ProfileType = finalCfg.Deployment.ProfileType

-    // Translate checkbox state → steps + features

selected := make(map[string]bool, len(items))

-    _ = config.Save(cfg, cfgPath) // best-effort; errors are non-fatal

+    _ = config.Save(finalCfg, cfgPath) // auto-save to default path

+  // ── Layout ───────────────────────────────────────────────────────────────

+  toolbar := container.NewHBox(loadBtn, saveBtn)

-    container.NewCenter(container.NewPadded(startBtn)),

+      container.NewBorder(nil, nil, toolbar, container.NewCenter(startBtn)),

-  // Log is a virtualised list – efficient even for thousands of lines

+  // Virtualised list – efficient for thousands of log lines

-// reading from the loaded config and defaulting to true (enabled).

+// reading from the loaded config (defaults to true / enabled when absent).

if v, ok := cfg.Steps[item.StepID]; ok {

-      // Step without features: enabled iff its checkbox is checked

+      // Simple step: enabled iff its own checkbox is checked

+// Ensure strings import is used (used in future log formatting if needed).

// Package gui implements the Fyne-based graphical interface for xetup.
//
// Three phases, one window:
//  1. Config form  – PC name, product key, profile, step selection
//  2. Live run     – real-time log streamed from PowerShell scripts
//  3. Summary      – per-step OK / ERROR / SKIPPED with elapsed time
package gui

import (
	"context"
	"fmt"
	"sync"
	"time"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/container"
	"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 used to persist the config after the form is submitted.
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(720, 640))
	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 pre-filled from loaded config
	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)

	// One checkbox per selectable item (flat list, same order as runner.AllSelectableItems)
	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, 310))

	startBtn := widget.NewButton("  SPUSTIT  ", func() {
		// Read form values
		cfg.Deployment.PCName = pcName.Text
		cfg.Deployment.PCDescription = pcDesc.Text
		cfg.Activation.ProductKey = productKey.Text
		cfg.Deployment.ProfileType = profileSel.Selected
		runCfg.ProfileType = profileSel.Selected

		// Translate checkbox state → steps + features
		selected := make(map[string]bool, len(items))
		for i, item := range items {
			selected[item.Key] = checks[i].Checked
		}
		steps, features := buildStepsAndFeatures(selected)
		cfg.Features = features

		_ = config.Save(cfg, cfgPath) // best-effort; errors are non-fatal

		showRun(w, runCfg, steps)
	})
	startBtn.Importance = widget.HighImportance

	form := widget.NewForm(
		widget.NewFormItem("PC jmeno", pcName),
		widget.NewFormItem("Popis PC", pcDesc),
		widget.NewFormItem("Product Key", productKey),
		widget.NewFormItem("Profil", profileSel),
	)

	w.SetContent(container.NewBorder(
		form,
		container.NewCenter(container.NewPadded(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...")

	// Log is a virtualised list – efficient even for thousands of 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 and defaulting to true (enabled).
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 == "" {
			// Step without features: enabled iff its 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
}


Bash

go doc git.xetup.x9.cz/x9/xetup/internal/gui 2>&1

Verify gui package compiles for docs


package gui // import "git.xetup.x9.cz/x9/xetup/internal/gui"

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

func Run(cfg config.Config, runCfg runner.RunConfig, cfgPath string)


Odstraním zbytečný `strings` import:


"encoding/json"
"fmt"
"io"
-  "strings"
"sync"
"time"


Edit

// Ensure strings import is used (used in future log formatting if needed).
var _ = strings.Builder{}


// Ensure strings import is used (used in future log formatting if needed).
var _ = strings.Builder{}

-// Ensure strings import is used (used in future log formatting if needed).


Teď aktualizuji CI workflow pro MinGW cross-compile: