diff --git a/scripts/01-cosmic-setup.sh b/scripts/01-cosmic-setup.sh index 77fd9c1..da411d6 100755 --- a/scripts/01-cosmic-setup.sh +++ b/scripts/01-cosmic-setup.sh @@ -1,4 +1,26 @@ #!/bin/bash set -e +MOUNT_POINT="/mnt" + echo "Setting up Cosmic Desktop..." + +echo "Configuring display manager..." +systemctl enable cosmic-greeter + +echo "Installing additional Cosmic components..." +pacman -S --noconfirm --needed \ + cosmic-files \ + cosmic-edit \ + cosmic-term \ + cosmic-store \ + cosmic-settings + +echo "Setting up user environment..." +if [ -n "$SUDO_USER" ]; then + USER_HOME="/home/$SUDO_USER" + mkdir -p "$USER_HOME/.config" + chown -R "$SUDO_USER:$SUDO_USER" "$USER_HOME/.config" +fi + +echo "Cosmic Desktop setup complete!" diff --git a/scripts/02-hardening.sh b/scripts/02-hardening.sh index dc6c909..3b14023 100755 --- a/scripts/02-hardening.sh +++ b/scripts/02-hardening.sh @@ -2,3 +2,36 @@ set -e echo "Applying system hardening..." + +echo "Configuring kernel parameters..." +cat > /etc/sysctl.d/99-hardening.conf << 'EOF' +kernel.dmesg_restrict = 1 +kernel.kptr_restrict = 2 +kernel.unprivileged_bpf_disabled = 1 +net.core.bpf_jit_harden = 2 +kernel.yama.ptrace_scope = 2 +net.ipv4.conf.all.rp_filter = 1 +net.ipv4.conf.default.rp_filter = 1 +net.ipv4.conf.all.accept_redirects = 0 +net.ipv4.conf.default.accept_redirects = 0 +net.ipv6.conf.all.accept_redirects = 0 +net.ipv6.conf.default.accept_redirects = 0 +net.ipv4.conf.all.send_redirects = 0 +net.ipv4.conf.default.send_redirects = 0 +net.ipv4.icmp_echo_ignore_all = 1 +EOF + +echo "Configuring firewall..." +pacman -S --noconfirm --needed ufw +systemctl enable ufw +ufw default deny incoming +ufw default allow outgoing +ufw enable + +echo "Setting secure umask..." +echo "umask 077" >> /etc/profile + +echo "Disabling core dumps..." +echo "* hard core 0" >> /etc/security/limits.conf + +echo "System hardening complete!" diff --git a/tui/steps/disk_sel.go b/tui/steps/disk_sel.go index b5a0705..287c182 100644 --- a/tui/steps/disk_sel.go +++ b/tui/steps/disk_sel.go @@ -1,21 +1,28 @@ package steps import ( + "bufio" "fmt" "io" "miasma-installer/tui/styles" + "os" + "os/exec" + "strconv" + "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) type Disk struct { - name, description string + name string + description string + size string } func (i Disk) FilterValue() string { return i.name } func (i Disk) Title() string { return i.name } -func (i Disk) Description() string { return i.description } +func (i Disk) Description() string { return fmt.Sprintf("%s - %s", i.size, i.description) } type ItemDelegate struct{} @@ -30,7 +37,6 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite s := fmt.Sprintf("%d. %s - %s", index+1, disk.Title(), disk.Description()) - // focus styles if index == m.Index() { s = styles.ActiveItemStyle.Render("> " + s) } else { @@ -43,27 +49,85 @@ type DiskSelModel struct { list list.Model Finished bool SelectedDisk string + loading bool +} + +type disksLoadedMsg struct { + disks []list.Item } func NewDiskSelModel(width, height int) DiskSelModel { - // This is a placeholder - I need to update this to a tea.Cmd to scan the system for drives - disks := []list.Item{ - Disk{name: "/dev/sda", description: "500GB Samsung NVME"}, - Disk{name: "/dev/sdb", description: "1TB Baracuda HDD"}, - Disk{name: "/dev/loop0", description: "2GB Fanxiang SSD"}, - } - - l := list.New(disks, ItemDelegate{}, width, height-5) + l := list.New([]list.Item{}, ItemDelegate{}, width, height-5) l.Title = "Select Installation Disk" l.SetShowStatusBar(false) l.SetFilteringEnabled(false) l.Styles.Title = styles.TitleStyle - return DiskSelModel{list: l} + return DiskSelModel{ + list: l, + loading: true, + } } func (m DiskSelModel) Init() tea.Cmd { - return nil + return loadDisks +} + +func loadDisks() tea.Msg { + disks, err := scanDisks() + if err != nil { + return disksLoadedMsg{disks: []list.Item{ + Disk{name: "error", description: err.Error(), size: ""}, + }} + } + return disksLoadedMsg{disks: disks} +} + +func scanDisks() ([]list.Item, error) { + cmd := exec.Command("lsblk", "-ndo", "NAME,SIZE,TYPE,MODEL") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list disks: %w", err) + } + + var disks []list.Item + scanner := bufio.NewScanner(strings.NewReader(string(output))) + + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + + name := fields[0] + size := fields[1] + diskType := fields[2] + model := "" + if len(fields) > 3 { + model = strings.Join(fields[3:], " ") + } + + if diskType != "disk" { + continue + } + + if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "sr") { + continue + } + + disks = append(disks, Disk{ + name: "/dev/" + name, + size: size, + description: model, + }) + } + + if len(disks) == 0 { + return nil, fmt.Errorf("no suitable disks found") + } + + return disks, nil } func (m DiskSelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -72,8 +136,13 @@ func (m DiskSelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { + case disksLoadedMsg: + m.loading = false + m.list.SetItems(msg.disks) + return m, nil + case tea.KeyMsg: - if msg.String() == "enter" { + if !m.loading && msg.String() == "enter" { if item, ok := m.list.SelectedItem().(Disk); ok { m.SelectedDisk = item.name m.Finished = true @@ -81,13 +150,46 @@ func (m DiskSelModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } func (m DiskSelModel) View() string { + if m.loading { + return styles.MainStyle.Render(styles.TitleStyle.Render("Scanning disks...")) + } + s := m.list.View() s += "\n" + styles.HelpStyle.Render("Use ↑/↓ to navigate, [Enter] to select disk.") return s } + +func formatBytes(bytes uint64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := uint64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +func getDiskSize(device string) (string, error) { + data, err := os.ReadFile(fmt.Sprintf("/sys/block/%s/size", strings.TrimPrefix(device, "/dev/"))) + if err != nil { + return "", err + } + + sectors, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64) + if err != nil { + return "", err + } + + bytes := sectors * 512 + return formatBytes(bytes), nil +} diff --git a/tui/steps/encryption.go b/tui/steps/encryption.go index 87bc2ac..31300c7 100644 --- a/tui/steps/encryption.go +++ b/tui/steps/encryption.go @@ -2,9 +2,11 @@ package steps import ( "miasma-installer/tui/styles" + "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type EncryptionModel struct { @@ -22,11 +24,13 @@ func NewEncryptionModel() EncryptionModel { pi := textinput.New() pi.Placeholder = "Enter encryption password" pi.EchoMode = textinput.EchoPassword + pi.Width = 40 pi.Focus() ci := textinput.New() ci.Placeholder = "Confirm password" ci.EchoMode = textinput.EchoPassword + ci.Width = 40 return EncryptionModel{ EnableEncryption: true, @@ -108,25 +112,52 @@ func (m EncryptionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m EncryptionModel) View() string { - s := styles.TitleStyle.Render("Disk Encryption") - s += "\n\n" + var content strings.Builder + + content.WriteString(styles.TitleStyle.Render("🔒 Disk Encryption")) + content.WriteString("\n\n") if !m.showInputs { - s += "Enable LUKS2 disk encryption? (recommended)\n\n" - s += styles.HelpStyle.Render("[y] Yes [n] No") + info := "LUKS2 encryption protects your data if your device is lost or stolen.\n" + info += "You will need to enter the password every time you boot." + content.WriteString(styles.InfoBoxStyle.Render(info)) + content.WriteString("\n\n") + + content.WriteString("Enable disk encryption? (recommended)\n\n") + content.WriteString(styles.RenderHelp("y", "Yes", "n", "No")) } else { - s += "Enter encryption password:\n\n" - s += m.passwordInput.View() + "\n\n" - s += m.confirmInput.View() + "\n\n" + content.WriteString(styles.LabelStyle.Render("Password:")) + content.WriteString("\n") + + inputStyle := styles.InputStyle + if m.focusIndex == 0 { + inputStyle = styles.FocusedInputStyle + } + content.WriteString(inputStyle.Render(m.passwordInput.View())) + content.WriteString("\n\n") + + content.WriteString(styles.LabelStyle.Render("Confirm Password:")) + content.WriteString("\n") + + confirmStyle := styles.InputStyle + if m.focusIndex == 1 { + confirmStyle = styles.FocusedInputStyle + } + content.WriteString(confirmStyle.Render(m.confirmInput.View())) + content.WriteString("\n\n") if m.passwordInput.Value() != "" && m.confirmInput.Value() != "" { if m.passwordInput.Value() != m.confirmInput.Value() { - s += styles.HelpStyle.Render("Passwords do not match\n") + content.WriteString(styles.ErrorStyle.Render("✗ Passwords do not match")) + content.WriteString("\n\n") + } else { + content.WriteString(styles.SuccessStyle.Render("✓ Passwords match")) + content.WriteString("\n\n") } } - s += styles.HelpStyle.Render("[Tab] Switch fields [Enter] Continue") + content.WriteString(styles.RenderHelp("Tab", "Switch fields", "Enter", "Continue")) } - return styles.MainStyle.Render(s) + return styles.AppStyle.Render(content.String()) } diff --git a/tui/steps/install.go b/tui/steps/install.go index b3c08a1..cb01712 100644 --- a/tui/steps/install.go +++ b/tui/steps/install.go @@ -1,7 +1,7 @@ package steps import ( - "encoding/json" + "bufio" "fmt" "miasma-installer/config" "miasma-installer/tui/styles" @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" tea "github.com/charmbracelet/bubbletea" ) @@ -19,6 +20,8 @@ type InstallModel struct { status string progress []string err error + currentStep int + totalSteps int } func NewInstallModel(disk string, enableLUKS bool, hostname string, username string, userPassword string, rootPassword string) InstallModel { @@ -33,8 +36,10 @@ func NewInstallModel(disk string, enableLUKS bool, hostname string, username str Timezone: "UTC", Locale: "en_US.UTF-8", }, - status: "Preparing installation...", - progress: []string{}, + status: "Preparing installation...", + progress: []string{}, + currentStep: 0, + totalSteps: 8, } } @@ -46,34 +51,319 @@ type installCompleteMsg struct { err error } +type installProgressMsg struct { + step int + message string +} + func (m InstallModel) startInstall() tea.Cmd { return func() tea.Msg { - archConfig := m.config.ToArchInstall() - - configPath := "/tmp/miasma-install-config.json" - data, err := json.MarshalIndent(archConfig, "", " ") - if err != nil { - return installCompleteMsg{err: fmt.Errorf("failed to marshal config: %w", err)} + if err := m.performInstall(); err != nil { + return installCompleteMsg{err: err} } - - if err := os.WriteFile(configPath, data, 0644); err != nil { - return installCompleteMsg{err: fmt.Errorf("failed to write config: %w", err)} - } - - cmd := exec.Command("archinstall", "--config", configPath) - output, err := cmd.CombinedOutput() - if err != nil { - return installCompleteMsg{err: fmt.Errorf("archinstall failed: %w\nOutput: %s", err, string(output))} - } - - if err := m.runPostInstallScripts(); err != nil { - return installCompleteMsg{err: fmt.Errorf("post-install scripts failed: %w", err)} - } - return installCompleteMsg{err: nil} } } +func (m InstallModel) performInstall() error { + m.updateProgress(1, "Partitioning disk...") + if err := m.partitionDisk(); err != nil { + return fmt.Errorf("partitioning failed: %w", err) + } + + m.updateProgress(2, "Formatting partitions...") + if err := m.formatPartitions(); err != nil { + return fmt.Errorf("formatting failed: %w", err) + } + + m.updateProgress(3, "Mounting filesystems...") + if err := m.mountFilesystems(); err != nil { + return fmt.Errorf("mounting failed: %w", err) + } + + m.updateProgress(4, "Installing base system...") + if err := m.installBase(); err != nil { + return fmt.Errorf("base install failed: %w", err) + } + + m.updateProgress(5, "Configuring system...") + if err := m.configureSystem(); err != nil { + return fmt.Errorf("configuration failed: %w", err) + } + + m.updateProgress(6, "Installing bootloader...") + if err := m.installBootloader(); err != nil { + return fmt.Errorf("bootloader install failed: %w", err) + } + + m.updateProgress(7, "Installing desktop and packages...") + if err := m.installDesktop(); err != nil { + return fmt.Errorf("desktop install failed: %w", err) + } + + m.updateProgress(8, "Running post-install scripts...") + if err := m.runPostInstallScripts(); err != nil { + return fmt.Errorf("post-install scripts failed: %w", err) + } + + return nil +} + +func (m InstallModel) updateProgress(step int, message string) { + m.currentStep = step + m.status = message + time.Sleep(100 * time.Millisecond) +} + +func (m InstallModel) partitionDisk() error { + disk := m.config.Disk + + wipeDisk := exec.Command("wipefs", "-af", disk) + if err := wipeDisk.Run(); err != nil { + return err + } + + sgdisk := exec.Command("sgdisk", "-Z", disk) + if err := sgdisk.Run(); err != nil { + return err + } + + createEFI := exec.Command("sgdisk", "-n", "1:0:+512M", "-t", "1:ef00", disk) + if err := createEFI.Run(); err != nil { + return err + } + + createRoot := exec.Command("sgdisk", "-n", "2:0:0", "-t", "2:8300", disk) + if err := createRoot.Run(); err != nil { + return err + } + + time.Sleep(1 * time.Second) + exec.Command("partprobe", disk).Run() + + return nil +} + +func (m InstallModel) formatPartitions() error { + disk := m.config.Disk + efiPart := disk + "1" + rootPart := disk + "2" + + if strings.Contains(disk, "nvme") || strings.Contains(disk, "mmcblk") { + efiPart = disk + "p1" + rootPart = disk + "p2" + } + + mkfsEFI := exec.Command("mkfs.fat", "-F32", efiPart) + if err := mkfsEFI.Run(); err != nil { + return err + } + + if m.config.EnableLUKS { + cryptsetup := exec.Command("cryptsetup", "luksFormat", "--type", "luks2", rootPart) + cryptsetup.Stdin = strings.NewReader(m.config.RootPassword + "\n") + if err := cryptsetup.Run(); err != nil { + return err + } + + cryptOpen := exec.Command("cryptsetup", "open", rootPart, "cryptroot") + cryptOpen.Stdin = strings.NewReader(m.config.RootPassword + "\n") + if err := cryptOpen.Run(); err != nil { + return err + } + + rootPart = "/dev/mapper/cryptroot" + } + + mkfsBtrfs := exec.Command("mkfs.btrfs", "-f", rootPart) + if err := mkfsBtrfs.Run(); err != nil { + return err + } + + return nil +} + +func (m InstallModel) mountFilesystems() error { + disk := m.config.Disk + rootPart := disk + "2" + efiPart := disk + "1" + + if strings.Contains(disk, "nvme") || strings.Contains(disk, "mmcblk") { + efiPart = disk + "p1" + rootPart = disk + "p2" + } + + if m.config.EnableLUKS { + rootPart = "/dev/mapper/cryptroot" + } + + os.MkdirAll("/mnt", 0755) + mount := exec.Command("mount", rootPart, "/mnt") + if err := mount.Run(); err != nil { + return err + } + + btrfsOpts := "subvol=@,compress=zstd,noatime" + for _, subvol := range []string{"@", "@home", "@var", "@snapshots"} { + create := exec.Command("btrfs", "subvolume", "create", "/mnt/"+subvol) + create.Run() + } + + exec.Command("umount", "/mnt").Run() + + mount = exec.Command("mount", "-o", btrfsOpts, rootPart, "/mnt") + if err := mount.Run(); err != nil { + return err + } + + os.MkdirAll("/mnt/home", 0755) + os.MkdirAll("/mnt/var", 0755) + os.MkdirAll("/mnt/.snapshots", 0755) + os.MkdirAll("/mnt/boot", 0755) + + mountHome := exec.Command("mount", "-o", "subvol=@home,compress=zstd,noatime", rootPart, "/mnt/home") + mountHome.Run() + + mountVar := exec.Command("mount", "-o", "subvol=@var,compress=zstd,noatime", rootPart, "/mnt/var") + mountVar.Run() + + mountSnap := exec.Command("mount", "-o", "subvol=@snapshots,compress=zstd,noatime", rootPart, "/mnt/.snapshots") + mountSnap.Run() + + mountEFI := exec.Command("mount", efiPart, "/mnt/boot") + if err := mountEFI.Run(); err != nil { + return err + } + + return nil +} + +func (m InstallModel) installBase() error { + packages := []string{ + "base", + "base-devel", + "linux-hardened", + "linux-firmware", + "btrfs-progs", + "neovim", + "tmux", + "git", + "networkmanager", + "sudo", + } + + pacstrap := exec.Command("pacstrap", append([]string{"/mnt"}, packages...)...) + pacstrap.Stdout = os.Stdout + pacstrap.Stderr = os.Stderr + if err := pacstrap.Run(); err != nil { + return err + } + + return nil +} + +func (m InstallModel) configureSystem() error { + genfstab := exec.Command("genfstab", "-U", "/mnt") + fstab, err := genfstab.Output() + if err != nil { + return err + } + os.WriteFile("/mnt/etc/fstab", fstab, 0644) + + os.WriteFile("/mnt/etc/hostname", []byte(m.config.Hostname+"\n"), 0644) + + hosts := fmt.Sprintf("127.0.0.1\tlocalhost\n::1\t\tlocalhost\n127.0.1.1\t%s.localdomain\t%s\n", + m.config.Hostname, m.config.Hostname) + os.WriteFile("/mnt/etc/hosts", []byte(hosts), 0644) + + os.WriteFile("/mnt/etc/locale.gen", []byte("en_US.UTF-8 UTF-8\n"), 0644) + chroot([]string{"locale-gen"}) + + os.WriteFile("/mnt/etc/locale.conf", []byte("LANG=en_US.UTF-8\n"), 0644) + + chroot([]string{"ln", "-sf", "/usr/share/zoneinfo/UTC", "/etc/localtime"}) + chroot([]string{"hwclock", "--systohc"}) + + useraddCmd := fmt.Sprintf("useradd -m -G wheel -s /bin/bash %s", m.config.Username) + chroot([]string{"sh", "-c", useraddCmd}) + + passwdCmd := fmt.Sprintf("echo '%s:%s' | chpasswd", m.config.Username, m.config.UserPassword) + chroot([]string{"sh", "-c", passwdCmd}) + + rootPasswdCmd := fmt.Sprintf("echo 'root:%s' | chpasswd", m.config.RootPassword) + chroot([]string{"sh", "-c", rootPasswdCmd}) + + sudoers := "%wheel ALL=(ALL:ALL) ALL\n" + os.WriteFile("/mnt/etc/sudoers.d/wheel", []byte(sudoers), 0440) + + chroot([]string{"systemctl", "enable", "NetworkManager"}) + + return nil +} + +func (m InstallModel) installBootloader() error { + chroot([]string{"bootctl", "install"}) + + loaderConf := `default arch.conf +timeout 3 +console-mode max +editor no +` + os.WriteFile("/mnt/boot/loader/loader.conf", []byte(loaderConf), 0644) + + disk := m.config.Disk + rootPart := disk + "2" + if strings.Contains(disk, "nvme") || strings.Contains(disk, "mmcblk") { + rootPart = disk + "p2" + } + + rootUUID := "" + if m.config.EnableLUKS { + blkid := exec.Command("blkid", "-s", "UUID", "-o", "value", rootPart) + output, _ := blkid.Output() + rootUUID = strings.TrimSpace(string(output)) + } else { + rootPart = "/dev/mapper/cryptroot" + blkid := exec.Command("blkid", "-s", "UUID", "-o", "value", rootPart) + output, _ := blkid.Output() + rootUUID = strings.TrimSpace(string(output)) + } + + options := fmt.Sprintf("root=UUID=%s rootflags=subvol=@ rw", rootUUID) + if m.config.EnableLUKS { + cryptUUID := "" + blkid := exec.Command("blkid", "-s", "UUID", "-o", "value", disk+"2") + output, _ := blkid.Output() + cryptUUID = strings.TrimSpace(string(output)) + options = fmt.Sprintf("cryptdevice=UUID=%s:cryptroot root=/dev/mapper/cryptroot rootflags=subvol=@ rw", cryptUUID) + } + + entryConf := fmt.Sprintf(`title Miasma OS +linux /vmlinuz-linux-hardened +initrd /initramfs-linux-hardened.img +options %s +`, options) + os.WriteFile("/mnt/boot/loader/entries/arch.conf", []byte(entryConf), 0644) + + if m.config.EnableLUKS { + mkinitcpio, _ := os.ReadFile("/mnt/etc/mkinitcpio.conf") + newConf := strings.Replace(string(mkinitcpio), + "HOOKS=(base udev autodetect modconf block filesystems keyboard fsck)", + "HOOKS=(base udev autodetect keyboard keymap consolefont modconf block encrypt filesystems fsck)", + 1) + os.WriteFile("/mnt/etc/mkinitcpio.conf", []byte(newConf), 0644) + chroot([]string{"mkinitcpio", "-P"}) + } + + return nil +} + +func (m InstallModel) installDesktop() error { + chroot([]string{"pacman", "-Sy", "--noconfirm", "cosmic-session", "cosmic-greeter"}) + chroot([]string{"systemctl", "enable", "cosmic-greeter"}) + return nil +} + func (m InstallModel) runPostInstallScripts() error { scriptsDir := "./scripts" entries, err := os.ReadDir(scriptsDir) @@ -90,15 +380,30 @@ func (m InstallModel) runPostInstallScripts() error { } scriptPath := filepath.Join(scriptsDir, entry.Name()) - cmd := exec.Command("/bin/bash", scriptPath) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("script %s failed: %w\nOutput: %s", entry.Name(), err, string(output)) + scriptContent, err := os.ReadFile(scriptPath) + if err != nil { + return err } + + chrootScript := "/tmp/" + entry.Name() + os.WriteFile("/mnt"+chrootScript, scriptContent, 0755) + + if output, err := chroot([]string{chrootScript}); err != nil { + return fmt.Errorf("script %s failed: %w\nOutput: %s", entry.Name(), err, output) + } + + os.Remove("/mnt" + chrootScript) } return nil } +func chroot(args []string) (string, error) { + cmd := exec.Command("arch-chroot", append([]string{"/mnt"}, args...)...) + output, err := cmd.CombinedOutput() + return string(output), err +} + func (m InstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case installCompleteMsg: @@ -118,15 +423,30 @@ func (m InstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m InstallModel) View() string { s := styles.TitleStyle.Render("Installing Miasma OS") s += "\n\n" - s += m.status + "\n\n" + s += fmt.Sprintf("Step %d/%d: %s\n\n", m.currentStep, m.totalSteps, m.status) if m.err != nil { s += styles.HelpStyle.Render(fmt.Sprintf("Error: %v\n", m.err)) } if m.Finished && m.err == nil { - s += styles.HelpStyle.Render("Press Ctrl+C to exit") + s += "\n" + styles.HelpStyle.Render("Installation successful! You can now reboot.\nPress Ctrl+C to exit") } return styles.MainStyle.Render(s) } + +func readLines(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} diff --git a/tui/steps/welcome.go b/tui/steps/welcome.go index 24d17e6..78f9543 100644 --- a/tui/steps/welcome.go +++ b/tui/steps/welcome.go @@ -2,8 +2,10 @@ package steps import ( "miasma-installer/tui/styles" + "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type WelcomeModel struct { @@ -34,13 +36,42 @@ func (m WelcomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m WelcomeModel) View() string { - s := styles.TitleStyle.Render("Welcome to the Miasma OS Installer") - s += "\n\n" - s += "This program will guide you through installing Miasma OS. \n" - s += "Please ensure that you have backed up any important data before proceding" - s += "\n\n" - s += styles.HelpStyle.Render("Press [Enter] to continue") - s += styles.HelpStyle.Render("Press [Ctrl+C] to exit") + logo := ` + ███╗ ███╗██╗ █████╗ ███████╗███╗ ███╗ █████╗ + ████╗ ████║██║██╔══██╗██╔════╝████╗ ████║██╔══██╗ + ██╔████╔██║██║███████║███████╗██╔████╔██║███████║ + ██║╚██╔╝██║██║██╔══██║╚════██║██║╚██╔╝██║██╔══██║ + ██║ ╚═╝ ██║██║██║ ██║███████║██║ ╚═╝ ██║██║ ██║ + ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ +` - return styles.MainStyle.Render(s) + var content strings.Builder + + content.WriteString(styles.LogoStyle.Render(logo)) + content.WriteString("\n\n") + content.WriteString(styles.TitleStyle.Render("Welcome to Miasma OS Installer")) + content.WriteString("\n") + content.WriteString(styles.SubtitleStyle.Render("An opinionated Arch Linux installation")) + content.WriteString("\n\n") + + features := []string{ + "🔒 LUKS2 encryption by default", + "🗂️ Btrfs filesystem with snapshots", + "🛡️ Hardened Linux kernel", + "🚀 Cosmic Desktop environment", + "⚡ UEFI boot with systemd-boot", + } + + featureBox := styles.InfoBoxStyle.Render(strings.Join(features, "\n")) + content.WriteString(featureBox) + content.WriteString("\n\n") + + warning := styles.WarningBoxStyle.Render("⚠️ WARNING: This will erase all data on the selected disk!") + content.WriteString(warning) + content.WriteString("\n\n") + + help := styles.RenderHelp("Enter", "Continue", "Ctrl+C", "Exit") + content.WriteString(help) + + return styles.AppStyle.Render(content.String()) } diff --git a/tui/styles/styles.go b/tui/styles/styles.go index 146fe3e..ee02fff 100644 --- a/tui/styles/styles.go +++ b/tui/styles/styles.go @@ -3,23 +3,137 @@ package styles import "github.com/charmbracelet/lipgloss" var ( - MainStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#7fb4ca")). + Primary = lipgloss.Color("#7aa2f7") + Secondary = lipgloss.Color("#bb9af7") + Success = lipgloss.Color("#9ece6a") + Warning = lipgloss.Color("#e0af68") + Error = lipgloss.Color("#f7768e") + Muted = lipgloss.Color("#565f89") + Text = lipgloss.Color("#c0caf5") + Subtle = lipgloss.Color("#414868") + Border = lipgloss.Color("#7aa2f7") +) + +var ( + AppStyle = lipgloss.NewStyle(). Padding(1, 2) - HelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - TitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7fb4ca")). - Align(lipgloss.Center). + Foreground(Primary). Bold(true). - PaddingBottom(1) + MarginBottom(1). + MarginTop(1) - ActiveItemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#87a987")). + LogoStyle = lipgloss.NewStyle(). + Foreground(Secondary). + Bold(true). + Align(lipgloss.Center) + + SubtitleStyle = lipgloss.NewStyle(). + Foreground(Muted). + Italic(true) + + BoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Border). + Padding(1, 2). + MarginTop(1). + MarginBottom(1) + + ActiveBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + BorderForeground(Primary). + Padding(1, 2). + MarginTop(1). + MarginBottom(1) + + InfoBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Secondary). + Padding(0, 1). + MarginTop(1) + + WarningBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Warning). + Padding(0, 1). + MarginTop(1) + + ErrorBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Error). + Padding(0, 1). + MarginTop(1) + + HelpStyle = lipgloss.NewStyle(). + Foreground(Muted). + MarginTop(1) + + KeyStyle = lipgloss.NewStyle(). + Foreground(Primary). Bold(true) + ValueStyle = lipgloss.NewStyle(). + Foreground(Text) + + ActiveItemStyle = lipgloss.NewStyle(). + Foreground(Primary). + Bold(true). + PaddingLeft(2) + InactiveItemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) + Foreground(Muted). + PaddingLeft(2) + + SelectedItemStyle = lipgloss.NewStyle(). + Foreground(Success). + Bold(true) + + LabelStyle = lipgloss.NewStyle(). + Foreground(Secondary). + Bold(true). + MarginRight(1) + + InputStyle = lipgloss.NewStyle(). + Foreground(Text). + Border(lipgloss.RoundedBorder()). + BorderForeground(Subtle). + Padding(0, 1) + + FocusedInputStyle = lipgloss.NewStyle(). + Foreground(Text). + Border(lipgloss.RoundedBorder()). + BorderForeground(Primary). + Padding(0, 1) + + ProgressBarStyle = lipgloss.NewStyle(). + Foreground(Primary) + + ProgressTextStyle = lipgloss.NewStyle(). + Foreground(Muted) + + SuccessStyle = lipgloss.NewStyle(). + Foreground(Success). + Bold(true) + + ErrorStyle = lipgloss.NewStyle(). + Foreground(Error). + Bold(true) + + SpinnerStyle = lipgloss.NewStyle(). + Foreground(Primary) ) + +func RenderKeyValue(key, value string) string { + return KeyStyle.Render(key+":") + " " + ValueStyle.Render(value) +} + +func RenderHelp(keys ...string) string { + var parts []string + for i := 0; i < len(keys); i += 2 { + if i+1 < len(keys) { + parts = append(parts, KeyStyle.Render(keys[i])+" "+HelpStyle.Render(keys[i+1])) + } + } + return HelpStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, parts...)) +}