1package tui
2
3import (
4 "context"
5 "os"
6
7 apphandlers "github.com/bnema/archup/internal/application/handlers"
8 "github.com/bnema/archup/internal/application/services"
9 "github.com/bnema/archup/internal/domain/ports"
10 "github.com/bnema/archup/internal/domain/system"
11 "github.com/bnema/archup/internal/interfaces/tui/handlers"
12 "github.com/bnema/archup/internal/interfaces/tui/models"
13 "github.com/bnema/archup/internal/interfaces/tui/views"
14 legacysystem "github.com/bnema/archup/internal/system"
15 tea "github.com/charmbracelet/bubbletea"
16)
17
18// App is the main TUI application coordinator
19// It manages the installation workflow by coordinating models, views, and handlers
20// with the application layer services
21type App struct {
22 // Core services
23 installService *services.InstallationService
24 progressTracker *services.ProgressTracker
25 gpuHandler *apphandlers.GPUHandler
26
27 // Infrastructure ports
28 logger ports.Logger
29
30 // UI models (using concrete types for compatibility with views/handlers)
31 formModel *models.FormModelImpl
32 diskModel *models.DiskModelImpl
33 encryptionModel *models.EncryptionModelImpl
34 kernelModel *models.KernelModelImpl
35 amdPstateModel *models.AMDPStateModelImpl
36 gpuModel *models.GPUModelImpl
37 reposModel *models.ReposModelImpl
38 dankLinuxModel *models.DankLinuxModelImpl
39 installationModel *models.InstallationModelImpl
40 progressModel *models.ProgressModelImpl
41
42 // Application state
43 currentScreen Screen
44 version string
45 ctx context.Context
46 cancel context.CancelFunc
47
48 // Validated form data - stored after form validation passes
49 formData models.FormData
50
51 // Program reference for sending messages from goroutines
52 program *tea.Program
53}
54
55// Screen represents the current TUI screen
56type Screen string
57
58const (
59 ScreenForm Screen = "form"
60 ScreenDisk Screen = "disk"
61 ScreenEncryption Screen = "encryption"
62 ScreenKernel Screen = "kernel"
63 ScreenAMDPState Screen = "amd-pstate"
64 ScreenGPU Screen = "gpu"
65 ScreenRepos Screen = "repos"
66 ScreenDankLinux Screen = "danklinux"
67 ScreenInstalling Screen = "installing"
68 ScreenProgress Screen = "progress"
69 ScreenSummary Screen = "summary"
70 ScreenError Screen = "error"
71)
72
73// NewApp creates a new TUI application
74func NewApp(
75 installService *services.InstallationService,
76 progressTracker *services.ProgressTracker,
77 gpuHandler *apphandlers.GPUHandler,
78 logger ports.Logger,
79 version string,
80) *App {
81 ctx, cancel := context.WithCancel(context.Background())
82
83 return &App{
84 installService: installService,
85 progressTracker: progressTracker,
86 gpuHandler: gpuHandler,
87 logger: logger,
88 version: version,
89 formModel: models.NewFormModel(),
90 diskModel: models.NewDiskModel(),
91 encryptionModel: models.NewEncryptionModel(),
92 kernelModel: models.NewKernelModel(),
93 amdPstateModel: models.NewAMDPStateModel(),
94 gpuModel: models.NewGPUModel(),
95 reposModel: models.NewReposModel(),
96 dankLinuxModel: models.NewDankLinuxModel(),
97 installationModel: models.NewInstallationModel(),
98 progressModel: models.NewProgressModel(),
99 currentScreen: ScreenForm,
100 ctx: ctx,
101 cancel: cancel,
102 }
103}
104
105// Getter methods for handlers to access app resources
106
107// GetLogger returns the logger
108func (a *App) GetLogger() ports.Logger {
109 return a.logger
110}
111
112// GetInstallService returns the installation service
113func (a *App) GetInstallService() *services.InstallationService {
114 return a.installService
115}
116
117// GetProgressTracker returns the progress tracker
118func (a *App) GetProgressTracker() *services.ProgressTracker {
119 return a.progressTracker
120}
121
122// GetContext returns the app context
123func (a *App) GetContext() context.Context {
124 return a.ctx
125}
126
127// Init is called when the program starts
128func (a *App) Init() tea.Cmd {
129 a.logger.Debug("TUI app initialized")
130 return tea.Batch(
131 a.formModel.Init(),
132 a.detectTimezoneCmd(),
133 )
134}
135
136// Update handles messages and updates the model
137func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
138 switch msg := msg.(type) {
139 case tea.KeyMsg:
140 return a.handleKeyMsg(msg)
141 case handlers.ProgressUpdateMsg:
142 return a.handleProgressUpdate(msg)
143 case handlers.InstallationErrorMsg:
144 return a.handleInstallationError(msg)
145 case handlers.InstallationCompleteMsg:
146 return a.handleInstallationComplete(msg)
147 case CPUDetectedMsg:
148 return a.handleCPUDetected(msg)
149 case GPUDetectedMsg:
150 return a.handleGPUDetected(msg)
151 case TimezoneDetectedMsg:
152 return a.handleTimezoneDetected(msg)
153 case DisksDetectedMsg:
154 return a.handleDisksDetected(msg)
155 }
156
157 return a, nil
158}
159
160// View renders the current screen using the views package
161func (a *App) View() string {
162 switch a.currentScreen {
163 case ScreenForm:
164 return views.RenderForm(a.formModel, a.version)
165 case ScreenDisk:
166 return views.RenderDiskSelection(a.diskModel)
167 case ScreenEncryption:
168 return views.RenderEncryptionSelection(a.encryptionModel)
169 case ScreenKernel:
170 return views.RenderKernelSelection(a.kernelModel)
171 case ScreenAMDPState:
172 return views.RenderAMDPStateSelection(a.amdPstateModel)
173 case ScreenGPU:
174 return views.RenderGPUSelection(a.gpuModel)
175 case ScreenRepos:
176 return views.RenderReposSelection(a.reposModel)
177 case ScreenDankLinux:
178 return views.RenderDankLinuxSelection(a.dankLinuxModel)
179 case ScreenInstalling, ScreenProgress:
180 return views.RenderProgress(a.progressModel)
181 case ScreenSummary:
182 return views.RenderSummary(a.installationModel)
183 case ScreenError:
184 return views.RenderError(a.installationModel.GetError())
185 default:
186 return views.RenderForm(a.formModel, a.version)
187 }
188}
189
190// handleKeyMsg handles keyboard input
191func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
192 switch a.currentScreen {
193 case ScreenForm:
194 return a.handleFormInput(msg)
195 case ScreenDisk:
196 return a.handleDiskInput(msg)
197 case ScreenEncryption:
198 return a.handleEncryptionInput(msg)
199 case ScreenKernel:
200 return a.handleKernelInput(msg)
201 case ScreenAMDPState:
202 return a.handleAMDPStateInput(msg)
203 case ScreenGPU:
204 return a.handleGPUInput(msg)
205 case ScreenRepos:
206 return a.handleReposInput(msg)
207 case ScreenDankLinux:
208 return a.handleDankLinuxInput(msg)
209 case ScreenProgress:
210 // Limited input during installation
211 if msg.String() == "ctrl+c" {
212 return a.handleCancel()
213 }
214 case ScreenSummary:
215 switch msg.String() {
216 case "q", "ctrl+c":
217 return a, tea.Quit
218 case "r":
219 a.unmountAndReboot()
220 return a, nil
221 }
222 case ScreenError:
223 if msg.String() == "q" || msg.String() == "ctrl+c" {
224 return a, tea.Quit
225 }
226 }
227
228 return a, nil
229}
230
231// handleFormInput processes form screen input
232func (a *App) handleFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
233 switch msg.String() {
234 case "ctrl+c":
235 return a, tea.Quit
236 case "enter":
237 a.formModel.ExtractData()
238 // Validate form and go to disk selection
239 if err := a.validateForm(); err != nil {
240 a.logger.Warn("Form validation failed", "error", err)
241 // Show error on form, don't leave screen
242 a.formModel.SetError(err)
243 return a, nil
244 }
245 // Clear any previous error and store validated data in App
246 a.formModel.SetError(nil)
247 a.formData = a.formModel.GetData()
248 a.logger.Debug("Form data stored", "password_len", len(a.formData.UserPassword))
249 return a.startDiskSelection()
250 default:
251 // Delegate to form handler
252 updatedForm, cmd := handlers.HandleFormUpdate(a.formModel, msg)
253 a.formModel = updatedForm
254 return a, cmd
255 }
256}
257
258// handleProgressUpdate processes progress events
259func (a *App) handleProgressUpdate(msg handlers.ProgressUpdateMsg) (tea.Model, tea.Cmd) {
260 updatedProgress, cmd := handlers.HandleProgressUpdate(a, msg, a.progressModel)
261 a.progressModel = updatedProgress
262 // Don't override summary or error screens with late progress messages
263 if a.currentScreen != ScreenSummary && a.currentScreen != ScreenError {
264 a.currentScreen = ScreenProgress
265 }
266 return a, cmd
267}
268
269// handleInstallationError processes installation errors
270func (a *App) handleInstallationError(msg handlers.InstallationErrorMsg) (tea.Model, tea.Cmd) {
271 updatedInstall, cmd := handlers.HandleInstallationError(a, msg, a.installationModel)
272 a.installationModel = updatedInstall
273 a.currentScreen = ScreenError
274 return a, cmd
275}
276
277// handleInstallationComplete processes installation completion
278func (a *App) handleInstallationComplete(msg handlers.InstallationCompleteMsg) (tea.Model, tea.Cmd) {
279 updatedInstall, cmd := handlers.HandleInstallationComplete(a, msg, a.installationModel)
280 a.installationModel = updatedInstall
281 a.currentScreen = ScreenSummary
282 return a, cmd
283}
284
285// handleGPUDetected updates GPU model with detection result.
286func (a *App) handleGPUDetected(msg GPUDetectedMsg) (tea.Model, tea.Cmd) {
287 a.gpuModel.SetDetectedGPU(msg.GPU)
288 return a, nil
289}
290
291// handleCancel cancels the installation
292func (a *App) handleCancel() (tea.Model, tea.Cmd) {
293 a.logger.Info("Installation cancelled by user")
294 a.cancel()
295 a.currentScreen = ScreenError
296 a.installationModel.SetError("Installation cancelled by user")
297 return a, nil
298}
299
300func (a *App) unmountAndReboot() {
301 mountPoint := "/mnt"
302 check := legacysystem.RunSimple("mountpoint", "-q", mountPoint)
303 if check.ExitCode == 0 {
304 if err := legacysystem.Unmount(a.logger.LogPath(), mountPoint); err != nil {
305 a.logger.Warn("Failed to unmount before reboot", "error", err)
306 a.installationModel.SetNotice("Failed to unmount /mnt. Please run: umount -R /mnt")
307 return
308 }
309 }
310
311 if _, err := os.Stat("/dev/mapper/cryptroot"); err == nil {
312 result := legacysystem.RunLogged(a.logger.LogPath(), "cryptsetup", "close", "cryptroot")
313 if result.Error != nil {
314 a.logger.Warn("Failed to close encrypted volume", "error", result.Error)
315 }
316 }
317
318 a.logger.Info("Rebooting system")
319 _ = legacysystem.RunLogged(a.logger.LogPath(), "reboot")
320}
321
322func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
323 switch msg.String() {
324 case "ctrl+c":
325 return a, tea.Quit
326 case "esc", "backspace":
327 return a.startAMDPStateSelection()
328 case "up", "shift+tab":
329 a.gpuModel.MoveUp()
330 return a, nil
331 case "down", "tab":
332 a.gpuModel.MoveDown()
333 return a, nil
334 case "enter":
335 selected := a.gpuModel.SelectedOption()
336 // Update stored form data directly
337 a.formData.GPUVendor = string(selected.Vendor)
338 a.formData.GPUDrivers = append([]string{}, selected.Drivers...)
339 return a.startReposSelection()
340 }
341
342 return a, nil
343}
344
345func (a *App) handleDankLinuxInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
346 switch msg.String() {
347 case "esc", "backspace":
348 return a.startReposSelection()
349 case "up", "shift+tab":
350 a.dankLinuxModel.MoveUp()
351 return a, nil
352 case "down", "tab":
353 a.dankLinuxModel.MoveDown()
354 return a, nil
355 case "enter":
356 a.formData.InstallDankLinux = a.dankLinuxModel.SelectedOption().Value
357 return a.startInstallation()
358 case "q", "ctrl+c":
359 return a, tea.Quit
360 }
361 return a, nil
362}
363
364func (a *App) startReposSelection() (tea.Model, tea.Cmd) {
365 a.currentScreen = ScreenRepos
366 return a, nil
367}
368
369func (a *App) handleReposInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
370 switch msg.String() {
371 case "ctrl+c", "q":
372 return a, tea.Quit
373 case "esc", "backspace":
374 return a.startGPUSelection()
375 case "up":
376 a.reposModel.MoveUp()
377 return a, nil
378 case "down":
379 a.reposModel.MoveDown()
380 return a, nil
381 case "left", "right", " ":
382 // Select the item under the cursor (radio within its section)
383 a.reposModel.Select()
384 return a, nil
385 case "enter":
386 a.formData.AURHelper = a.reposModel.SelectedAURHelper()
387 a.currentScreen = ScreenDankLinux
388 return a, nil
389 }
390 return a, nil
391}
392
393func (a *App) handleKernelInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
394 switch msg.String() {
395 case "ctrl+c":
396 return a, tea.Quit
397 case "esc", "backspace":
398 return a.startDiskSelection()
399 case "up", "shift+tab":
400 a.kernelModel.MoveUp()
401 return a, nil
402 case "down", "tab":
403 a.kernelModel.MoveDown()
404 return a, nil
405 case "enter":
406 selected := a.kernelModel.SelectedOption()
407 a.formData.KernelVariant = selected.Package
408 return a.startAMDPStateSelection()
409 }
410
411 return a, nil
412}
413
414func (a *App) handleCPUDetected(msg CPUDetectedMsg) (tea.Model, tea.Cmd) {
415 if msg.Err != nil {
416 a.logger.Warn("CPU detection reported error", "error", msg.Err)
417 }
418
419 a.amdPstateModel.SetCPUInfo(msg.CPU)
420
421 // Always include microcode when CPU vendor is known
422 if msg.CPU != nil && msg.CPU.Vendor != "" && msg.CPU.Vendor != legacysystem.CPUVendorUnknown {
423 a.formData.Microcode = true
424 }
425
426 if !a.amdPstateModel.ShouldPrompt() {
427 a.formData.AMDPState = ""
428 a.formData.KernelParamsExtra = ""
429 return a.startGPUSelection()
430 }
431
432 return a, nil
433}
434
435func (a *App) handleAMDPStateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
436 switch msg.String() {
437 case "ctrl+c":
438 return a, tea.Quit
439 case "esc", "backspace":
440 return a.startKernelSelection()
441 case "up", "shift+tab":
442 a.amdPstateModel.MoveUp()
443 return a, nil
444 case "down", "tab":
445 a.amdPstateModel.MoveDown()
446 return a, nil
447 case "enter":
448 selected := a.amdPstateModel.SelectedOption()
449 mode := string(selected.Mode)
450 a.formData.AMDPState = mode
451 a.formData.KernelParamsExtra = ""
452
453 cpuInfo := a.amdPstateModel.CPUInfo()
454 switch {
455 case mode == "":
456 // No selection
457 case cpuInfo != nil && cpuInfo.AMDZenGen != nil:
458 a.formData.KernelParamsExtra = legacysystem.GetAMDPStateKernelParams(cpuInfo.AMDZenGen, selected.Mode)
459 default:
460 a.formData.KernelParamsExtra = "amd_pstate=" + mode
461 }
462
463 return a.startGPUSelection()
464 }
465
466 return a, nil
467}
468
469// validateForm validates the form data using domain validation
470func (a *App) validateForm() error {
471 formData := a.formModel.GetData()
472
473 // Use domain validation for all fields
474 if err := system.ValidateHostname(formData.Hostname); err != nil {
475 return err
476 }
477
478 if err := system.ValidateUsername(formData.Username); err != nil {
479 return err
480 }
481
482 if err := system.ValidatePassword(formData.UserPassword); err != nil {
483 return err
484 }
485
486 if err := system.ValidateTimezone(formData.Timezone); err != nil {
487 return err
488 }
489
490 if err := system.ValidateLocale(formData.Locale); err != nil {
491 return err
492 }
493
494 if err := system.ValidateKeymap(formData.Keymap); err != nil {
495 return err
496 }
497
498 // TargetDisk is selected in disk selection screen, not validated here
499 return nil
500}
501
502// startInstallation starts the installation process
503func (a *App) startInstallation() (tea.Model, tea.Cmd) {
504 a.currentScreen = ScreenInstalling
505 a.logger.Debug("Starting installation", "password_len", len(a.formData.UserPassword))
506
507 // Create installation command using stored form data
508 cmd := handlers.CreateInstallationCommand(a, a.formData)
509
510 return a, tea.Batch(cmd)
511}
512
513func (a *App) startAMDPStateSelection() (tea.Model, tea.Cmd) {
514 a.currentScreen = ScreenAMDPState
515 return a, a.detectCPUCmd()
516}
517
518func (a *App) startKernelSelection() (tea.Model, tea.Cmd) {
519 a.currentScreen = ScreenKernel
520 a.kernelModel.SetSelectedPackage(a.formData.KernelVariant)
521 return a, nil
522}
523
524func (a *App) detectCPUCmd() tea.Cmd {
525 return func() tea.Msg {
526 info, err := legacysystem.DetectCPUInfo()
527 if err != nil {
528 a.logger.Warn("CPU detection failed", "error", err)
529 }
530 return CPUDetectedMsg{CPU: info, Err: err}
531 }
532}
533
534func (a *App) startGPUSelection() (tea.Model, tea.Cmd) {
535 a.currentScreen = ScreenGPU
536 return a, a.detectGPUCmd()
537}
538
539func (a *App) detectGPUCmd() tea.Cmd {
540 return func() tea.Msg {
541 gpu, err := a.gpuHandler.Detect(a.ctx)
542 if err != nil {
543 a.logger.Warn("GPU detection failed", "error", err)
544 }
545 return GPUDetectedMsg{GPU: gpu}
546 }
547}
548
549func (a *App) startDiskSelection() (tea.Model, tea.Cmd) {
550 a.currentScreen = ScreenDisk
551 return a, a.detectDisksCmd()
552}
553
554func (a *App) detectDisksCmd() tea.Cmd {
555 return func() tea.Msg {
556 disks, err := legacysystem.ListDisks()
557 return DisksDetectedMsg{Disks: disks, Err: err}
558 }
559}
560
561func (a *App) detectTimezoneCmd() tea.Cmd {
562 return func() tea.Msg {
563 tz := legacysystem.DetectTimezoneSimple()
564 return TimezoneDetectedMsg{Timezone: tz}
565 }
566}
567
568func (a *App) handleDiskInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
569 switch msg.String() {
570 case "ctrl+c":
571 return a, tea.Quit
572 case "esc", "backspace":
573 a.currentScreen = ScreenForm
574 return a, nil
575 case "up", "shift+tab":
576 a.diskModel.MoveUp()
577 return a, nil
578 case "down", "tab":
579 a.diskModel.MoveDown()
580 return a, nil
581 case "enter":
582 selected := a.diskModel.SelectedOption()
583 if selected.Path == "" {
584 // No disk selected
585 a.currentScreen = ScreenError
586 a.installationModel.SetError("No disk selected")
587 return a, nil
588 }
589 // Update stored form data directly
590 a.formData.TargetDisk = selected.Path
591 return a.startEncryptionSelection()
592 }
593 return a, nil
594}
595
596func (a *App) startEncryptionSelection() (tea.Model, tea.Cmd) {
597 a.currentScreen = ScreenEncryption
598 return a, nil
599}
600
601func (a *App) handleEncryptionInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
602 switch msg.String() {
603 case "ctrl+c":
604 return a, tea.Quit
605 case "esc", "backspace":
606 return a.startDiskSelection()
607 case "up", "shift+tab":
608 a.encryptionModel.MoveUp()
609 return a, nil
610 case "down", "tab":
611 a.encryptionModel.MoveDown()
612 return a, nil
613 case "enter":
614 a.formData.EncryptionType = a.encryptionModel.SelectedOption().Value
615 return a.startKernelSelection()
616 }
617 return a, nil
618}
619
620func (a *App) handleTimezoneDetected(msg TimezoneDetectedMsg) (tea.Model, tea.Cmd) {
621 if msg.Timezone != "" {
622 a.logger.Info("Timezone detected", "timezone", msg.Timezone)
623 a.formModel.SetTimezone(msg.Timezone)
624 }
625 return a, nil
626}
627
628func (a *App) handleDisksDetected(msg DisksDetectedMsg) (tea.Model, tea.Cmd) {
629 if msg.Err != nil {
630 a.logger.Warn("Disk detection failed", "error", msg.Err)
631 a.diskModel.SetError(msg.Err)
632 } else {
633 a.diskModel.SetDisks(msg.Disks)
634 }
635 return a, nil
636}
637
638// Close cleans up resources
639func (a *App) Close() error {
640 a.cancel()
641 return a.installService.Close()
642}
643
644// SetProgram sets the tea.Program reference for sending messages from goroutines
645func (a *App) SetProgram(p *tea.Program) {
646 a.program = p
647}
648
649// GetProgram returns the tea.Program reference
650func (a *App) GetProgram() *tea.Program {
651 return a.program
652}