Fiw-Gentoo-Dots/cmd/dots/main.go

497 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type selection struct {
Name string `json:"name"`
Profile string `json:"profile"`
Groups []string `json:"groups"`
Configs []string `json:"configs"`
Kernel string `json:"kernel"`
Bootloader string `json:"bootloader"`
Extras []string `json:"extras"`
Firefox bool `json:"firefox_privacy"`
Spotify bool `json:"spotify_custom"`
Flatpaks []string `json:"flatpaks"`
Services []string `json:"services"`
}
type config struct {
Label string `json:"label"`
Group string `json:"group"`
}
type setupOption struct {
Label string `json:"label"`
Group string `json:"group"`
Scope string `json:"scope"`
}
type catalog struct {
Groups []string `json:"groups"`
Configs map[string]config `json:"configs"`
Extras map[string]string `json:"extras"`
Flatpaks map[string]setupOption `json:"flatpaks"`
Services map[string]setupOption `json:"services"`
Stock selection `json:"stock"`
Ryzen selection `json:"ryzen"`
}
type row struct {
id, label string
checked bool
}
type previewMsg struct {
content string
err error
}
type model struct {
repo string
catalog catalog
selection selection
rows []row
stage, cursor, height, scroll int
preview, errorText, action string
done, loading bool
}
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func (m model) Init() tea.Cmd { return nil }
func (m *model) prepare() {
m.cursor, m.scroll = 0, 0
m.rows = nil
switch m.stage {
case 0:
m.rows = []row{{"stock", "Stock — binary kernel, portable build settings", m.selection.Profile == "stock"}, {"fiw-ryzen", "Fiw's Ryzen — current Zen 5 tuning, custom + binary fallback", m.selection.Profile == "fiw-ryzen"}}
if m.selection.Profile == "fiw-ryzen" {
m.cursor = 1
}
case 1:
for _, id := range m.catalog.Groups {
label := id
if id == "tidewm" {
label += " (optional)"
}
m.rows = append(m.rows, row{id, label, contains(m.selection.Groups, id)})
}
case 2:
ids := make([]string, 0, len(m.catalog.Configs))
for id := range m.catalog.Configs {
if id != "autostart" {
ids = append(ids, id)
}
}
sort.Strings(ids)
for _, id := range ids {
c := m.catalog.Configs[id]
m.rows = append(m.rows, row{id, c.Label, contains(m.selection.Configs, id)})
}
case 3:
m.rows = []row{
{"binary", "Kernel: generic binary", m.selection.Kernel == "binary"},
{"custom", "Kernel: custom + generic binary fallback", m.selection.Kernel == "custom"},
{"keep", "Bootloader: keep existing", m.selection.Bootloader == "keep"},
{"limine", "Bootloader: optional Limine", m.selection.Bootloader == "limine"},
{"grub", "Bootloader: optional GRUB", m.selection.Bootloader == "grub"},
}
for _, id := range []string{"nvidia", "btrfs", "filelight"} {
m.rows = append(m.rows, row{id, m.catalog.Extras[id], contains(m.selection.Extras, id)})
}
m.rows = append(m.rows, row{"autostart", "Optional app autostart", contains(m.selection.Configs, "autostart")}, row{"firefox", "Optional Firefox-Privacy setup", m.selection.Firefox}, row{"spotify", "Optional Spotify customization script (stock by default)", m.selection.Spotify})
case 4:
flatpaks := make([]string, 0, len(m.catalog.Flatpaks))
for id := range m.catalog.Flatpaks {
flatpaks = append(flatpaks, id)
}
sort.Strings(flatpaks)
for _, id := range flatpaks {
option := m.catalog.Flatpaks[id]
m.rows = append(m.rows, row{"flatpak:" + id, "Flatpak: " + option.Label + " (" + option.Group + ")", contains(m.selection.Flatpaks, id)})
}
for _, id := range []string{"audio", "network", "bluetooth", "power"} {
if option, ok := m.catalog.Services[id]; ok {
m.rows = append(m.rows, row{"service:" + id, option.Label, contains(m.selection.Services, id)})
}
}
}
}
func (m *model) remember() {
switch m.stage {
case 1:
m.selection.Groups = []string{}
for _, r := range m.rows {
if r.checked {
m.selection.Groups = append(m.selection.Groups, r.id)
}
}
case 2:
autostart := contains(m.selection.Configs, "autostart")
m.selection.Configs = []string{}
for _, r := range m.rows {
if r.checked {
m.selection.Configs = append(m.selection.Configs, r.id)
}
}
if autostart {
m.selection.Configs = append(m.selection.Configs, "autostart")
}
case 3:
m.selection.Extras = []string{}
for _, r := range m.rows {
if _, ok := m.catalog.Extras[r.id]; ok && r.checked {
m.selection.Extras = append(m.selection.Extras, r.id)
}
if r.checked && (r.id == "binary" || r.id == "custom") {
m.selection.Kernel = r.id
}
if r.checked && (r.id == "keep" || r.id == "limine" || r.id == "grub") {
m.selection.Bootloader = r.id
}
if r.id == "firefox" {
m.selection.Firefox = r.checked
}
if r.id == "spotify" {
m.selection.Spotify = r.checked
}
}
configNames := []string{}
for _, id := range m.selection.Configs {
if id != "autostart" {
configNames = append(configNames, id)
}
}
for _, r := range m.rows {
if r.id == "autostart" && r.checked {
configNames = append(configNames, "autostart")
}
}
m.selection.Configs = configNames
case 4:
m.selection.Flatpaks, m.selection.Services = []string{}, []string{}
for _, r := range m.rows {
if !r.checked {
continue
}
if strings.HasPrefix(r.id, "flatpak:") {
m.selection.Flatpaks = append(m.selection.Flatpaks, strings.TrimPrefix(r.id, "flatpak:"))
}
if strings.HasPrefix(r.id, "service:") {
m.selection.Services = append(m.selection.Services, strings.TrimPrefix(r.id, "service:"))
}
}
}
}
func (m model) makePreview() tea.Cmd {
return func() tea.Msg {
file, err := os.CreateTemp("", "fiw-dots-selection-*.json")
if err != nil {
return previewMsg{err: err}
}
defer os.Remove(file.Name())
data, err := json.Marshal(m.selection)
if err == nil {
_, err = file.Write(data)
}
file.Close()
if err != nil {
return previewMsg{err: err}
}
cmd := exec.Command("python3", filepath.Join(m.repo, "lib", "rice.py"), "--plan", "--selection", file.Name())
output, err := cmd.CombinedOutput()
return previewMsg{string(output), err}
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch v := msg.(type) {
case tea.WindowSizeMsg:
m.height = v.Height
case previewMsg:
m.loading = false
m.preview = v.content
if v.err != nil {
m.errorText = v.err.Error()
}
case tea.KeyMsg:
key := v.String()
if key == "ctrl+c" || key == "q" {
return m, tea.Quit
}
if m.loading {
return m, nil
}
if m.stage == 5 {
switch key {
case "up", "k":
if m.scroll > 0 {
m.scroll--
}
case "down", "j":
if m.scroll < len(strings.Split(m.preview, "\n"))-1 {
m.scroll++
}
case "esc":
m.stage--
m.prepare()
case "enter", "s", "a", "i", "f", "v", "b", "r":
if m.errorText == "" {
m.done = true
m.action = key
return m, tea.Quit
}
}
return m, nil
}
switch key {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.rows)-1 {
m.cursor++
}
case " ":
if len(m.rows) == 0 {
break
}
if m.stage == 0 {
if m.rows[m.cursor].id == "stock" {
m.selection = m.catalog.Stock
} else {
m.selection = m.catalog.Ryzen
}
m.prepare()
} else {
id := m.rows[m.cursor].id
if m.stage == 3 && (id == "binary" || id == "custom") {
for i := range m.rows {
if m.rows[i].id == "binary" || m.rows[i].id == "custom" {
m.rows[i].checked = false
}
}
m.rows[m.cursor].checked = true
} else if m.stage == 3 && (id == "keep" || id == "limine" || id == "grub") {
for i := range m.rows {
if m.rows[i].id == "keep" || m.rows[i].id == "limine" || m.rows[i].id == "grub" {
m.rows[i].checked = false
}
}
m.rows[m.cursor].checked = true
} else {
m.rows[m.cursor].checked = !m.rows[m.cursor].checked
}
}
case "enter":
if m.stage == 0 {
if m.rows[m.cursor].id == "stock" {
m.selection = m.catalog.Stock
} else {
m.selection = m.catalog.Ryzen
}
}
m.remember()
m.stage++
if m.stage == 5 {
m.loading = true
m.errorText = ""
return m, m.makePreview()
}
m.prepare()
case "esc":
if m.stage > 0 {
m.remember()
m.stage--
m.prepare()
}
}
}
return m, nil
}
func (m model) View() string {
const purple = "\033[38;2;157;143;217m"
const reset = "\033[0m"
titles := []string{"Preset", "Package sections", "Optional app configs", "Kernel, bootloader and extras", "Flatpaks and optional services", "Final preview"}
text := purple + " Fiw-Gentoo-Dots\n" + reset + " " + titles[m.stage] + " · " + m.selection.Name + "\n\n"
budget := m.height - 10
if budget < 5 {
budget = 5
}
if m.stage == 5 {
if m.loading {
return text + " Preparing preview…\n"
}
lines := strings.Split(m.preview, "\n")
end := m.scroll + budget
if end > len(lines) {
end = len(lines)
}
for _, line := range lines[m.scroll:end] {
text += " " + line + "\n"
}
text += "\n ↑/↓ scroll · Esc back · Enter save · r full restore · a configs · i packages\n f Flatpaks · v services · b bootloader · q cancel\n"
if m.errorText != "" {
text += " " + m.errorText + "\n"
}
return text
}
start := 0
if m.cursor >= budget {
start = m.cursor - budget + 1
}
end := start + budget
if end > len(m.rows) {
end = len(m.rows)
}
for i := start; i < end; i++ {
r := m.rows[i]
mark := "[ ]"
if r.checked {
mark = "[✓]"
}
cursor := " "
if m.cursor == i {
cursor = "› "
}
line := " " + cursor + mark + " " + r.label
if m.cursor == i {
line = purple + line + reset
}
text += line + "\n"
}
return text + "\n ↑/↓ move · Space select · Enter next · Esc back · q cancel\n"
}
func main() {
repo, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
output, err := exec.Command("python3", filepath.Join(repo, "lib", "rice.py"), "--catalog").Output()
if err != nil {
fmt.Fprintln(os.Stderr, "Cannot load the installer catalogue:", err)
os.Exit(1)
}
var c catalog
if err := json.Unmarshal(output, &c); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := model{repo: repo, catalog: c, selection: c.Stock, height: 28}
savedPath := filepath.Join(repo, "local", "selection.json")
if saved, readErr := os.ReadFile(savedPath); readErr == nil {
var previous selection
if json.Unmarshal(saved, &previous) == nil && (previous.Profile == "stock" || previous.Profile == "fiw-ryzen") {
m.selection = previous
m.stage = 1
}
}
m.prepare()
result, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
final := result.(model)
if !final.done {
return
}
path := filepath.Join(repo, "local", "selection.json")
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
data, _ := json.MarshalIndent(final.selection, "", " ")
if err := os.WriteFile(path, append(data, '\n'), 0600); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(final.preview)
fmt.Println("\nSaved selection:", path)
run := func(action string, root bool) error {
args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path, action}
program := "python3"
if root {
program = "sudo"
args = append([]string{"python3"}, args...)
}
cmd := exec.Command(program, args...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
return cmd.Run()
}
services := func() error {
system, user := false, false
for _, id := range final.selection.Services {
if c.Services[id].Scope == "system" {
system = true
}
if c.Services[id].Scope == "user" {
user = true
}
}
if system {
if err := run("--enable-services", true); err != nil {
return err
}
}
if user {
return run("--enable-services", false)
}
if !system {
fmt.Println("No services selected.")
}
return nil
}
var actionErr error
switch final.action {
case "a":
actionErr = run("--apply-configs", false)
case "i":
actionErr = run("--install-packages", true)
case "f":
actionErr = run("--install-flatpaks", false)
case "v":
actionErr = services()
case "b":
actionErr = run("--deploy-bootloader", true)
case "r":
actionErr = run("--check-configs", false)
if actionErr == nil && (len(final.selection.Groups) > 0 || len(final.selection.Extras) > 0 || final.selection.Bootloader != "keep" || len(final.selection.Flatpaks) > 0 || len(final.selection.Services) > 0) {
actionErr = run("--install-packages", true)
}
if actionErr == nil && len(final.selection.Flatpaks) > 0 {
actionErr = run("--install-flatpaks", false)
}
if actionErr == nil && len(final.selection.Services) > 0 {
actionErr = services()
}
if actionErr == nil && len(final.selection.Configs) > 0 {
actionErr = run("--apply-configs", false)
}
if actionErr == nil && final.selection.Bootloader != "keep" {
actionErr = run("--deploy-bootloader", true)
}
default:
fmt.Println("Saved. Choose an action in the TUI or use the commands in docs/setup.md.")
return
}
if actionErr != nil {
fmt.Fprintln(os.Stderr, actionErr)
os.Exit(1)
}
}