Copies the live system to a local disk via unsquashfs — no debootstrap, no network required. Supports UEFI (GPT+EFI) and BIOS (MBR) layouts. ISO: - Add squashfs-tools, parted, grub-pc, grub-efi-amd64 to package list - New overlay script bee-install: partitions, formats, unsquashfs, writes fstab, runs grub-install+update-grub in chroot Go TUI: - Settings → Tools submenu (Install to disk, Check tools) - Disk picker screen: lists non-USB, non-boot disks via lsblk - Confirm screen warns about data loss - Runs with live progress tail of /tmp/bee-install.log - platform/install.go: ListInstallDisks, InstallToDisk, findLiveBootDevice Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
363 lines
9.0 KiB
Go
363 lines
9.0 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
if m.busy {
|
|
if msg.String() == "ctrl+c" {
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|
|
next, cmd := m.updateKey(msg)
|
|
nextModel := next.(model)
|
|
if shouldRefreshSnapshot(m, nextModel) {
|
|
return nextModel, tea.Batch(cmd, nextModel.refreshSnapshotCmd())
|
|
}
|
|
return nextModel, cmd
|
|
case satProgressMsg:
|
|
if m.busy && m.progressPrefix != "" {
|
|
if len(msg.lines) > 0 {
|
|
m.progressLines = msg.lines
|
|
}
|
|
return m, pollSATProgress(m.progressPrefix, m.progressSince)
|
|
}
|
|
if m.busy && m.installCancel != nil {
|
|
if len(msg.lines) > 0 {
|
|
m.progressLines = msg.lines
|
|
}
|
|
return m, pollInstallProgress(DefaultInstallLogFile)
|
|
}
|
|
return m, nil
|
|
case snapshotMsg:
|
|
m.banner = msg.banner
|
|
m.panel = msg.panel
|
|
return m, nil
|
|
case resultMsg:
|
|
m.busy = false
|
|
m.busyTitle = ""
|
|
m.progressLines = nil
|
|
m.progressPrefix = ""
|
|
m.title = msg.title
|
|
if msg.err != nil {
|
|
body := strings.TrimSpace(msg.body)
|
|
if body == "" {
|
|
m.body = fmt.Sprintf("ERROR: %v", msg.err)
|
|
} else {
|
|
m.body = fmt.Sprintf("%s\n\nERROR: %v", body, msg.err)
|
|
}
|
|
} else {
|
|
m.body = msg.body
|
|
}
|
|
m.pendingAction = actionNone
|
|
if msg.back != "" {
|
|
m.prevScreen = msg.back
|
|
} else {
|
|
m.prevScreen = m.screen
|
|
}
|
|
m.screen = screenOutput
|
|
m.cursor = 0
|
|
return m, m.refreshSnapshotCmd()
|
|
case servicesMsg:
|
|
m.busy = false
|
|
m.busyTitle = ""
|
|
if msg.err != nil {
|
|
m.title = "Services"
|
|
m.body = msg.err.Error()
|
|
m.prevScreen = screenSettings
|
|
m.screen = screenOutput
|
|
return m, m.refreshSnapshotCmd()
|
|
}
|
|
m.services = msg.services
|
|
m.screen = screenServices
|
|
m.cursor = 0
|
|
return m, m.refreshSnapshotCmd()
|
|
case interfacesMsg:
|
|
m.busy = false
|
|
m.busyTitle = ""
|
|
if msg.err != nil {
|
|
m.title = "interfaces"
|
|
m.body = msg.err.Error()
|
|
m.prevScreen = screenNetwork
|
|
m.screen = screenOutput
|
|
return m, m.refreshSnapshotCmd()
|
|
}
|
|
m.interfaces = msg.ifaces
|
|
m.screen = screenInterfacePick
|
|
m.cursor = 0
|
|
return m, m.refreshSnapshotCmd()
|
|
case exportTargetsMsg:
|
|
m.busy = false
|
|
m.busyTitle = ""
|
|
if msg.err != nil {
|
|
m.title = "export"
|
|
m.body = msg.err.Error()
|
|
m.prevScreen = screenMain
|
|
m.screen = screenOutput
|
|
return m, m.refreshSnapshotCmd()
|
|
}
|
|
if len(msg.targets) == 0 {
|
|
m.title = "Export support bundle"
|
|
m.body = "No writable removable filesystems found.\n\nRead-only or boot media are hidden from this list."
|
|
m.prevScreen = screenMain
|
|
m.screen = screenOutput
|
|
return m, m.refreshSnapshotCmd()
|
|
}
|
|
m.targets = msg.targets
|
|
m.screen = screenExportTargets
|
|
m.cursor = 0
|
|
return m, m.refreshSnapshotCmd()
|
|
case installDisksMsg:
|
|
m.busy = false
|
|
m.busyTitle = ""
|
|
if msg.err != nil {
|
|
m.title = "Install to disk"
|
|
m.body = msg.err.Error()
|
|
m.prevScreen = screenTools
|
|
m.screen = screenOutput
|
|
return m, m.refreshSnapshotCmd()
|
|
}
|
|
if len(msg.disks) == 0 {
|
|
m.title = "Install to disk"
|
|
m.body = "No suitable disks found.\n\nOnly non-USB, non-boot disks are shown.\nAttach a target disk and try again."
|
|
m.prevScreen = screenTools
|
|
m.screen = screenOutput
|
|
return m, m.refreshSnapshotCmd()
|
|
}
|
|
m.installDisks = msg.disks
|
|
m.screen = screenInstallDiskPick
|
|
m.cursor = 0
|
|
return m, m.refreshSnapshotCmd()
|
|
case installDoneMsg:
|
|
if m.installCancel != nil {
|
|
m.installCancel()
|
|
m.installCancel = nil
|
|
}
|
|
m.busy = false
|
|
m.busyTitle = ""
|
|
m.progressLines = nil
|
|
m.prevScreen = screenTools
|
|
m.screen = screenOutput
|
|
m.title = "Install to disk"
|
|
if msg.err != nil {
|
|
m.body = fmt.Sprintf("Installation FAILED.\n\nLog: %s\n\nERROR: %v", DefaultInstallLogFile, msg.err)
|
|
} else {
|
|
m.body = fmt.Sprintf("Installation complete.\n\nRemove the ISO and reboot to start the installed system.\n\nLog: %s", DefaultInstallLogFile)
|
|
}
|
|
return m, m.refreshSnapshotCmd()
|
|
case nvtopClosedMsg:
|
|
return m, nil
|
|
case gpuStressDoneMsg:
|
|
if m.gpuStressAborted {
|
|
return m, nil
|
|
}
|
|
if m.gpuStressCancel != nil {
|
|
m.gpuStressCancel()
|
|
m.gpuStressCancel = nil
|
|
}
|
|
m.prevScreen = screenBurnInTests
|
|
m.screen = screenOutput
|
|
m.title = msg.title
|
|
if msg.err != nil {
|
|
body := strings.TrimSpace(msg.body)
|
|
if body == "" {
|
|
m.body = fmt.Sprintf("ERROR: %v", msg.err)
|
|
} else {
|
|
m.body = fmt.Sprintf("%s\n\nERROR: %v", body, msg.err)
|
|
}
|
|
} else {
|
|
m.body = msg.body
|
|
}
|
|
return m, m.refreshSnapshotCmd()
|
|
case gpuLiveTickMsg:
|
|
if m.screen == screenGPUStressRunning {
|
|
if len(msg.rows) > 0 {
|
|
elapsed := time.Since(m.gpuLiveStart).Seconds()
|
|
for i := range msg.rows {
|
|
msg.rows[i].ElapsedSec = elapsed
|
|
}
|
|
m.gpuLiveRows = append(m.gpuLiveRows, msg.rows...)
|
|
n := max(1, len(msg.indices))
|
|
if len(m.gpuLiveRows) > 60*n {
|
|
m.gpuLiveRows = m.gpuLiveRows[len(m.gpuLiveRows)-60*n:]
|
|
}
|
|
}
|
|
return m, pollGPULive(msg.indices)
|
|
}
|
|
return m, nil
|
|
case nvidiaSATDoneMsg:
|
|
if m.nvidiaSATAborted {
|
|
return m, nil
|
|
}
|
|
if m.nvidiaSATCancel != nil {
|
|
m.nvidiaSATCancel()
|
|
m.nvidiaSATCancel = nil
|
|
}
|
|
m.prevScreen = screenHealthCheck
|
|
m.screen = screenOutput
|
|
m.title = msg.title
|
|
if msg.err != nil {
|
|
body := strings.TrimSpace(msg.body)
|
|
if body == "" {
|
|
m.body = fmt.Sprintf("ERROR: %v", msg.err)
|
|
} else {
|
|
m.body = fmt.Sprintf("%s\n\nERROR: %v", body, msg.err)
|
|
}
|
|
} else {
|
|
m.body = msg.body
|
|
}
|
|
return m, m.refreshSnapshotCmd()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch m.screen {
|
|
case screenMain:
|
|
return m.updateMain(msg)
|
|
case screenHealthCheck:
|
|
return m.updateHealthCheck(msg)
|
|
case screenBurnInTests:
|
|
return m.updateBurnInTests(msg)
|
|
case screenSettings:
|
|
return m.updateMenu(msg, len(m.settingsMenu), m.handleSettingsMenu)
|
|
case screenNetwork:
|
|
return m.updateMenu(msg, len(m.networkMenu), m.handleNetworkMenu)
|
|
case screenServices:
|
|
return m.updateMenu(msg, len(m.services), m.handleServicesMenu)
|
|
case screenServiceAction:
|
|
return m.updateMenu(msg, len(m.serviceMenu), m.handleServiceActionMenu)
|
|
case screenNvidiaSATSetup:
|
|
return m.updateNvidiaSATSetup(msg)
|
|
case screenNvidiaSATRunning:
|
|
return m.updateNvidiaSATRunning(msg)
|
|
case screenGPUStressRunning:
|
|
return m.updateGPUStressRunning(msg)
|
|
case screenExportTargets:
|
|
return m.updateMenu(msg, len(m.targets), m.handleExportTargetsMenu)
|
|
case screenInterfacePick:
|
|
return m.updateMenu(msg, len(m.interfaces), m.handleInterfacePickMenu)
|
|
case screenTools:
|
|
return m.updateMenu(msg, len(m.toolsMenu), m.handleToolsMenu)
|
|
case screenInstallDiskPick:
|
|
return m.updateMenu(msg, len(m.installDisks), m.handleInstallDiskPickMenu)
|
|
case screenOutput:
|
|
switch msg.String() {
|
|
case "esc", "enter", "q":
|
|
m.screen = m.prevScreen
|
|
m.body = ""
|
|
m.title = ""
|
|
m.pendingAction = actionNone
|
|
return m, nil
|
|
case "ctrl+c":
|
|
return m, tea.Quit
|
|
}
|
|
case screenStaticForm:
|
|
return m.updateStaticForm(msg)
|
|
case screenConfirm:
|
|
return m.updateConfirm(msg)
|
|
}
|
|
if msg.String() == "ctrl+c" {
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// updateMain handles keys on the main (two-column) screen.
|
|
func (m model) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.panelFocus {
|
|
return m.updateMainPanel(msg)
|
|
}
|
|
// Switch focus to right panel.
|
|
if (msg.String() == "tab" || msg.String() == "right" || msg.String() == "l") && len(m.panel.Rows) > 0 {
|
|
m.panelFocus = true
|
|
return m, nil
|
|
}
|
|
return m.updateMenu(msg, len(m.mainMenu), m.handleMainMenu)
|
|
}
|
|
|
|
// updateMainPanel handles keys when right panel has focus.
|
|
func (m model) updateMainPanel(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.panelCursor > 0 {
|
|
m.panelCursor--
|
|
}
|
|
case "down", "j":
|
|
if m.panelCursor < len(m.panel.Rows)-1 {
|
|
m.panelCursor++
|
|
}
|
|
case "enter":
|
|
if m.panelCursor < len(m.panel.Rows) {
|
|
key := m.panel.Rows[m.panelCursor].Key
|
|
m.busy = true
|
|
m.busyTitle = key
|
|
return m, func() tea.Msg {
|
|
r := m.app.ComponentDetailResult(key)
|
|
return resultMsg{title: r.Title, body: r.Body, back: screenMain}
|
|
}
|
|
}
|
|
case "tab", "left", "h", "esc":
|
|
m.panelFocus = false
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) updateMenu(msg tea.KeyMsg, size int, onEnter func() (tea.Model, tea.Cmd)) (tea.Model, tea.Cmd) {
|
|
if size == 0 {
|
|
size = 1
|
|
}
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
case "down", "j":
|
|
if m.cursor < size-1 {
|
|
m.cursor++
|
|
}
|
|
case "enter":
|
|
return onEnter()
|
|
case "esc":
|
|
switch m.screen {
|
|
case screenNetwork, screenServices:
|
|
m.screen = screenSettings
|
|
m.cursor = 0
|
|
case screenSettings:
|
|
m.screen = screenMain
|
|
m.cursor = 0
|
|
case screenServiceAction:
|
|
m.screen = screenServices
|
|
m.cursor = 0
|
|
case screenExportTargets:
|
|
m.screen = screenMain
|
|
m.cursor = 0
|
|
case screenInterfacePick:
|
|
m.screen = screenNetwork
|
|
m.cursor = 0
|
|
case screenTools:
|
|
m.screen = screenSettings
|
|
m.cursor = 0
|
|
case screenInstallDiskPick:
|
|
m.screen = screenTools
|
|
m.cursor = 0
|
|
}
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
}
|
|
return m, nil
|
|
}
|