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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 8 | 8 | ||
| 9 | 9 | // InstallBootloaderCommand contains data for bootloader installation | |
| 10 | 10 | 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 | |
| 20 | 21 | } | |
| @@ -8,13 +8,14 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | // InstallBootloaderCommand contains data for bootloader installation | 9 | // InstallBootloaderCommand contains data for bootloader installation | ||
| 10 | type InstallBootloaderCommand struct { | 10 | type InstallBootloaderCommand struct { | ||
| 11 | - | MountPoint string // Root mount point | 11 | + | MountPoint string // Root mount point |
| 12 | - | BootloaderType bootloader.BootloaderType // BootloaderTypeLimine | 12 | + | 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 name | 14 | + | Branding string // Bootloader display name |
| 15 | - | KernelVariant packages.KernelVariant // KernelStable, KernelZen, KernelLTS, KernelHardened, KernelCachyOS | 15 | + | KernelVariant packages.KernelVariant // KernelStable, KernelZen, KernelLTS, KernelHardened, KernelCachyOS |
| 16 | - | RootPartition string // Root partition device path | 16 | + | RootPartition string // Root partition device path |
| 17 | - | EncryptionType disk.EncryptionType // EncryptionTypeNone, EncryptionTypeLUKS, EncryptionTypeLUKSLVM | 17 | + | EncryptionType disk.EncryptionType // EncryptionTypeNone, EncryptionTypeLUKS, EncryptionTypeLUKSLVM |
| 18 | - | EFIPartition string // EFI partition device path | 18 | + | EFIPartition string // EFI partition device path |
| 19 | - | TargetDisk string // Target disk device path | 19 | + | 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 | |||
| 132 | 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 | 147 | return nil | |
| 136 | 148 | } | |
| 137 | 149 | ||
| @@ -150,7 +162,10 @@ func (h *BootloaderHandler) configureLimine(ctx context.Context, cmd commands.In | |||
| 150 | 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 | 170 | templatePath, err := h.resolveLimineTemplate() | |
| 156 | 171 | if err != nil { | |
| @@ -186,7 +201,7 @@ func (h *BootloaderHandler) createBootEntry(ctx context.Context, targetDisk, efi | |||
| 186 | 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 | 205 | h.logger.Error("Failed to create EFI boot entry", "error", err) | |
| 191 | 206 | return fmt.Errorf("failed to create EFI boot entry: %w", err) | |
| 192 | 207 | } | |
| @@ -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 nil | 147 | 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 ( | |||
| 146 | 146 | // UEFI boot entry | |
| 147 | 147 | const ( | |
| 148 | 148 | UEFIBootLabel = "ArchUp" | |
| 149 | - | UEFIBootLoader = "\\\\EFI\\\\limine\\\\BOOTX64.EFI" | |
| 149 | + | UEFIBootLoader = "\\EFI\\limine\\BOOTX64.EFI" | |
| 150 | 150 | ) | |
| 151 | 151 | ||
| 152 | 152 | // Repository paths | |
| @@ -146,7 +146,7 @@ const ( | |||||
| 146 | // UEFI boot entry | 146 | // 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 paths | 152 | // Repository paths | ||
M internal/interfaces/tui/app.go +103 -2
| @@ -2,6 +2,7 @@ package tui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "os" | |
| 5 | 6 | ||
| 6 | 7 | apphandlers "github.com/bnema/archup/internal/application/handlers" | |
| 7 | 8 | "github.com/bnema/archup/internal/application/services" | |
| @@ -29,6 +30,7 @@ type App struct { | |||
| 29 | 30 | // UI models (using concrete types for compatibility with views/handlers) | |
| 30 | 31 | formModel *models.FormModelImpl | |
| 31 | 32 | diskModel *models.DiskModelImpl | |
| 33 | + | amdPstateModel *models.AMDPStateModelImpl | |
| 32 | 34 | gpuModel *models.GPUModelImpl | |
| 33 | 35 | installationModel *models.InstallationModelImpl | |
| 34 | 36 | progressModel *models.ProgressModelImpl | |
| @@ -51,6 +53,7 @@ type Screen string | |||
| 51 | 53 | const ( | |
| 52 | 54 | ScreenForm Screen = "form" | |
| 53 | 55 | ScreenDisk Screen = "disk" | |
| 56 | + | ScreenAMDPState Screen = "amd-pstate" | |
| 54 | 57 | ScreenGPU Screen = "gpu" | |
| 55 | 58 | ScreenInstalling Screen = "installing" | |
| 56 | 59 | ScreenProgress Screen = "progress" | |
| @@ -74,6 +77,7 @@ func NewApp( | |||
| 74 | 77 | logger: logger, | |
| 75 | 78 | formModel: models.NewFormModel(), | |
| 76 | 79 | diskModel: models.NewDiskModel(), | |
| 80 | + | amdPstateModel: models.NewAMDPStateModel(), | |
| 77 | 81 | gpuModel: models.NewGPUModel(), | |
| 78 | 82 | installationModel: models.NewInstallationModel(), | |
| 79 | 83 | progressModel: models.NewProgressModel(), | |
| @@ -125,6 +129,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 125 | 129 | return a.handleInstallationError(msg) | |
| 126 | 130 | case handlers.InstallationCompleteMsg: | |
| 127 | 131 | return a.handleInstallationComplete(msg) | |
| 132 | + | case CPUDetectedMsg: | |
| 133 | + | return a.handleCPUDetected(msg) | |
| 128 | 134 | case GPUDetectedMsg: | |
| 129 | 135 | return a.handleGPUDetected(msg) | |
| 130 | 136 | case TimezoneDetectedMsg: | |
| @@ -143,6 +149,8 @@ func (a *App) View() string { | |||
| 143 | 149 | return views.RenderForm(a.formModel) | |
| 144 | 150 | case ScreenDisk: | |
| 145 | 151 | return views.RenderDiskSelection(a.diskModel) | |
| 152 | + | case ScreenAMDPState: | |
| 153 | + | return views.RenderAMDPStateSelection(a.amdPstateModel) | |
| 146 | 154 | case ScreenGPU: | |
| 147 | 155 | return views.RenderGPUSelection(a.gpuModel) | |
| 148 | 156 | case ScreenInstalling, ScreenProgress: | |
| @@ -163,6 +171,8 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |||
| 163 | 171 | return a.handleFormInput(msg) | |
| 164 | 172 | case ScreenDisk: | |
| 165 | 173 | return a.handleDiskInput(msg) | |
| 174 | + | case ScreenAMDPState: | |
| 175 | + | return a.handleAMDPStateInput(msg) | |
| 166 | 176 | case ScreenGPU: | |
| 167 | 177 | return a.handleGPUInput(msg) | |
| 168 | 178 | case ScreenProgress: | |
| @@ -171,8 +181,12 @@ func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |||
| 171 | 181 | return a.handleCancel() | |
| 172 | 182 | } | |
| 173 | 183 | case ScreenSummary: | |
| 174 | - | if msg.String() == "q" || msg.String() == "ctrl+c" { | |
| 184 | + | switch msg.String() { | |
| 185 | + | case "q", "ctrl+c": | |
| 175 | 186 | return a, tea.Quit | |
| 187 | + | case "r": | |
| 188 | + | a.unmountAndReboot() | |
| 189 | + | return a, nil | |
| 176 | 190 | } | |
| 177 | 191 | case ScreenError: | |
| 178 | 192 | if msg.String() == "q" || msg.String() == "ctrl+c" { | |
| @@ -252,6 +266,28 @@ func (a *App) handleCancel() (tea.Model, tea.Cmd) { | |||
| 252 | 266 | 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 | 291 | func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |
| 256 | 292 | switch msg.String() { | |
| 257 | 293 | case "ctrl+c": | |
| @@ -273,6 +309,56 @@ func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |||
| 273 | 309 | 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 | 362 | // validateForm validates the form data using domain validation | |
| 277 | 363 | func (a *App) validateForm() error { | |
| 278 | 364 | formData := a.formModel.GetData() | |
| @@ -317,6 +403,21 @@ func (a *App) startInstallation() (tea.Model, tea.Cmd) { | |||
| 317 | 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 | 421 | func (a *App) startGPUSelection() (tea.Model, tea.Cmd) { | |
| 321 | 422 | a.currentScreen = ScreenGPU | |
| 322 | 423 | return a, a.detectGPUCmd() | |
| @@ -371,7 +472,7 @@ func (a *App) handleDiskInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |||
| 371 | 472 | } | |
| 372 | 473 | // Update stored form data directly | |
| 373 | 474 | a.formData.TargetDisk = selected.Path | |
| 374 | - | return a.startGPUSelection() | |
| 475 | + | return a.startAMDPStateSelection() | |
| 375 | 476 | } | |
| 376 | 477 | return a, nil | |
| 377 | 478 | } | |
| @@ -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.FormModelImpl | 31 | formModel *models.FormModelImpl | ||
| 31 | diskModel *models.DiskModelImpl | 32 | diskModel *models.DiskModelImpl | ||
| 33 | + | amdPstateModel *models.AMDPStateModelImpl | |||
| 32 | gpuModel *models.GPUModelImpl | 34 | gpuModel *models.GPUModelImpl | ||
| 33 | installationModel *models.InstallationModelImpl | 35 | installationModel *models.InstallationModelImpl | ||
| 34 | progressModel *models.ProgressModelImpl | 36 | 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.Quit | 186 | 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, nil | 266 | 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, nil | 309 | 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 validation | 362 | // 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 = ScreenGPU | 422 | 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 directly | 473 | // Update stored form data directly | ||
| 373 | a.formData.TargetDisk = selected.Path | 474 | a.formData.TargetDisk = selected.Path | ||
| 374 | - | return a.startGPUSelection() | 475 | + | return a.startAMDPStateSelection() |
| 375 | } | 476 | } | ||
| 376 | return a, nil | 477 | 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 | |||
| 53 | 53 | func HandleInstallationComplete(app AppContext, msg interface{}, installModel *models.InstallationModelImpl) (*models.InstallationModelImpl, tea.Cmd) { | |
| 54 | 54 | app.GetLogger().Info("Installation completed successfully") | |
| 55 | 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 | 60 | return installModel, nil | |
| 57 | 61 | } | |
| 58 | 62 | ||
| @@ -168,15 +172,16 @@ func CreateInstallationCommand(app AppContext, formData models.FormData) tea.Cmd | |||
| 168 | 172 | ||
| 169 | 173 | // Phase 5: Bootloader | |
| 170 | 174 | 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, | |
| 180 | 185 | } | |
| 181 | 186 | if _, err := svc.RunBootloaderSetup(ctx, bootCmd); err != nil { | |
| 182 | 187 | 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, nil | 60 | 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: Bootloader | 173 | // 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 { | |||
| 23 | 23 | 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 | 32 | // TimezoneDetectedMsg is sent when timezone detection completes | |
| 27 | 33 | type TimezoneDetectedMsg struct { | |
| 28 | 34 | Timezone string | |
| @@ -23,6 +23,12 @@ type GPUDetectedMsg struct { | |||||
| 23 | GPU *system.GPU | 23 | 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 completes | 32 | // TimezoneDetectedMsg is sent when timezone detection completes | ||
| 27 | type TimezoneDetectedMsg struct { | 33 | type TimezoneDetectedMsg struct { | ||
| 28 | Timezone string | 34 | 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) { | |||
| 22 | 22 | ||
| 23 | 23 | // FormData contains the data from the form | |
| 24 | 24 | 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 | |
| 40 | 42 | } | |
| 41 | 43 | ||
| 42 | 44 | // FormModelImpl implements FormModel interface | |
| @@ -296,16 +298,18 @@ func (fm *FormModelImpl) extractData() { | |||
| 296 | 298 | // Use fm.fields slice values, not the original fields (which are copies) | |
| 297 | 299 | // Field order: hostname(0), username(1), email(2), password(3), timezone(4), locale(5), keymap(6) | |
| 298 | 300 | 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(), | |
| 309 | 313 | } | |
| 310 | 314 | } | |
| 311 | 315 | ||
| @@ -22,21 +22,23 @@ func (bm *BaseModel) SetSize(width, height int) { | |||||
| 22 | 22 | ||||
| 23 | // FormData contains the data from the form | 23 | // FormData contains the data from the form | ||
| 24 | type FormData struct { | 24 | type FormData struct { | ||
| 25 | - | Hostname string | 25 | + | Hostname string |
| 26 | - | Username string | 26 | + | Username string |
| 27 | - | UserEmail string | 27 | + | UserEmail string |
| 28 | - | UserPassword string | 28 | + | UserPassword string |
| 29 | - | RootPassword string | 29 | + | RootPassword string |
| 30 | - | TargetDisk string | 30 | + | TargetDisk string |
| 31 | - | EncryptionType string | 31 | + | EncryptionType string |
| 32 | - | GPUVendor string | 32 | + | AMDPState string |
| 33 | - | GPUDrivers []string | 33 | + | KernelParamsExtra string |
| 34 | - | Timezone string | 34 | + | GPUVendor string |
| 35 | - | Locale string | 35 | + | GPUDrivers []string |
| 36 | - | Keymap string | 36 | + | Timezone string |
| 37 | - | KernelVariant string | 37 | + | Locale string |
| 38 | - | AURHelper string | 38 | + | Keymap string |
| 39 | - | Microcode bool | 39 | + | KernelVariant string |
| 40 | + | AURHelper string | |||
| 41 | + | Microcode bool | |||
| 40 | } | 42 | } | ||
| 41 | 43 | ||||
| 42 | // FormModelImpl implements FormModel interface | 44 | // 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 config | 303 | + | 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 sudoer | 305 | + | 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 @@ | |||
| 1 | 1 | 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 | 9 | // GPUOption represents a selectable GPU driver option. | |
| 6 | 10 | type GPUOption struct { | |
| @@ -35,7 +39,7 @@ func NewGPUModel() *GPUModelImpl { | |||
| 35 | 39 | Drivers: []string{"nvidia-open", "nvidia-utils", "libva-nvidia-driver"}, | |
| 36 | 40 | }, | |
| 37 | 41 | { | |
| 38 | - | Label: "Skip GPU drivers", | |
| 42 | + | Label: "None / VM", | |
| 39 | 43 | Vendor: system.GPUVendorUnknown, | |
| 40 | 44 | Drivers: []string{}, | |
| 41 | 45 | }, | |
| @@ -53,6 +57,10 @@ func (gm *GPUModelImpl) SetDetectedGPU(gpu *system.GPU) { | |||
| 53 | 57 | if gpu == nil { | |
| 54 | 58 | return | |
| 55 | 59 | } | |
| 60 | + | if isVirtualGPUModel(gpu.Model()) || gpu.Vendor() == system.GPUVendorUnknown { | |
| 61 | + | gm.selectUnknown() | |
| 62 | + | return | |
| 63 | + | } | |
| 56 | 64 | for i, option := range gm.options { | |
| 57 | 65 | if option.Vendor == gpu.Vendor() { | |
| 58 | 66 | 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 | 101 | // DetectedGPU returns the detected GPU. | |
| 65 | 102 | func (gm *GPUModelImpl) DetectedGPU() *system.GPU { | |
| 66 | 103 | return gm.detected | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package models | 1 | 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 | return | 58 | 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 = i | 66 | 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.detected | 103 | return gm.detected | ||
M internal/interfaces/tui/models/installation.go +11 -1
| @@ -16,6 +16,7 @@ type InstallationModelImpl struct { | |||
| 16 | 16 | ||
| 17 | 17 | status *dto.InstallationStatus | |
| 18 | 18 | err string | |
| 19 | + | notice string | |
| 19 | 20 | complete bool | |
| 20 | 21 | } | |
| 21 | 22 | ||
| @@ -24,6 +25,7 @@ func NewInstallationModel() *InstallationModelImpl { | |||
| 24 | 25 | return &InstallationModelImpl{ | |
| 25 | 26 | status: &dto.InstallationStatus{}, | |
| 26 | 27 | err: "", | |
| 28 | + | notice: "", | |
| 27 | 29 | complete: false, | |
| 28 | 30 | } | |
| 29 | 31 | } | |
| @@ -87,6 +89,10 @@ func (im *InstallationModelImpl) SetError(message string) { | |||
| 87 | 89 | im.err = message | |
| 88 | 90 | } | |
| 89 | 91 | ||
| 92 | + | func (im *InstallationModelImpl) SetNotice(message string) { | |
| 93 | + | im.notice = message | |
| 94 | + | } | |
| 95 | + | ||
| 90 | 96 | // SetComplete marks installation as complete | |
| 91 | 97 | func (im *InstallationModelImpl) SetComplete() { | |
| 92 | 98 | im.complete = true | |
| @@ -102,6 +108,10 @@ func (im *InstallationModelImpl) GetError() string { | |||
| 102 | 108 | return im.err | |
| 103 | 109 | } | |
| 104 | 110 | ||
| 111 | + | func (im *InstallationModelImpl) GetNotice() string { | |
| 112 | + | return im.notice | |
| 113 | + | } | |
| 114 | + | ||
| 105 | 115 | // IsComplete returns whether the installation is complete | |
| 106 | 116 | func (im *InstallationModelImpl) IsComplete() bool { | |
| 107 | 117 | return im.complete | |
| @@ -195,7 +205,7 @@ func (im *InstallationModelImpl) renderSummary() string { | |||
| 195 | 205 | b.WriteString("\n") | |
| 196 | 206 | b.WriteString(lipgloss.NewStyle(). | |
| 197 | 207 | Faint(true). | |
| 198 | - | Render("Press 'q' to exit")) | |
| 208 | + | Render("Press 'r' to unmount and reboot, or 'q' to exit")) | |
| 199 | 209 | ||
| 200 | 210 | return b.String() | |
| 201 | 211 | } | |
| @@ -16,6 +16,7 @@ type InstallationModelImpl struct { | |||||
| 16 | 16 | ||||
| 17 | status *dto.InstallationStatus | 17 | status *dto.InstallationStatus | ||
| 18 | err string | 18 | err string | ||
| 19 | + | notice string | |||
| 19 | complete bool | 20 | 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 = message | 89 | 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 complete | 96 | // SetComplete marks installation as complete | ||
| 91 | func (im *InstallationModelImpl) SetComplete() { | 97 | func (im *InstallationModelImpl) SetComplete() { | ||
| 92 | im.complete = true | 98 | im.complete = true | ||
| @@ -102,6 +108,10 @@ func (im *InstallationModelImpl) GetError() string { | |||||
| 102 | return im.err | 108 | 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 complete | 115 | // IsComplete returns whether the installation is complete | ||
| 106 | func (im *InstallationModelImpl) IsComplete() bool { | 116 | func (im *InstallationModelImpl) IsComplete() bool { | ||
| 107 | return im.complete | 117 | 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 { | |||
| 66 | 66 | Render("You can now reboot into your new system!")) | |
| 67 | 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 | 76 | b.WriteString(lipgloss.NewStyle(). | |
| 70 | 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 | 80 | return b.String() | |
| 74 | 81 | } | |
| @@ -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) { | |||
| 34 | 34 | ||
| 35 | 35 | // Verify key content is present | |
| 36 | 36 | 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 | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | for _, check := range checks { | |
| @@ -34,11 +34,11 @@ func TestRenderSummary(t *testing.T) { | |||||
| 34 | 34 | ||||
| 35 | // Verify key content is present | 35 | // Verify key content is present | ||
| 36 | checks := []string{ | 36 | checks := []string{ | ||
| 37 | - | "Installation Complete!", // Title | 37 | + | "Installation Complete!", // Title |
| 38 | - | "test-host", // Hostname | 38 | + | "test-host", // Hostname |
| 39 | - | "testuser", // Username | 39 | + | "testuser", // Username |
| 40 | - | "/dev/sda", // Disk | 40 | + | "/dev/sda", // Disk |
| 41 | - | "Press 'q' to exit", // Instructions | 41 | + | "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 { | |||
| 255 | 255 | ||
| 256 | 256 | // GetAvailablePStateModes returns available P-State modes for a given Zen generation | |
| 257 | 257 | func GetAvailablePStateModes(zenGen *AMDZenGen) []AMDPStateMode { | |
| 258 | + | hasActive := kernelVersionAtLeast(6, 3) | |
| 259 | + | hasGuided := kernelVersionAtLeast(6, 4) | |
| 260 | + | ||
| 258 | 261 | switch zenGen.Generation { | |
| 259 | 262 | case "1", "1+": | |
| 260 | 263 | // Zen 1 and Zen+: No CPPC support, use acpi-cpufreq | |
| 261 | 264 | return []AMDPStateMode{} | |
| 262 | 265 | case "2": | |
| 263 | 266 | // 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 | 273 | case "3", "3+", "4", "5": | |
| 266 | 274 | // 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 | 284 | default: | |
| 269 | 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 | 322 | // GetPStateModeDescription returns human-readable description for P-State mode | |
| 274 | 323 | func GetPStateModeDescription(mode AMDPStateMode) string { | |
| 275 | 324 | switch mode { | |
| @@ -255,21 +255,70 @@ func GetRecommendedPStateMode(zenGen *AMDZenGen) AMDPStateMode { | |||||
| 255 | 255 | ||||
| 256 | // GetAvailablePStateModes returns available P-State modes for a given Zen generation | 256 | // 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-cpufreq | 263 | // 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 modes | 266 | // 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 modes | 274 | // 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 mode | 322 | // 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 { | ||