detached c6c0266

feat(installer): support AMD P-state tuning and reliable UEFI boot paths

commit
c6c0266d3eb24dc242f1a349e1e259fa981bbc9e
author
brice · · 2026-01-26 17:56 UTC
parent
ac700d5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +484 -59
M internal/application/commands/install_bootloader.go +10 -9
@@ -8,13 +8,14 @@ import (
88
99 // InstallBootloaderCommand contains data for bootloader installation
1010 type InstallBootloaderCommand struct {
11- MountPoint string // Root mount point
12- BootloaderType bootloader.BootloaderType // BootloaderTypeLimine
13- TimeoutSeconds int // Boot menu timeout (0-600 seconds)
14- Branding string // Bootloader display name
15- KernelVariant packages.KernelVariant // KernelStable, KernelZen, KernelLTS, KernelHardened, KernelCachyOS
16- RootPartition string // Root partition device path
17- EncryptionType disk.EncryptionType // EncryptionTypeNone, EncryptionTypeLUKS, EncryptionTypeLUKSLVM
18- EFIPartition string // EFI partition device path
19- TargetDisk string // Target disk device path
11+ MountPoint string // Root mount point
12+ BootloaderType bootloader.BootloaderType // BootloaderTypeLimine
13+ TimeoutSeconds int // Boot menu timeout (0-600 seconds)
14+ Branding string // Bootloader display name
15+ KernelVariant packages.KernelVariant // KernelStable, KernelZen, KernelLTS, KernelHardened, KernelCachyOS
16+ RootPartition string // Root partition device path
17+ EncryptionType disk.EncryptionType // EncryptionTypeNone, EncryptionTypeLUKS, EncryptionTypeLUKSLVM
18+ EFIPartition string // EFI partition device path
19+ TargetDisk string // Target disk device path
20+ KernelParamsExtra string // Additional kernel parameters
2021 }
@@ -8,13 +8,14 @@ import (
8 8
9 // InstallBootloaderCommand contains data for bootloader installation9 // InstallBootloaderCommand contains data for bootloader installation
10 type InstallBootloaderCommand struct {10 type InstallBootloaderCommand struct {
11- MountPoint string // Root mount point11+ MountPoint string // Root mount point
12- BootloaderType bootloader.BootloaderType // BootloaderTypeLimine12+ BootloaderType bootloader.BootloaderType // BootloaderTypeLimine
13- TimeoutSeconds int // Boot menu timeout (0-600 seconds)13+ TimeoutSeconds int // Boot menu timeout (0-600 seconds)
14- Branding string // Bootloader display name14+ Branding string // Bootloader display name
15- KernelVariant packages.KernelVariant // KernelStable, KernelZen, KernelLTS, KernelHardened, KernelCachyOS15+ KernelVariant packages.KernelVariant // KernelStable, KernelZen, KernelLTS, KernelHardened, KernelCachyOS
16- RootPartition string // Root partition device path16+ RootPartition string // Root partition device path
17- EncryptionType disk.EncryptionType // EncryptionTypeNone, EncryptionTypeLUKS, EncryptionTypeLUKSLVM17+ EncryptionType disk.EncryptionType // EncryptionTypeNone, EncryptionTypeLUKS, EncryptionTypeLUKSLVM
18- EFIPartition string // EFI partition device path18+ EFIPartition string // EFI partition device path
19- TargetDisk string // Target disk device path19+ TargetDisk string // Target disk device path
20+ KernelParamsExtra string // Additional kernel parameters
20 }21 }
M internal/application/handlers/bootloader_handler.go +17 -2
@@ -132,6 +132,18 @@ func (h *BootloaderHandler) installLimine(ctx context.Context, mountPoint string
132132 return fmt.Errorf("failed to copy Limine EFI: %w", err)
133133 }
134134
135+ // Also copy to default/fallback boot path for UEFI firmwares that don't honor boot entries
136+ fallbackDir := filepath.Join(mountPoint, "boot", "EFI", "BOOT")
137+ if err := h.fs.MkdirAll(fallbackDir, 0755); err != nil {
138+ h.logger.Error("Failed to create fallback EFI directory", "error", err)
139+ return fmt.Errorf("failed to create fallback EFI directory: %w", err)
140+ }
141+ fallbackDst := filepath.Join(fallbackDir, "BOOTX64.EFI")
142+ if _, err := h.cmdExec.Execute(ctx, "cp", src, fallbackDst); err != nil {
143+ h.logger.Error("Failed to copy Limine EFI to fallback path", "error", err)
144+ return fmt.Errorf("failed to copy Limine EFI to fallback path: %w", err)
145+ }
146+
135147 return nil
136148 }
137149
@@ -150,7 +162,10 @@ func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.In
150162 kernelParams = fmt.Sprintf("root=UUID=%s rootflags=subvol=@ rw", rootUUID)
151163 }
152164
153- kernelParams = fmt.Sprintf("%s %s", kernelParams, config.KernelParamsQuiet)
165+ kernelParams = strings.TrimSpace(fmt.Sprintf("%s %s", kernelParams, config.KernelParamsQuiet))
166+ if extra := strings.TrimSpace(cmd.KernelParamsExtra); extra != "" {
167+ kernelParams = strings.TrimSpace(kernelParams + " " + extra)
168+ }
154169
155170 templatePath, err := h.resolveLimineTemplate()
156171 if err != nil {
@@ -186,7 +201,7 @@ func (h *BootloaderHandler) createBootEntry(ctx context.Context, targetDisk, efi
186201 return fmt.Errorf("failed to determine EFI partition number from %s", efiPartition)
187202 }
188203
189- if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "efibootmgr", "--create", "--disk", targetDisk, "--part", partNum, "--label", config.UEFIBootLabel, "--loader", config.UEFIBootLoader); err != nil {
204+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "efibootmgr", "--create", "--disk", targetDisk, "--part", partNum, "--label", config.UEFIBootLabel, "--loader", config.UEFIBootLoader, "--unicode"); err != nil {
190205 h.logger.Error("Failed to create EFI boot entry", "error", err)
191206 return fmt.Errorf("failed to create EFI boot entry: %w", err)
192207 }
@@ -132,6 +132,18 @@ func (h *BootloaderHandler) installLimine(ctx context.Context, mountPoint string
132 return fmt.Errorf("failed to copy Limine EFI: %w", err)132 return fmt.Errorf("failed to copy Limine EFI: %w", err)
133 }133 }
134 134
135+ // Also copy to default/fallback boot path for UEFI firmwares that don't honor boot entries
136+ fallbackDir := filepath.Join(mountPoint, "boot", "EFI", "BOOT")
137+ if err := h.fs.MkdirAll(fallbackDir, 0755); err != nil {
138+ h.logger.Error("Failed to create fallback EFI directory", "error", err)
139+ return fmt.Errorf("failed to create fallback EFI directory: %w", err)
140+ }
141+ fallbackDst := filepath.Join(fallbackDir, "BOOTX64.EFI")
142+ if _, err := h.cmdExec.Execute(ctx, "cp", src, fallbackDst); err != nil {
143+ h.logger.Error("Failed to copy Limine EFI to fallback path", "error", err)
144+ return fmt.Errorf("failed to copy Limine EFI to fallback path: %w", err)
145+ }
146+
135 return nil147 return nil
136 }148 }
137 149
@@ -150,7 +162,10 @@ func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.In
150 kernelParams = fmt.Sprintf("root=UUID=%s rootflags=subvol=@ rw", rootUUID)162 kernelParams = fmt.Sprintf("root=UUID=%s rootflags=subvol=@ rw", rootUUID)
151 }163 }
152 164
153- kernelParams = fmt.Sprintf("%s %s", kernelParams, config.KernelParamsQuiet)165+ kernelParams = strings.TrimSpace(fmt.Sprintf("%s %s", kernelParams, config.KernelParamsQuiet))
166+ if extra := strings.TrimSpace(cmd.KernelParamsExtra); extra != "" {
167+ kernelParams = strings.TrimSpace(kernelParams + " " + extra)
168+ }
154 169
155 templatePath, err := h.resolveLimineTemplate()170 templatePath, err := h.resolveLimineTemplate()
156 if err != nil {171 if err != nil {
@@ -186,7 +201,7 @@ func (h *BootloaderHandler) createBootEntry(ctx context.Context, targetDisk, efi
186 return fmt.Errorf("failed to determine EFI partition number from %s", efiPartition)201 return fmt.Errorf("failed to determine EFI partition number from %s", efiPartition)
187 }202 }
188 203
189- if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "efibootmgr", "--create", "--disk", targetDisk, "--part", partNum, "--label", config.UEFIBootLabel, "--loader", config.UEFIBootLoader); err != nil {204+ if _, err := h.chrExec.ExecuteInChroot(ctx, mountPoint, "efibootmgr", "--create", "--disk", targetDisk, "--part", partNum, "--label", config.UEFIBootLabel, "--loader", config.UEFIBootLoader, "--unicode"); err != nil {
190 h.logger.Error("Failed to create EFI boot entry", "error", err)205 h.logger.Error("Failed to create EFI boot entry", "error", err)
191 return fmt.Errorf("failed to create EFI boot entry: %w", err)206 return fmt.Errorf("failed to create EFI boot entry: %w", err)
192 }207 }
M internal/config/config.go +1 -1
@@ -146,7 +146,7 @@ const (
146146 // UEFI boot entry
147147 const (
148148 UEFIBootLabel = "ArchUp"
149- UEFIBootLoader = "\\\\EFI\\\\limine\\\\BOOTX64.EFI"
149+ UEFIBootLoader = "\\EFI\\limine\\BOOTX64.EFI"
150150 )
151151
152152 // Repository paths
@@ -146,7 +146,7 @@ const (
146 // UEFI boot entry146 // UEFI boot entry
147 const (147 const (
148 UEFIBootLabel = "ArchUp"148 UEFIBootLabel = "ArchUp"
149- UEFIBootLoader = "\\\\EFI\\\\limine\\\\BOOTX64.EFI"149+ UEFIBootLoader = "\\EFI\\limine\\BOOTX64.EFI"
150 )150 )
151 151
152 // Repository paths152 // Repository paths
M internal/interfaces/tui/app.go +103 -2
@@ -2,6 +2,7 @@ package tui
22
33 import (
44 "context"
5+ "os"
56
67 apphandlers "github.com/bnema/archup/internal/application/handlers"
78 "github.com/bnema/archup/internal/application/services"
@@ -29,6 +30,7 @@ type App struct {
2930 // UI models (using concrete types for compatibility with views/handlers)
3031 formModel *models.FormModelImpl
3132 diskModel *models.DiskModelImpl
33+ amdPstateModel *models.AMDPStateModelImpl
3234 gpuModel *models.GPUModelImpl
3335 installationModel *models.InstallationModelImpl
3436 progressModel *models.ProgressModelImpl
@@ -51,6 +53,7 @@ type Screen string
5153 const (
5254 ScreenForm Screen = "form"
5355 ScreenDisk Screen = "disk"
56+ ScreenAMDPState Screen = "amd-pstate"
5457 ScreenGPU Screen = "gpu"
5558 ScreenInstalling Screen = "installing"
5659 ScreenProgress Screen = "progress"
@@ -74,6 +77,7 @@ func NewApp(
7477 logger: logger,
7578 formModel: models.NewFormModel(),
7679 diskModel: models.NewDiskModel(),
80+ amdPstateModel: models.NewAMDPStateModel(),
7781 gpuModel: models.NewGPUModel(),
7882 installationModel: models.NewInstallationModel(),
7983 progressModel: models.NewProgressModel(),
@@ -125,6 +129,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
125129 return a.handleInstallationError(msg)
126130 case handlers.InstallationCompleteMsg:
127131 return a.handleInstallationComplete(msg)
132+ case CPUDetectedMsg:
133+ return a.handleCPUDetected(msg)
128134 case GPUDetectedMsg:
129135 return a.handleGPUDetected(msg)
130136 case TimezoneDetectedMsg:
@@ -143,6 +149,8 @@ func (a *App) View() string {
143149 return views.RenderForm(a.formModel)
144150 case ScreenDisk:
145151 return views.RenderDiskSelection(a.diskModel)
152+ case ScreenAMDPState:
153+ return views.RenderAMDPStateSelection(a.amdPstateModel)
146154 case ScreenGPU:
147155 return views.RenderGPUSelection(a.gpuModel)
148156 case ScreenInstalling, ScreenProgress:
@@ -163,6 +171,8 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
163171 return a.handleFormInput(msg)
164172 case ScreenDisk:
165173 return a.handleDiskInput(msg)
174+ case ScreenAMDPState:
175+ return a.handleAMDPStateInput(msg)
166176 case ScreenGPU:
167177 return a.handleGPUInput(msg)
168178 case ScreenProgress:
@@ -171,8 +181,12 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
171181 return a.handleCancel()
172182 }
173183 case ScreenSummary:
174- if msg.String() == "q" || msg.String() == "ctrl+c" {
184+ switch msg.String() {
185+ case "q", "ctrl+c":
175186 return a, tea.Quit
187+ case "r":
188+ a.unmountAndReboot()
189+ return a, nil
176190 }
177191 case ScreenError:
178192 if msg.String() == "q" || msg.String() == "ctrl+c" {
@@ -252,6 +266,28 @@ func (a *App) handleCancel() (tea.Model, tea.Cmd) {
252266 return a, nil
253267 }
254268
269+func (a *App) unmountAndReboot() {
270+ mountPoint := "/mnt"
271+ check := legacysystem.RunSimple("mountpoint", "-q", mountPoint)
272+ if check.ExitCode == 0 {
273+ if err := legacysystem.Unmount(a.logger.LogPath(), mountPoint); err != nil {
274+ a.logger.Warn("Failed to unmount before reboot", "error", err)
275+ a.installationModel.SetNotice("Failed to unmount /mnt. Please run: umount -R /mnt")
276+ return
277+ }
278+ }
279+
280+ if _, err := os.Stat("/dev/mapper/cryptroot"); err == nil {
281+ result := legacysystem.RunLogged(a.logger.LogPath(), "cryptsetup", "close", "cryptroot")
282+ if result.Error != nil {
283+ a.logger.Warn("Failed to close encrypted volume", "error", result.Error)
284+ }
285+ }
286+
287+ a.logger.Info("Rebooting system")
288+ _ = legacysystem.RunLogged(a.logger.LogPath(), "reboot")
289+}
290+
255291 func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
256292 switch msg.String() {
257293 case "ctrl+c":
@@ -273,6 +309,56 @@ func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
273309 return a, nil
274310 }
275311
312+func (a *App) handleCPUDetected(msg CPUDetectedMsg) (tea.Model, tea.Cmd) {
313+ if msg.Err != nil {
314+ a.logger.Warn("CPU detection reported error", "error", msg.Err)
315+ }
316+
317+ a.amdPstateModel.SetCPUInfo(msg.CPU)
318+
319+ if !a.amdPstateModel.ShouldPrompt() {
320+ a.formData.AMDPState = ""
321+ a.formData.KernelParamsExtra = ""
322+ return a.startGPUSelection()
323+ }
324+
325+ return a, nil
326+}
327+
328+func (a *App) handleAMDPStateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
329+ switch msg.String() {
330+ case "ctrl+c":
331+ return a, tea.Quit
332+ case "esc", "backspace":
333+ return a.startDiskSelection()
334+ case "up", "shift+tab":
335+ a.amdPstateModel.MoveUp()
336+ return a, nil
337+ case "down", "tab":
338+ a.amdPstateModel.MoveDown()
339+ return a, nil
340+ case "enter":
341+ selected := a.amdPstateModel.SelectedOption()
342+ mode := string(selected.Mode)
343+ a.formData.AMDPState = mode
344+ a.formData.KernelParamsExtra = ""
345+
346+ cpuInfo := a.amdPstateModel.CPUInfo()
347+ switch {
348+ case mode == "":
349+ // No selection
350+ case cpuInfo != nil && cpuInfo.AMDZenGen != nil:
351+ a.formData.KernelParamsExtra = legacysystem.GetAMDPStateKernelParams(cpuInfo.AMDZenGen, selected.Mode)
352+ default:
353+ a.formData.KernelParamsExtra = "amd_pstate=" + mode
354+ }
355+
356+ return a.startGPUSelection()
357+ }
358+
359+ return a, nil
360+}
361+
276362 // validateForm validates the form data using domain validation
277363 func (a *App) validateForm() error {
278364 formData := a.formModel.GetData()
@@ -317,6 +403,21 @@ func (a *App) startInstallation() (tea.Model, tea.Cmd) {
317403 return a, tea.Batch(cmd)
318404 }
319405
406+func (a *App) startAMDPStateSelection() (tea.Model, tea.Cmd) {
407+ a.currentScreen = ScreenAMDPState
408+ return a, a.detectCPUCmd()
409+}
410+
411+func (a *App) detectCPUCmd() tea.Cmd {
412+ return func() tea.Msg {
413+ info, err := legacysystem.DetectCPUInfo()
414+ if err != nil {
415+ a.logger.Warn("CPU detection failed", "error", err)
416+ }
417+ return CPUDetectedMsg{CPU: info, Err: err}
418+ }
419+}
420+
320421 func (a *App) startGPUSelection() (tea.Model, tea.Cmd) {
321422 a.currentScreen = ScreenGPU
322423 return a, a.detectGPUCmd()
@@ -371,7 +472,7 @@ func (a *App) handleDiskInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
371472 }
372473 // Update stored form data directly
373474 a.formData.TargetDisk = selected.Path
374- return a.startGPUSelection()
475+ return a.startAMDPStateSelection()
375476 }
376477 return a, nil
377478 }
@@ -2,6 +2,7 @@ package tui
2 2
3 import (3 import (
4 "context"4 "context"
5+ "os"
5 6
6 apphandlers "github.com/bnema/archup/internal/application/handlers"7 apphandlers "github.com/bnema/archup/internal/application/handlers"
7 "github.com/bnema/archup/internal/application/services"8 "github.com/bnema/archup/internal/application/services"
@@ -29,6 +30,7 @@ type App struct {
29 // UI models (using concrete types for compatibility with views/handlers)30 // UI models (using concrete types for compatibility with views/handlers)
30 formModel *models.FormModelImpl31 formModel *models.FormModelImpl
31 diskModel *models.DiskModelImpl32 diskModel *models.DiskModelImpl
33+ amdPstateModel *models.AMDPStateModelImpl
32 gpuModel *models.GPUModelImpl34 gpuModel *models.GPUModelImpl
33 installationModel *models.InstallationModelImpl35 installationModel *models.InstallationModelImpl
34 progressModel *models.ProgressModelImpl36 progressModel *models.ProgressModelImpl
@@ -51,6 +53,7 @@ type Screen string
51 const (53 const (
52 ScreenForm Screen = "form"54 ScreenForm Screen = "form"
53 ScreenDisk Screen = "disk"55 ScreenDisk Screen = "disk"
56+ ScreenAMDPState Screen = "amd-pstate"
54 ScreenGPU Screen = "gpu"57 ScreenGPU Screen = "gpu"
55 ScreenInstalling Screen = "installing"58 ScreenInstalling Screen = "installing"
56 ScreenProgress Screen = "progress"59 ScreenProgress Screen = "progress"
@@ -74,6 +77,7 @@ func NewApp(
74 logger: logger,77 logger: logger,
75 formModel: models.NewFormModel(),78 formModel: models.NewFormModel(),
76 diskModel: models.NewDiskModel(),79 diskModel: models.NewDiskModel(),
80+ amdPstateModel: models.NewAMDPStateModel(),
77 gpuModel: models.NewGPUModel(),81 gpuModel: models.NewGPUModel(),
78 installationModel: models.NewInstallationModel(),82 installationModel: models.NewInstallationModel(),
79 progressModel: models.NewProgressModel(),83 progressModel: models.NewProgressModel(),
@@ -125,6 +129,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
125 return a.handleInstallationError(msg)129 return a.handleInstallationError(msg)
126 case handlers.InstallationCompleteMsg:130 case handlers.InstallationCompleteMsg:
127 return a.handleInstallationComplete(msg)131 return a.handleInstallationComplete(msg)
132+ case CPUDetectedMsg:
133+ return a.handleCPUDetected(msg)
128 case GPUDetectedMsg:134 case GPUDetectedMsg:
129 return a.handleGPUDetected(msg)135 return a.handleGPUDetected(msg)
130 case TimezoneDetectedMsg:136 case TimezoneDetectedMsg:
@@ -143,6 +149,8 @@ func (a *App) View() string {
143 return views.RenderForm(a.formModel)149 return views.RenderForm(a.formModel)
144 case ScreenDisk:150 case ScreenDisk:
145 return views.RenderDiskSelection(a.diskModel)151 return views.RenderDiskSelection(a.diskModel)
152+ case ScreenAMDPState:
153+ return views.RenderAMDPStateSelection(a.amdPstateModel)
146 case ScreenGPU:154 case ScreenGPU:
147 return views.RenderGPUSelection(a.gpuModel)155 return views.RenderGPUSelection(a.gpuModel)
148 case ScreenInstalling, ScreenProgress:156 case ScreenInstalling, ScreenProgress:
@@ -163,6 +171,8 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
163 return a.handleFormInput(msg)171 return a.handleFormInput(msg)
164 case ScreenDisk:172 case ScreenDisk:
165 return a.handleDiskInput(msg)173 return a.handleDiskInput(msg)
174+ case ScreenAMDPState:
175+ return a.handleAMDPStateInput(msg)
166 case ScreenGPU:176 case ScreenGPU:
167 return a.handleGPUInput(msg)177 return a.handleGPUInput(msg)
168 case ScreenProgress:178 case ScreenProgress:
@@ -171,8 +181,12 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
171 return a.handleCancel()181 return a.handleCancel()
172 }182 }
173 case ScreenSummary:183 case ScreenSummary:
174- if msg.String() == "q" || msg.String() == "ctrl+c" {184+ switch msg.String() {
185+ case "q", "ctrl+c":
175 return a, tea.Quit186 return a, tea.Quit
187+ case "r":
188+ a.unmountAndReboot()
189+ return a, nil
176 }190 }
177 case ScreenError:191 case ScreenError:
178 if msg.String() == "q" || msg.String() == "ctrl+c" {192 if msg.String() == "q" || msg.String() == "ctrl+c" {
@@ -252,6 +266,28 @@ func (a *App) handleCancel() (tea.Model, tea.Cmd) {
252 return a, nil266 return a, nil
253 }267 }
254 268
269+func (a *App) unmountAndReboot() {
270+ mountPoint := "/mnt"
271+ check := legacysystem.RunSimple("mountpoint", "-q", mountPoint)
272+ if check.ExitCode == 0 {
273+ if err := legacysystem.Unmount(a.logger.LogPath(), mountPoint); err != nil {
274+ a.logger.Warn("Failed to unmount before reboot", "error", err)
275+ a.installationModel.SetNotice("Failed to unmount /mnt. Please run: umount -R /mnt")
276+ return
277+ }
278+ }
279+
280+ if _, err := os.Stat("/dev/mapper/cryptroot"); err == nil {
281+ result := legacysystem.RunLogged(a.logger.LogPath(), "cryptsetup", "close", "cryptroot")
282+ if result.Error != nil {
283+ a.logger.Warn("Failed to close encrypted volume", "error", result.Error)
284+ }
285+ }
286+
287+ a.logger.Info("Rebooting system")
288+ _ = legacysystem.RunLogged(a.logger.LogPath(), "reboot")
289+}
290+
255 func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {291 func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
256 switch msg.String() {292 switch msg.String() {
257 case "ctrl+c":293 case "ctrl+c":
@@ -273,6 +309,56 @@ func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
273 return a, nil309 return a, nil
274 }310 }
275 311
312+func (a *App) handleCPUDetected(msg CPUDetectedMsg) (tea.Model, tea.Cmd) {
313+ if msg.Err != nil {
314+ a.logger.Warn("CPU detection reported error", "error", msg.Err)
315+ }
316+
317+ a.amdPstateModel.SetCPUInfo(msg.CPU)
318+
319+ if !a.amdPstateModel.ShouldPrompt() {
320+ a.formData.AMDPState = ""
321+ a.formData.KernelParamsExtra = ""
322+ return a.startGPUSelection()
323+ }
324+
325+ return a, nil
326+}
327+
328+func (a *App) handleAMDPStateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
329+ switch msg.String() {
330+ case "ctrl+c":
331+ return a, tea.Quit
332+ case "esc", "backspace":
333+ return a.startDiskSelection()
334+ case "up", "shift+tab":
335+ a.amdPstateModel.MoveUp()
336+ return a, nil
337+ case "down", "tab":
338+ a.amdPstateModel.MoveDown()
339+ return a, nil
340+ case "enter":
341+ selected := a.amdPstateModel.SelectedOption()
342+ mode := string(selected.Mode)
343+ a.formData.AMDPState = mode
344+ a.formData.KernelParamsExtra = ""
345+
346+ cpuInfo := a.amdPstateModel.CPUInfo()
347+ switch {
348+ case mode == "":
349+ // No selection
350+ case cpuInfo != nil && cpuInfo.AMDZenGen != nil:
351+ a.formData.KernelParamsExtra = legacysystem.GetAMDPStateKernelParams(cpuInfo.AMDZenGen, selected.Mode)
352+ default:
353+ a.formData.KernelParamsExtra = "amd_pstate=" + mode
354+ }
355+
356+ return a.startGPUSelection()
357+ }
358+
359+ return a, nil
360+}
361+
276 // validateForm validates the form data using domain validation362 // validateForm validates the form data using domain validation
277 func (a *App) validateForm() error {363 func (a *App) validateForm() error {
278 formData := a.formModel.GetData()364 formData := a.formModel.GetData()
@@ -317,6 +403,21 @@ func (a *App) startInstallation() (tea.Model, tea.Cmd) {
317 return a, tea.Batch(cmd)403 return a, tea.Batch(cmd)
318 }404 }
319 405
406+func (a *App) startAMDPStateSelection() (tea.Model, tea.Cmd) {
407+ a.currentScreen = ScreenAMDPState
408+ return a, a.detectCPUCmd()
409+}
410+
411+func (a *App) detectCPUCmd() tea.Cmd {
412+ return func() tea.Msg {
413+ info, err := legacysystem.DetectCPUInfo()
414+ if err != nil {
415+ a.logger.Warn("CPU detection failed", "error", err)
416+ }
417+ return CPUDetectedMsg{CPU: info, Err: err}
418+ }
419+}
420+
320 func (a *App) startGPUSelection() (tea.Model, tea.Cmd) {421 func (a *App) startGPUSelection() (tea.Model, tea.Cmd) {
321 a.currentScreen = ScreenGPU422 a.currentScreen = ScreenGPU
322 return a, a.detectGPUCmd()423 return a, a.detectGPUCmd()
@@ -371,7 +472,7 @@ func (a *App) handleDiskInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
371 }472 }
372 // Update stored form data directly473 // Update stored form data directly
373 a.formData.TargetDisk = selected.Path474 a.formData.TargetDisk = selected.Path
374- return a.startGPUSelection()475+ return a.startAMDPStateSelection()
375 }476 }
376 return a, nil477 return a, nil
377 }478 }
M internal/interfaces/tui/handlers/install_handler.go +14 -9
@@ -53,6 +53,10 @@ func HandleInstallationError(app AppContext, msg interface{}, installModel *mode
5353 func HandleInstallationComplete(app AppContext, msg interface{}, installModel *models.InstallationModelImpl) (*models.InstallationModelImpl, tea.Cmd) {
5454 app.GetLogger().Info("Installation completed successfully")
5555 installModel.SetComplete()
56+ // Update status with final timestamps for duration display
57+ if svc := app.GetInstallService(); svc != nil {
58+ installModel.SetStatus(svc.GetStatus())
59+ }
5660 return installModel, nil
5761 }
5862
@@ -168,15 +172,16 @@ func CreateInstallationCommand(app AppContext, formData models.FormData) tea.Cmd
168172
169173 // Phase 5: Bootloader
170174 bootCmd := commands.InstallBootloaderCommand{
171- MountPoint: "/mnt",
172- BootloaderType: bootloader.BootloaderTypeLimine,
173- TimeoutSeconds: 5,
174- Branding: "ArchUp",
175- KernelVariant: parseKernelVariant(formData.KernelVariant),
176- RootPartition: partitionResult.RootPartition,
177- EncryptionType: partitionCmd.EncryptionType,
178- EFIPartition: partitionResult.EFIPartition,
179- TargetDisk: formData.TargetDisk,
175+ MountPoint: "/mnt",
176+ BootloaderType: bootloader.BootloaderTypeLimine,
177+ TimeoutSeconds: 5,
178+ Branding: "ArchUp",
179+ KernelVariant: parseKernelVariant(formData.KernelVariant),
180+ RootPartition: partitionResult.RootPartition,
181+ EncryptionType: partitionCmd.EncryptionType,
182+ EFIPartition: partitionResult.EFIPartition,
183+ TargetDisk: formData.TargetDisk,
184+ KernelParamsExtra: formData.KernelParamsExtra,
180185 }
181186 if _, err := svc.RunBootloaderSetup(ctx, bootCmd); err != nil {
182187 logger.Error("Bootloader setup failed", "error", err)
@@ -53,6 +53,10 @@ func HandleInstallationError(app AppContext, msg interface{}, installModel *mode
53 func HandleInstallationComplete(app AppContext, msg interface{}, installModel *models.InstallationModelImpl) (*models.InstallationModelImpl, tea.Cmd) {53 func HandleInstallationComplete(app AppContext, msg interface{}, installModel *models.InstallationModelImpl) (*models.InstallationModelImpl, tea.Cmd) {
54 app.GetLogger().Info("Installation completed successfully")54 app.GetLogger().Info("Installation completed successfully")
55 installModel.SetComplete()55 installModel.SetComplete()
56+ // Update status with final timestamps for duration display
57+ if svc := app.GetInstallService(); svc != nil {
58+ installModel.SetStatus(svc.GetStatus())
59+ }
56 return installModel, nil60 return installModel, nil
57 }61 }
58 62
@@ -168,15 +172,16 @@ func CreateInstallationCommand(app AppContext, formData models.FormData) tea.Cmd
168 172
169 // Phase 5: Bootloader173 // Phase 5: Bootloader
170 bootCmd := commands.InstallBootloaderCommand{174 bootCmd := commands.InstallBootloaderCommand{
171- MountPoint: "/mnt",175+ MountPoint: "/mnt",
172- BootloaderType: bootloader.BootloaderTypeLimine,176+ BootloaderType: bootloader.BootloaderTypeLimine,
173- TimeoutSeconds: 5,177+ TimeoutSeconds: 5,
174- Branding: "ArchUp",178+ Branding: "ArchUp",
175- KernelVariant: parseKernelVariant(formData.KernelVariant),179+ KernelVariant: parseKernelVariant(formData.KernelVariant),
176- RootPartition: partitionResult.RootPartition,180+ RootPartition: partitionResult.RootPartition,
177- EncryptionType: partitionCmd.EncryptionType,181+ EncryptionType: partitionCmd.EncryptionType,
178- EFIPartition: partitionResult.EFIPartition,182+ EFIPartition: partitionResult.EFIPartition,
179- TargetDisk: formData.TargetDisk,183+ TargetDisk: formData.TargetDisk,
184+ KernelParamsExtra: formData.KernelParamsExtra,
180 }185 }
181 if _, err := svc.RunBootloaderSetup(ctx, bootCmd); err != nil {186 if _, err := svc.RunBootloaderSetup(ctx, bootCmd); err != nil {
182 logger.Error("Bootloader setup failed", "error", err)187 logger.Error("Bootloader setup failed", "error", err)
M internal/interfaces/tui/messages.go +6 -0
@@ -23,6 +23,12 @@ type GPUDetectedMsg struct {
2323 GPU *system.GPU
2424 }
2525
26+// CPUDetectedMsg is sent when CPU detection completes
27+type CPUDetectedMsg struct {
28+ CPU *legacysystem.CPUInfo
29+ Err error
30+}
31+
2632 // TimezoneDetectedMsg is sent when timezone detection completes
2733 type TimezoneDetectedMsg struct {
2834 Timezone string
@@ -23,6 +23,12 @@ type GPUDetectedMsg struct {
23 GPU *system.GPU23 GPU *system.GPU
24 }24 }
25 25
26+// CPUDetectedMsg is sent when CPU detection completes
27+type CPUDetectedMsg struct {
28+ CPU *legacysystem.CPUInfo
29+ Err error
30+}
31+
26 // TimezoneDetectedMsg is sent when timezone detection completes32 // TimezoneDetectedMsg is sent when timezone detection completes
27 type TimezoneDetectedMsg struct {33 type TimezoneDetectedMsg struct {
28 Timezone string34 Timezone string
A internal/interfaces/tui/models/amd_pstate.go +114 -0
@@ -0,0 +1,114 @@
1+package models
2+
3+import legacysystem "github.com/bnema/archup/internal/system"
4+
5+// AMDPStateOption represents a selectable AMD P-State mode.
6+type AMDPStateOption struct {
7+ Mode legacysystem.AMDPStateMode
8+ Label string
9+ Recommended bool
10+}
11+
12+// AMDPStateModelImpl holds AMD P-State selection state.
13+type AMDPStateModelImpl struct {
14+ cpuInfo *legacysystem.CPUInfo
15+ options []AMDPStateOption
16+ selected int
17+}
18+
19+// NewAMDPStateModel creates a new AMD P-State selection model.
20+func NewAMDPStateModel() *AMDPStateModelImpl {
21+ return &AMDPStateModelImpl{
22+ options: []AMDPStateOption{},
23+ selected: 0,
24+ }
25+}
26+
27+// SetCPUInfo sets CPU info and rebuilds options.
28+func (am *AMDPStateModelImpl) SetCPUInfo(info *legacysystem.CPUInfo) {
29+ am.cpuInfo = info
30+ am.options = []AMDPStateOption{}
31+ am.selected = 0
32+
33+ if info == nil {
34+ return
35+ }
36+
37+ for _, mode := range info.AMDPStateModes {
38+ desc := legacysystem.GetPStateModeDescription(mode)
39+ label := string(mode)
40+ if desc != "" {
41+ label = label + " - " + desc
42+ }
43+ option := AMDPStateOption{
44+ Mode: mode,
45+ Label: label,
46+ Recommended: mode == info.RecommendedPStateMode,
47+ }
48+ am.options = append(am.options, option)
49+ }
50+
51+ for i, option := range am.options {
52+ if option.Recommended {
53+ am.selected = i
54+ break
55+ }
56+ }
57+}
58+
59+// CPUInfo returns detected CPU info.
60+func (am *AMDPStateModelImpl) CPUInfo() *legacysystem.CPUInfo {
61+ return am.cpuInfo
62+}
63+
64+// Options returns the selectable options.
65+func (am *AMDPStateModelImpl) Options() []AMDPStateOption {
66+ return am.options
67+}
68+
69+// SelectedIndex returns the current selection index.
70+func (am *AMDPStateModelImpl) SelectedIndex() int {
71+ return am.selected
72+}
73+
74+// SelectedOption returns the currently selected option.
75+func (am *AMDPStateModelImpl) SelectedOption() AMDPStateOption {
76+ if len(am.options) == 0 {
77+ return AMDPStateOption{}
78+ }
79+ if am.selected < 0 || am.selected >= len(am.options) {
80+ return am.options[0]
81+ }
82+ return am.options[am.selected]
83+}
84+
85+// MoveUp moves selection up.
86+func (am *AMDPStateModelImpl) MoveUp() {
87+ if len(am.options) == 0 {
88+ return
89+ }
90+ if am.selected == 0 {
91+ am.selected = len(am.options) - 1
92+ return
93+ }
94+ am.selected--
95+}
96+
97+// MoveDown moves selection down.
98+func (am *AMDPStateModelImpl) MoveDown() {
99+ if len(am.options) == 0 {
100+ return
101+ }
102+ am.selected = (am.selected + 1) % len(am.options)
103+}
104+
105+// ShouldPrompt returns true if AMD P-State selection should be shown.
106+func (am *AMDPStateModelImpl) ShouldPrompt() bool {
107+ if am.cpuInfo == nil {
108+ return false
109+ }
110+ if am.cpuInfo.Vendor != legacysystem.CPUVendorAMD {
111+ return false
112+ }
113+ return len(am.options) > 0
114+}
@@ -0,0 +1,114 @@
1+package models
2+
3+import legacysystem "github.com/bnema/archup/internal/system"
4+
5+// AMDPStateOption represents a selectable AMD P-State mode.
6+type AMDPStateOption struct {
7+ Mode legacysystem.AMDPStateMode
8+ Label string
9+ Recommended bool
10+}
11+
12+// AMDPStateModelImpl holds AMD P-State selection state.
13+type AMDPStateModelImpl struct {
14+ cpuInfo *legacysystem.CPUInfo
15+ options []AMDPStateOption
16+ selected int
17+}
18+
19+// NewAMDPStateModel creates a new AMD P-State selection model.
20+func NewAMDPStateModel() *AMDPStateModelImpl {
21+ return &AMDPStateModelImpl{
22+ options: []AMDPStateOption{},
23+ selected: 0,
24+ }
25+}
26+
27+// SetCPUInfo sets CPU info and rebuilds options.
28+func (am *AMDPStateModelImpl) SetCPUInfo(info *legacysystem.CPUInfo) {
29+ am.cpuInfo = info
30+ am.options = []AMDPStateOption{}
31+ am.selected = 0
32+
33+ if info == nil {
34+ return
35+ }
36+
37+ for _, mode := range info.AMDPStateModes {
38+ desc := legacysystem.GetPStateModeDescription(mode)
39+ label := string(mode)
40+ if desc != "" {
41+ label = label + " - " + desc
42+ }
43+ option := AMDPStateOption{
44+ Mode: mode,
45+ Label: label,
46+ Recommended: mode == info.RecommendedPStateMode,
47+ }
48+ am.options = append(am.options, option)
49+ }
50+
51+ for i, option := range am.options {
52+ if option.Recommended {
53+ am.selected = i
54+ break
55+ }
56+ }
57+}
58+
59+// CPUInfo returns detected CPU info.
60+func (am *AMDPStateModelImpl) CPUInfo() *legacysystem.CPUInfo {
61+ return am.cpuInfo
62+}
63+
64+// Options returns the selectable options.
65+func (am *AMDPStateModelImpl) Options() []AMDPStateOption {
66+ return am.options
67+}
68+
69+// SelectedIndex returns the current selection index.
70+func (am *AMDPStateModelImpl) SelectedIndex() int {
71+ return am.selected
72+}
73+
74+// SelectedOption returns the currently selected option.
75+func (am *AMDPStateModelImpl) SelectedOption() AMDPStateOption {
76+ if len(am.options) == 0 {
77+ return AMDPStateOption{}
78+ }
79+ if am.selected < 0 || am.selected >= len(am.options) {
80+ return am.options[0]
81+ }
82+ return am.options[am.selected]
83+}
84+
85+// MoveUp moves selection up.
86+func (am *AMDPStateModelImpl) MoveUp() {
87+ if len(am.options) == 0 {
88+ return
89+ }
90+ if am.selected == 0 {
91+ am.selected = len(am.options) - 1
92+ return
93+ }
94+ am.selected--
95+}
96+
97+// MoveDown moves selection down.
98+func (am *AMDPStateModelImpl) MoveDown() {
99+ if len(am.options) == 0 {
100+ return
101+ }
102+ am.selected = (am.selected + 1) % len(am.options)
103+}
104+
105+// ShouldPrompt returns true if AMD P-State selection should be shown.
106+func (am *AMDPStateModelImpl) ShouldPrompt() bool {
107+ if am.cpuInfo == nil {
108+ return false
109+ }
110+ if am.cpuInfo.Vendor != legacysystem.CPUVendorAMD {
111+ return false
112+ }
113+ return len(am.options) > 0
114+}
M internal/interfaces/tui/models/form.go +29 -25
@@ -22,21 +22,23 @@ func (bm *BaseModel) SetSize(width, height int) {
2222
2323 // FormData contains the data from the form
2424 type FormData struct {
25- Hostname string
26- Username string
27- UserEmail string
28- UserPassword string
29- RootPassword string
30- TargetDisk string
31- EncryptionType string
32- GPUVendor string
33- GPUDrivers []string
34- Timezone string
35- Locale string
36- Keymap string
37- KernelVariant string
38- AURHelper string
39- Microcode bool
25+ Hostname string
26+ Username string
27+ UserEmail string
28+ UserPassword string
29+ RootPassword string
30+ TargetDisk string
31+ EncryptionType string
32+ AMDPState string
33+ KernelParamsExtra string
34+ GPUVendor string
35+ GPUDrivers []string
36+ Timezone string
37+ Locale string
38+ Keymap string
39+ KernelVariant string
40+ AURHelper string
41+ Microcode bool
4042 }
4143
4244 // FormModelImpl implements FormModel interface
@@ -296,16 +298,18 @@ func (fm *FormModelImpl) extractData() {
296298 // Use fm.fields slice values, not the original fields (which are copies)
297299 // Field order: hostname(0), username(1), email(2), password(3), timezone(4), locale(5), keymap(6)
298300 fm.data = FormData{
299- Hostname: fm.fields[0].Value(),
300- Username: fm.fields[1].Value(),
301- UserEmail: fm.fields[2].Value(), // Optional - for git config
302- UserPassword: fm.fields[3].Value(),
303- RootPassword: "", // No root password - root account locked, user is sudoer
304- TargetDisk: fm.targetDisk.Value(),
305- EncryptionType: fm.data.EncryptionType,
306- Timezone: fm.fields[4].Value(),
307- Locale: fm.fields[5].Value(),
308- Keymap: fm.fields[6].Value(),
301+ Hostname: fm.fields[0].Value(),
302+ Username: fm.fields[1].Value(),
303+ UserEmail: fm.fields[2].Value(), // Optional - for git config
304+ UserPassword: fm.fields[3].Value(),
305+ RootPassword: "", // No root password - root account locked, user is sudoer
306+ TargetDisk: fm.targetDisk.Value(),
307+ EncryptionType: fm.data.EncryptionType,
308+ AMDPState: fm.data.AMDPState,
309+ KernelParamsExtra: fm.data.KernelParamsExtra,
310+ Timezone: fm.fields[4].Value(),
311+ Locale: fm.fields[5].Value(),
312+ Keymap: fm.fields[6].Value(),
309313 }
310314 }
311315
@@ -22,21 +22,23 @@ func (bm *BaseModel) SetSize(width, height int) {
22 22
23 // FormData contains the data from the form23 // FormData contains the data from the form
24 type FormData struct {24 type FormData struct {
25- Hostname string25+ Hostname string
26- Username string26+ Username string
27- UserEmail string27+ UserEmail string
28- UserPassword string28+ UserPassword string
29- RootPassword string29+ RootPassword string
30- TargetDisk string30+ TargetDisk string
31- EncryptionType string31+ EncryptionType string
32- GPUVendor string32+ AMDPState string
33- GPUDrivers []string33+ KernelParamsExtra string
34- Timezone string34+ GPUVendor string
35- Locale string35+ GPUDrivers []string
36- Keymap string36+ Timezone string
37- KernelVariant string37+ Locale string
38- AURHelper string38+ Keymap string
39- Microcode bool39+ KernelVariant string
40+ AURHelper string
41+ Microcode bool
40 }42 }
41 43
42 // FormModelImpl implements FormModel interface44 // FormModelImpl implements FormModel interface
@@ -296,16 +298,18 @@ func (fm *FormModelImpl) extractData() {
296 // Use fm.fields slice values, not the original fields (which are copies)298 // Use fm.fields slice values, not the original fields (which are copies)
297 // Field order: hostname(0), username(1), email(2), password(3), timezone(4), locale(5), keymap(6)299 // Field order: hostname(0), username(1), email(2), password(3), timezone(4), locale(5), keymap(6)
298 fm.data = FormData{300 fm.data = FormData{
299- Hostname: fm.fields[0].Value(),301+ Hostname: fm.fields[0].Value(),
300- Username: fm.fields[1].Value(),302+ Username: fm.fields[1].Value(),
301- UserEmail: fm.fields[2].Value(), // Optional - for git config303+ UserEmail: fm.fields[2].Value(), // Optional - for git config
302- UserPassword: fm.fields[3].Value(),304+ UserPassword: fm.fields[3].Value(),
303- RootPassword: "", // No root password - root account locked, user is sudoer305+ RootPassword: "", // No root password - root account locked, user is sudoer
304- TargetDisk: fm.targetDisk.Value(),306+ TargetDisk: fm.targetDisk.Value(),
305- EncryptionType: fm.data.EncryptionType,307+ EncryptionType: fm.data.EncryptionType,
306- Timezone: fm.fields[4].Value(),308+ AMDPState: fm.data.AMDPState,
307- Locale: fm.fields[5].Value(),309+ KernelParamsExtra: fm.data.KernelParamsExtra,
308- Keymap: fm.fields[6].Value(),310+ Timezone: fm.fields[4].Value(),
311+ Locale: fm.fields[5].Value(),
312+ Keymap: fm.fields[6].Value(),
309 }313 }
310 }314 }
311 315
M internal/interfaces/tui/models/gpu.go +39 -2
@@ -1,6 +1,10 @@
11 package models
22
3-import "github.com/bnema/archup/internal/domain/system"
3+import (
4+ "strings"
5+
6+ "github.com/bnema/archup/internal/domain/system"
7+)
48
59 // GPUOption represents a selectable GPU driver option.
610 type GPUOption struct {
@@ -35,7 +39,7 @@ func NewGPUModel() *GPUModelImpl {
3539 Drivers: []string{"nvidia-open", "nvidia-utils", "libva-nvidia-driver"},
3640 },
3741 {
38- Label: "Skip GPU drivers",
42+ Label: "None / VM",
3943 Vendor: system.GPUVendorUnknown,
4044 Drivers: []string{},
4145 },
@@ -53,6 +57,10 @@ func (gm *GPUModelImpl) SetDetectedGPU(gpu *system.GPU) {
5357 if gpu == nil {
5458 return
5559 }
60+ if isVirtualGPUModel(gpu.Model()) || gpu.Vendor() == system.GPUVendorUnknown {
61+ gm.selectUnknown()
62+ return
63+ }
5664 for i, option := range gm.options {
5765 if option.Vendor == gpu.Vendor() {
5866 gm.selected = i
@@ -61,6 +69,35 @@ func (gm *GPUModelImpl) SetDetectedGPU(gpu *system.GPU) {
6169 }
6270 }
6371
72+func (gm *GPUModelImpl) selectUnknown() {
73+ for i, option := range gm.options {
74+ if option.Vendor == system.GPUVendorUnknown {
75+ gm.selected = i
76+ return
77+ }
78+ }
79+}
80+
81+func isVirtualGPUModel(model string) bool {
82+ lower := strings.ToLower(model)
83+ virtualMarkers := []string{
84+ "virtualbox",
85+ "vmware",
86+ "qxl",
87+ "virtio",
88+ "bochs",
89+ "hyper-v",
90+ "microsoft",
91+ "svga",
92+ }
93+ for _, marker := range virtualMarkers {
94+ if strings.Contains(lower, marker) {
95+ return true
96+ }
97+ }
98+ return false
99+}
100+
64101 // DetectedGPU returns the detected GPU.
65102 func (gm *GPUModelImpl) DetectedGPU() *system.GPU {
66103 return gm.detected
@@ -1,6 +1,10 @@
1 package models1 package models
2 2
3-import "github.com/bnema/archup/internal/domain/system"3+import (
4+ "strings"
5+
6+ "github.com/bnema/archup/internal/domain/system"
7+)
4 8
5 // GPUOption represents a selectable GPU driver option.9 // GPUOption represents a selectable GPU driver option.
6 type GPUOption struct {10 type GPUOption struct {
@@ -35,7 +39,7 @@ func NewGPUModel() *GPUModelImpl {
35 Drivers: []string{"nvidia-open", "nvidia-utils", "libva-nvidia-driver"},39 Drivers: []string{"nvidia-open", "nvidia-utils", "libva-nvidia-driver"},
36 },40 },
37 {41 {
38- Label: "Skip GPU drivers",42+ Label: "None / VM",
39 Vendor: system.GPUVendorUnknown,43 Vendor: system.GPUVendorUnknown,
40 Drivers: []string{},44 Drivers: []string{},
41 },45 },
@@ -53,6 +57,10 @@ func (gm *GPUModelImpl) SetDetectedGPU(gpu *system.GPU) {
53 if gpu == nil {57 if gpu == nil {
54 return58 return
55 }59 }
60+ if isVirtualGPUModel(gpu.Model()) || gpu.Vendor() == system.GPUVendorUnknown {
61+ gm.selectUnknown()
62+ return
63+ }
56 for i, option := range gm.options {64 for i, option := range gm.options {
57 if option.Vendor == gpu.Vendor() {65 if option.Vendor == gpu.Vendor() {
58 gm.selected = i66 gm.selected = i
@@ -61,6 +69,35 @@ func (gm *GPUModelImpl) SetDetectedGPU(gpu *system.GPU) {
61 }69 }
62 }70 }
63 71
72+func (gm *GPUModelImpl) selectUnknown() {
73+ for i, option := range gm.options {
74+ if option.Vendor == system.GPUVendorUnknown {
75+ gm.selected = i
76+ return
77+ }
78+ }
79+}
80+
81+func isVirtualGPUModel(model string) bool {
82+ lower := strings.ToLower(model)
83+ virtualMarkers := []string{
84+ "virtualbox",
85+ "vmware",
86+ "qxl",
87+ "virtio",
88+ "bochs",
89+ "hyper-v",
90+ "microsoft",
91+ "svga",
92+ }
93+ for _, marker := range virtualMarkers {
94+ if strings.Contains(lower, marker) {
95+ return true
96+ }
97+ }
98+ return false
99+}
100+
64 // DetectedGPU returns the detected GPU.101 // DetectedGPU returns the detected GPU.
65 func (gm *GPUModelImpl) DetectedGPU() *system.GPU {102 func (gm *GPUModelImpl) DetectedGPU() *system.GPU {
66 return gm.detected103 return gm.detected
M internal/interfaces/tui/models/installation.go +11 -1
@@ -16,6 +16,7 @@ type InstallationModelImpl struct {
1616
1717 status *dto.InstallationStatus
1818 err string
19+ notice string
1920 complete bool
2021 }
2122
@@ -24,6 +25,7 @@ func NewInstallationModel() *InstallationModelImpl {
2425 return &InstallationModelImpl{
2526 status: &dto.InstallationStatus{},
2627 err: "",
28+ notice: "",
2729 complete: false,
2830 }
2931 }
@@ -87,6 +89,10 @@ func (im *InstallationModelImpl) SetError(message string) {
8789 im.err = message
8890 }
8991
92+func (im *InstallationModelImpl) SetNotice(message string) {
93+ im.notice = message
94+}
95+
9096 // SetComplete marks installation as complete
9197 func (im *InstallationModelImpl) SetComplete() {
9298 im.complete = true
@@ -102,6 +108,10 @@ func (im *InstallationModelImpl) GetError() string {
102108 return im.err
103109 }
104110
111+func (im *InstallationModelImpl) GetNotice() string {
112+ return im.notice
113+}
114+
105115 // IsComplete returns whether the installation is complete
106116 func (im *InstallationModelImpl) IsComplete() bool {
107117 return im.complete
@@ -195,7 +205,7 @@ func (im *InstallationModelImpl) renderSummary() string {
195205 b.WriteString("\n")
196206 b.WriteString(lipgloss.NewStyle().
197207 Faint(true).
198- Render("Press 'q' to exit"))
208+ Render("Press 'r' to unmount and reboot, or 'q' to exit"))
199209
200210 return b.String()
201211 }
@@ -16,6 +16,7 @@ type InstallationModelImpl struct {
16 16
17 status *dto.InstallationStatus17 status *dto.InstallationStatus
18 err string18 err string
19+ notice string
19 complete bool20 complete bool
20 }21 }
21 22
@@ -24,6 +25,7 @@ func NewInstallationModel() *InstallationModelImpl {
24 return &InstallationModelImpl{25 return &InstallationModelImpl{
25 status: &dto.InstallationStatus{},26 status: &dto.InstallationStatus{},
26 err: "",27 err: "",
28+ notice: "",
27 complete: false,29 complete: false,
28 }30 }
29 }31 }
@@ -87,6 +89,10 @@ func (im *InstallationModelImpl) SetError(message string) {
87 im.err = message89 im.err = message
88 }90 }
89 91
92+func (im *InstallationModelImpl) SetNotice(message string) {
93+ im.notice = message
94+}
95+
90 // SetComplete marks installation as complete96 // SetComplete marks installation as complete
91 func (im *InstallationModelImpl) SetComplete() {97 func (im *InstallationModelImpl) SetComplete() {
92 im.complete = true98 im.complete = true
@@ -102,6 +108,10 @@ func (im *InstallationModelImpl) GetError() string {
102 return im.err108 return im.err
103 }109 }
104 110
111+func (im *InstallationModelImpl) GetNotice() string {
112+ return im.notice
113+}
114+
105 // IsComplete returns whether the installation is complete115 // IsComplete returns whether the installation is complete
106 func (im *InstallationModelImpl) IsComplete() bool {116 func (im *InstallationModelImpl) IsComplete() bool {
107 return im.complete117 return im.complete
@@ -195,7 +205,7 @@ func (im *InstallationModelImpl) renderSummary() string {
195 b.WriteString("\n")205 b.WriteString("\n")
196 b.WriteString(lipgloss.NewStyle().206 b.WriteString(lipgloss.NewStyle().
197 Faint(true).207 Faint(true).
198- Render("Press 'q' to exit"))208+ Render("Press 'r' to unmount and reboot, or 'q' to exit"))
199 209
200 return b.String()210 return b.String()
201 }211 }
A internal/interfaces/tui/views/amd_pstate.go +76 -0
@@ -0,0 +1,76 @@
1+package views
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/archup/internal/interfaces/tui/models"
7+ legacysystem "github.com/bnema/archup/internal/system"
8+ "github.com/charmbracelet/lipgloss"
9+)
10+
11+// RenderAMDPStateSelection renders the AMD P-State selection screen.
12+func RenderAMDPStateSelection(am *models.AMDPStateModelImpl) string {
13+ var b strings.Builder
14+
15+ title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
16+ info := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
17+ active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
18+
19+ b.WriteString("\n")
20+ b.WriteString(title.Render("AMD P-State Configuration"))
21+ b.WriteString("\n\n")
22+
23+ if am == nil || am.CPUInfo() == nil {
24+ b.WriteString(info.Render("Detecting CPU..."))
25+ b.WriteString("\n\n")
26+ b.WriteString(info.Render("This screen will appear only for AMD CPUs."))
27+ return b.String()
28+ }
29+
30+ cpuInfo := am.CPUInfo()
31+ if cpuInfo.Vendor != legacysystem.CPUVendorAMD {
32+ b.WriteString(info.Render("Non-AMD CPU detected. Skipping AMD P-State configuration."))
33+ return b.String()
34+ }
35+
36+ if cpuInfo.AMDZenGen != nil {
37+ b.WriteString(info.Render("Detected: "))
38+ b.WriteString(cpuInfo.AMDZenGen.Label)
39+ b.WriteString("\n")
40+ }
41+ if cpuInfo.ModelName != "" {
42+ b.WriteString(info.Render("CPU: "))
43+ b.WriteString(cpuInfo.ModelName)
44+ b.WriteString("\n")
45+ }
46+
47+ b.WriteString("\n")
48+ b.WriteString(info.Render("Note: Requires CPPC enabled in UEFI (AMD CBS > NBIO > SMU > CPPC)"))
49+ b.WriteString("\n\n")
50+
51+ options := am.Options()
52+ if len(options) == 0 {
53+ b.WriteString(info.Render("No AMD P-State modes available for this CPU."))
54+ return b.String()
55+ }
56+
57+ for i, option := range options {
58+ prefix := " "
59+ style := lipgloss.NewStyle()
60+ label := option.Label
61+ if option.Recommended {
62+ label = label + " (recommended)"
63+ }
64+ if i == am.SelectedIndex() {
65+ prefix = "> "
66+ style = active
67+ }
68+ b.WriteString(style.Render(prefix + label))
69+ b.WriteString("\n")
70+ }
71+
72+ b.WriteString("\n")
73+ b.WriteString(info.Render("Up/Down Move | Enter Confirm | Esc Back | Ctrl+C Quit"))
74+
75+ return b.String()
76+}
@@ -0,0 +1,76 @@
1+package views
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/archup/internal/interfaces/tui/models"
7+ legacysystem "github.com/bnema/archup/internal/system"
8+ "github.com/charmbracelet/lipgloss"
9+)
10+
11+// RenderAMDPStateSelection renders the AMD P-State selection screen.
12+func RenderAMDPStateSelection(am *models.AMDPStateModelImpl) string {
13+ var b strings.Builder
14+
15+ title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
16+ info := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
17+ active := lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
18+
19+ b.WriteString("\n")
20+ b.WriteString(title.Render("AMD P-State Configuration"))
21+ b.WriteString("\n\n")
22+
23+ if am == nil || am.CPUInfo() == nil {
24+ b.WriteString(info.Render("Detecting CPU..."))
25+ b.WriteString("\n\n")
26+ b.WriteString(info.Render("This screen will appear only for AMD CPUs."))
27+ return b.String()
28+ }
29+
30+ cpuInfo := am.CPUInfo()
31+ if cpuInfo.Vendor != legacysystem.CPUVendorAMD {
32+ b.WriteString(info.Render("Non-AMD CPU detected. Skipping AMD P-State configuration."))
33+ return b.String()
34+ }
35+
36+ if cpuInfo.AMDZenGen != nil {
37+ b.WriteString(info.Render("Detected: "))
38+ b.WriteString(cpuInfo.AMDZenGen.Label)
39+ b.WriteString("\n")
40+ }
41+ if cpuInfo.ModelName != "" {
42+ b.WriteString(info.Render("CPU: "))
43+ b.WriteString(cpuInfo.ModelName)
44+ b.WriteString("\n")
45+ }
46+
47+ b.WriteString("\n")
48+ b.WriteString(info.Render("Note: Requires CPPC enabled in UEFI (AMD CBS > NBIO > SMU > CPPC)"))
49+ b.WriteString("\n\n")
50+
51+ options := am.Options()
52+ if len(options) == 0 {
53+ b.WriteString(info.Render("No AMD P-State modes available for this CPU."))
54+ return b.String()
55+ }
56+
57+ for i, option := range options {
58+ prefix := " "
59+ style := lipgloss.NewStyle()
60+ label := option.Label
61+ if option.Recommended {
62+ label = label + " (recommended)"
63+ }
64+ if i == am.SelectedIndex() {
65+ prefix = "> "
66+ style = active
67+ }
68+ b.WriteString(style.Render(prefix + label))
69+ b.WriteString("\n")
70+ }
71+
72+ b.WriteString("\n")
73+ b.WriteString(info.Render("Up/Down Move | Enter Confirm | Esc Back | Ctrl+C Quit"))
74+
75+ return b.String()
76+}
M internal/interfaces/tui/views/summary_view.go +8 -1
@@ -66,9 +66,16 @@ func RenderSummary(im *models.InstallationModelImpl) string {
6666 Render("You can now reboot into your new system!"))
6767 b.WriteString("\n\n")
6868
69+ if notice := im.GetNotice(); notice != "" {
70+ b.WriteString(lipgloss.NewStyle().
71+ Foreground(lipgloss.Color("3")).
72+ Render(notice))
73+ b.WriteString("\n\n")
74+ }
75+
6976 b.WriteString(lipgloss.NewStyle().
7077 Faint(true).
71- Render("Press 'q' to exit, then run: reboot"))
78+ Render("Press 'r' to unmount and reboot, or 'q' to exit"))
7279
7380 return b.String()
7481 }
@@ -66,9 +66,16 @@ func RenderSummary(im *models.InstallationModelImpl) string {
66 Render("You can now reboot into your new system!"))66 Render("You can now reboot into your new system!"))
67 b.WriteString("\n\n")67 b.WriteString("\n\n")
68 68
69+ if notice := im.GetNotice(); notice != "" {
70+ b.WriteString(lipgloss.NewStyle().
71+ Foreground(lipgloss.Color("3")).
72+ Render(notice))
73+ b.WriteString("\n\n")
74+ }
75+
69 b.WriteString(lipgloss.NewStyle().76 b.WriteString(lipgloss.NewStyle().
70 Faint(true).77 Faint(true).
71- Render("Press 'q' to exit, then run: reboot"))78+ Render("Press 'r' to unmount and reboot, or 'q' to exit"))
72 79
73 return b.String()80 return b.String()
74 }81 }
M internal/interfaces/tui/views/summary_view_test.go +5 -5
@@ -34,11 +34,11 @@ func TestRenderSummary(t *testing.T) {
3434
3535 // Verify key content is present
3636 checks := []string{
37- "Installation Complete!", // Title
38- "test-host", // Hostname
39- "testuser", // Username
40- "/dev/sda", // Disk
41- "Press 'q' to exit", // Instructions
37+ "Installation Complete!", // Title
38+ "test-host", // Hostname
39+ "testuser", // Username
40+ "/dev/sda", // Disk
41+ "Press 'r' to unmount and reboot", // Instructions
4242 }
4343
4444 for _, check := range checks {
@@ -34,11 +34,11 @@ func TestRenderSummary(t *testing.T) {
34 34
35 // Verify key content is present35 // Verify key content is present
36 checks := []string{36 checks := []string{
37- "Installation Complete!", // Title37+ "Installation Complete!", // Title
38- "test-host", // Hostname38+ "test-host", // Hostname
39- "testuser", // Username39+ "testuser", // Username
40- "/dev/sda", // Disk40+ "/dev/sda", // Disk
41- "Press 'q' to exit", // Instructions41+ "Press 'r' to unmount and reboot", // Instructions
42 }42 }
43 43
44 for _, check := range checks {44 for _, check := range checks {
M internal/system/cpu.go +51 -2
@@ -255,21 +255,70 @@ func GetRecommendedPStateMode(zenGen *AMDZenGen) AMDPStateMode {
255255
256256 // GetAvailablePStateModes returns available P-State modes for a given Zen generation
257257 func GetAvailablePStateModes(zenGen *AMDZenGen) []AMDPStateMode {
258+ hasActive := kernelVersionAtLeast(6, 3)
259+ hasGuided := kernelVersionAtLeast(6, 4)
260+
258261 switch zenGen.Generation {
259262 case "1", "1+":
260263 // Zen 1 and Zen+: No CPPC support, use acpi-cpufreq
261264 return []AMDPStateMode{}
262265 case "2":
263266 // Zen 2: Basic CPPC support, passive and active modes
264- return []AMDPStateMode{AMDPStateModePassive, AMDPStateModeActive}
267+ modes := []AMDPStateMode{}
268+ if hasActive {
269+ modes = append(modes, AMDPStateModeActive)
270+ }
271+ modes = append(modes, AMDPStateModePassive)
272+ return modes
265273 case "3", "3+", "4", "5":
266274 // Zen 3+: Full CPPC support for all modes
267- return []AMDPStateMode{AMDPStateModeActive, AMDPStateModeGuided, AMDPStateModePassive}
275+ modes := []AMDPStateMode{}
276+ if hasActive {
277+ modes = append(modes, AMDPStateModeActive)
278+ }
279+ if hasGuided {
280+ modes = append(modes, AMDPStateModeGuided)
281+ }
282+ modes = append(modes, AMDPStateModePassive)
283+ return modes
268284 default:
269285 return []AMDPStateMode{AMDPStateModePassive}
270286 }
271287 }
272288
289+func kernelVersionAtLeast(major, minor int) bool {
290+ curMajor, curMinor := detectKernelVersion()
291+ if curMajor == 0 && curMinor == 0 {
292+ return false
293+ }
294+ if curMajor > major {
295+ return true
296+ }
297+ if curMajor < major {
298+ return false
299+ }
300+ return curMinor >= minor
301+}
302+
303+func detectKernelVersion() (int, int) {
304+ data, err := os.ReadFile("/proc/sys/kernel/osrelease")
305+ if err != nil {
306+ return 0, 0
307+ }
308+
309+ version := strings.TrimSpace(string(data))
310+ parts := strings.Split(version, ".")
311+ if len(parts) < 2 {
312+ return 0, 0
313+ }
314+
315+ major, _ := strconv.Atoi(parts[0])
316+ minorPart := strings.Split(parts[1], "-")[0]
317+ minor, _ := strconv.Atoi(minorPart)
318+
319+ return major, minor
320+}
321+
273322 // GetPStateModeDescription returns human-readable description for P-State mode
274323 func GetPStateModeDescription(mode AMDPStateMode) string {
275324 switch mode {
@@ -255,21 +255,70 @@ func GetRecommendedPStateMode(zenGen *AMDZenGen) AMDPStateMode {
255 255
256 // GetAvailablePStateModes returns available P-State modes for a given Zen generation256 // GetAvailablePStateModes returns available P-State modes for a given Zen generation
257 func GetAvailablePStateModes(zenGen *AMDZenGen) []AMDPStateMode {257 func GetAvailablePStateModes(zenGen *AMDZenGen) []AMDPStateMode {
258+ hasActive := kernelVersionAtLeast(6, 3)
259+ hasGuided := kernelVersionAtLeast(6, 4)
260+
258 switch zenGen.Generation {261 switch zenGen.Generation {
259 case "1", "1+":262 case "1", "1+":
260 // Zen 1 and Zen+: No CPPC support, use acpi-cpufreq263 // Zen 1 and Zen+: No CPPC support, use acpi-cpufreq
261 return []AMDPStateMode{}264 return []AMDPStateMode{}
262 case "2":265 case "2":
263 // Zen 2: Basic CPPC support, passive and active modes266 // Zen 2: Basic CPPC support, passive and active modes
264- return []AMDPStateMode{AMDPStateModePassive, AMDPStateModeActive}267+ modes := []AMDPStateMode{}
268+ if hasActive {
269+ modes = append(modes, AMDPStateModeActive)
270+ }
271+ modes = append(modes, AMDPStateModePassive)
272+ return modes
265 case "3", "3+", "4", "5":273 case "3", "3+", "4", "5":
266 // Zen 3+: Full CPPC support for all modes274 // Zen 3+: Full CPPC support for all modes
267- return []AMDPStateMode{AMDPStateModeActive, AMDPStateModeGuided, AMDPStateModePassive}275+ modes := []AMDPStateMode{}
276+ if hasActive {
277+ modes = append(modes, AMDPStateModeActive)
278+ }
279+ if hasGuided {
280+ modes = append(modes, AMDPStateModeGuided)
281+ }
282+ modes = append(modes, AMDPStateModePassive)
283+ return modes
268 default:284 default:
269 return []AMDPStateMode{AMDPStateModePassive}285 return []AMDPStateMode{AMDPStateModePassive}
270 }286 }
271 }287 }
272 288
289+func kernelVersionAtLeast(major, minor int) bool {
290+ curMajor, curMinor := detectKernelVersion()
291+ if curMajor == 0 && curMinor == 0 {
292+ return false
293+ }
294+ if curMajor > major {
295+ return true
296+ }
297+ if curMajor < major {
298+ return false
299+ }
300+ return curMinor >= minor
301+}
302+
303+func detectKernelVersion() (int, int) {
304+ data, err := os.ReadFile("/proc/sys/kernel/osrelease")
305+ if err != nil {
306+ return 0, 0
307+ }
308+
309+ version := strings.TrimSpace(string(data))
310+ parts := strings.Split(version, ".")
311+ if len(parts) < 2 {
312+ return 0, 0
313+ }
314+
315+ major, _ := strconv.Atoi(parts[0])
316+ minorPart := strings.Split(parts[1], "-")[0]
317+ minor, _ := strconv.Atoi(minorPart)
318+
319+ return major, minor
320+}
321+
273 // GetPStateModeDescription returns human-readable description for P-State mode322 // GetPStateModeDescription returns human-readable description for P-State mode
274 func GetPStateModeDescription(mode AMDPStateMode) string {323 func GetPStateModeDescription(mode AMDPStateMode) string {
275 switch mode {324 switch mode {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close