From a649fdd1e215f678a70a5c7a7c08d3026ba9059b Mon Sep 17 00:00:00 2001 From: David Young Date: Tue, 31 Mar 2026 14:56:14 -0600 Subject: [PATCH] hampack-manager --- hampack-manager-src/go.mod | 8 + hampack-manager-src/main.go | 535 ++++++++++++++++++++++++++++++++++++ install-binaries.sh | 2 +- install-compiled.sh | 2 +- 4 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 hampack-manager-src/go.mod create mode 100644 hampack-manager-src/main.go diff --git a/hampack-manager-src/go.mod b/hampack-manager-src/go.mod new file mode 100644 index 0000000..52a3974 --- /dev/null +++ b/hampack-manager-src/go.mod @@ -0,0 +1,8 @@ +module hampack-manager + +go 1.22 + +require ( + github.com/diamondburned/gotk4-adw v0.0.0-20240804043422-6b76e90f99ef + github.com/diamondburned/gotk4/pkg/core v0.3.1 +) diff --git a/hampack-manager-src/main.go b/hampack-manager-src/main.go new file mode 100644 index 0000000..fde0972 --- /dev/null +++ b/hampack-manager-src/main.go @@ -0,0 +1,535 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/diamondburned/gotk4-adw/pkg/adw" + "github.com/diamondburned/gotk4/pkg/glib/v2" + "github.com/diamondburned/gotk4/pkg/gtk/v4" +) + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +func homeDir() string { + d, _ := os.UserHomeDir() + return d +} + +func hampackDir() string { + if d := os.Getenv("HAMPACK_DIR"); d != "" { + return d + } + return filepath.Join(homeDir(), ".local/share/HamPack") +} + +func stateFilePath() string { + return filepath.Join(homeDir(), ".local/state/HamPack/selections.json") +} + +func expand(v string) string { + return strings.ReplaceAll(v, "$HOME", homeDir()) +} + +var windowsItems = [][2]string{ + {"VARA HF", filepath.Join(homeDir(), ".wine/drive_c/VARA")}, + {"VARA FM", filepath.Join(homeDir(), ".wine/drive_c/VARA FM")}, + {"VARA Terminal", filepath.Join(homeDir(), ".wine/drive_c/VARA Terminal")}, + {"Winlink Express", filepath.Join(homeDir(), ".wine/drive_c/RMS Express")}, +} + +// ── Config parsing ───────────────────────────────────────────────────────────── + +func parseINI(path string) map[string]map[string]string { + entries := make(map[string]map[string]string) + data, err := os.ReadFile(path) + if err != nil { + return entries + } + var cur string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if len(line) > 2 && line[0] == '[' && line[len(line)-1] == ']' { + cur = line[1 : len(line)-1] + entries[cur] = make(map[string]string) + continue + } + if cur == "" { + continue + } + if k, v, ok := strings.Cut(line, "="); ok { + entries[cur][strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return entries +} + +func parsePackages() (utils, apps []string) { + path := filepath.Join(hampackDir(), "packages.conf") + cmd := exec.Command("bash", "-c", fmt.Sprintf( + `source '%s' && printf '%%s\n' ${UTILITIES[@]} && echo '---' && printf '%%s\n' ${APPLICATIONS[@]}`, + path)) + out, err := cmd.Output() + if err != nil { + return + } + inApps := false + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "---" { + inApps = true + } else if line != "" { + if inApps { + apps = append(apps, line) + } else { + utils = append(utils, line) + } + } + } + return +} + +func parseFlatpaks() []string { + path := filepath.Join(hampackDir(), "install-flatpaks.sh") + data, err := os.ReadFile(path) + if err != nil { + return nil + } + re := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*(\.[a-zA-Z][a-zA-Z0-9_-]*){2,}$`) + var ids []string + for _, line := range strings.Split(string(data), "\n") { + s := strings.Trim(strings.TrimSpace(line), `"'`) + if re.MatchString(s) { + ids = append(ids, s) + } + } + return ids +} + +// ── State ────────────────────────────────────────────────────────────────────── + +func loadState() map[string]bool { + data, err := os.ReadFile(stateFilePath()) + if err != nil { + return map[string]bool{} + } + var state map[string]bool + if err := json.Unmarshal(data, &state); err != nil { + return map[string]bool{} + } + return state +} + +func saveState(state map[string]bool) { + os.MkdirAll(filepath.Dir(stateFilePath()), 0755) + data, _ := json.MarshalIndent(state, "", " ") + os.WriteFile(stateFilePath(), data, 0644) +} + +// ── Operations ──────────────────────────────────────────────────────────────── + +type Op struct { + Label string + Cmds [][]string + Env []string // nil means inherit os.Environ() + Tmp string // temp file to clean up after the op +} + +func writeTempINI(name string, fields map[string]string) string { + f, err := os.CreateTemp("", "hampack-*.conf") + if err != nil { + return "" + } + defer f.Close() + fmt.Fprintf(f, "[%s]\n", name) + for k, v := range fields { + fmt.Fprintf(f, "%s=%s\n", k, v) + } + return f.Name() +} + +func sortedKeys(m map[string]map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func computeOps( + oldState, newState map[string]bool, + compiled, binaries map[string]map[string]string, +) []Op { + allKeys := make(map[string]bool) + for k := range oldState { + allKeys[k] = true + } + for k := range newState { + allKeys[k] = true + } + keys := make([]string, 0, len(allKeys)) + for k := range allKeys { + keys = append(keys, k) + } + sort.Strings(keys) + + var ops []Op + windowsInstallAdded := false + + for _, key := range keys { + was := oldState[key] + now := newState[key] + if was == now { + continue + } + parts := strings.SplitN(key, ":", 2) + if len(parts) != 2 { + continue + } + cat, name := parts[0], parts[1] + + if now { // ── install ────────────────────────────────────────────────── + switch cat { + case "utility", "application": + ops = append(ops, Op{ + Label: "Install " + name, + Cmds: [][]string{{"yay", "-S", "--noconfirm", name}}, + }) + case "flatpak": + ops = append(ops, Op{ + Label: "Install " + name, + Cmds: [][]string{{"flatpak", "install", "--noninteractive", "flathub", name}}, + }) + case "compiled": + if fields, ok := compiled[name]; ok { + tmp := writeTempINI(name, fields) + ops = append(ops, Op{ + Label: "Build " + name, + Cmds: [][]string{{"bash", filepath.Join(hampackDir(), "install-compiled.sh")}}, + Env: append(os.Environ(), "HAMPACK_COMPILE_CONF="+tmp), + Tmp: tmp, + }) + } + case "binary": + if fields, ok := binaries[name]; ok { + tmp := writeTempINI(name, fields) + ops = append(ops, Op{ + Label: "Install " + name, + Cmds: [][]string{{"bash", filepath.Join(hampackDir(), "install-binaries.sh")}}, + Env: append(os.Environ(), "HAMPACK_BINARIES_CONF="+tmp), + Tmp: tmp, + }) + } + case "windows": + if !windowsInstallAdded { + ops = append(ops, Op{ + Label: "Install Windows Apps (Wine)", + Cmds: [][]string{{"bash", filepath.Join(hampackDir(), "install-windows-apps.sh")}}, + }) + windowsInstallAdded = true + } + } + } else { // ── uninstall ──────────────────────────────────────────────── + switch cat { + case "utility", "application": + ops = append(ops, Op{ + Label: "Remove " + name, + Cmds: [][]string{{"sudo", "pacman", "-Rns", "--noconfirm", name}}, + }) + case "flatpak": + ops = append(ops, Op{ + Label: "Remove " + name, + Cmds: [][]string{{"flatpak", "uninstall", "--noninteractive", name}}, + }) + case "compiled": + if fields, ok := compiled[name]; ok { + installPath := expand(fields["install"]) + desktopSrc := expand(fields["desktop"]) + cmds := [][]string{{"rm", "-f", installPath}} + if desktopSrc != "" { + cmds = append(cmds, []string{"rm", "-f", desktopSrc}) + } + cmds = append(cmds, []string{"rm", "-f", + filepath.Join(homeDir(), ".local/share/applications", name+".desktop")}) + ops = append(ops, Op{Label: "Remove " + name, Cmds: cmds}) + } + case "binary": + if fields, ok := binaries[name]; ok { + installPath := expand(fields["install"]) + ops = append(ops, Op{ + Label: "Remove " + name, + Cmds: [][]string{ + {"rm", "-f", installPath}, + {"rm", "-f", filepath.Join(homeDir(), ".local/share/applications", name+".desktop")}, + }, + }) + } + case "windows": + for _, item := range windowsItems { + if item[0] == name { + ops = append(ops, Op{ + Label: "Remove " + name, + Cmds: [][]string{{"rm", "-rf", item[1]}}, + }) + break + } + } + } + } + } + return ops +} + +// ── Output window ───────────────────────────────────────────────────────────── + +type outputWin struct { + *gtk.Window + buf *gtk.TextBuffer + tv *gtk.TextView + endMark *gtk.TextMark + closeBtn *gtk.Button +} + +func newOutputWin(ops []Op, onDone func()) *outputWin { + w := &outputWin{Window: gtk.NewWindow()} + w.SetTitle("Applying Changes") + w.SetDefaultSize(680, 500) + + toolbar := adw.NewToolbarView() + w.SetChild(toolbar) + + header := adw.NewHeaderBar() + header.SetShowEndTitleButtons(false) + toolbar.AddTopBar(header) + + w.closeBtn = gtk.NewButtonWithLabel("Close") + w.closeBtn.SetSensitive(false) + w.closeBtn.ConnectClicked(func() { w.Close() }) + header.PackEnd(w.closeBtn) + + scroll := gtk.NewScrolledWindow() + scroll.SetPolicy(gtk.PolicyAutomatic, gtk.PolicyAlways) + scroll.SetVExpand(true) + scroll.SetHExpand(true) + + w.buf = gtk.NewTextBuffer(nil) + endIter := w.buf.EndIter() + w.endMark = w.buf.CreateMark("end", &endIter, false) + + w.tv = gtk.NewTextView() + w.tv.SetBuffer(w.buf) + w.tv.SetEditable(false) + w.tv.SetCursorVisible(false) + w.tv.SetMonospace(true) + w.tv.SetLeftMargin(12) + w.tv.SetRightMargin(12) + w.tv.SetTopMargin(8) + w.tv.SetBottomMargin(8) + w.tv.SetWrapMode(gtk.WrapWordChar) + scroll.SetChild(w.tv) + toolbar.SetContent(scroll) + + go w.run(ops, onDone) + return w +} + +func (w *outputWin) append(text string) { + glib.IdleAdd(func() bool { + iter := w.buf.EndIter() + w.buf.Insert(&iter, text) + w.tv.ScrollToMark(w.endMark, 0, false, 0, 1) + return false + }) +} + +func (w *outputWin) runCmd(cmd, env []string) bool { + w.append(fmt.Sprintf("$ %s\n", strings.Join(cmd, " "))) + + c := exec.Command(cmd[0], cmd[1:]...) + c.Dir = hampackDir() + if env != nil { + c.Env = env + } + + pr, pw := io.Pipe() + c.Stdout = pw + c.Stderr = pw + + scanDone := make(chan struct{}) + go func() { + defer close(scanDone) + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + w.append(scanner.Text() + "\n") + } + }() + + if err := c.Start(); err != nil { + pw.Close() + <-scanDone + pr.Close() + w.append(fmt.Sprintf("Error: %v\n", err)) + return false + } + + err := c.Wait() + pw.Close() + <-scanDone + pr.Close() + + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + w.append(fmt.Sprintf("[exit %d]\n", exitErr.ExitCode())) + } + return false + } + return true +} + +func (w *outputWin) run(ops []Op, onDone func()) { + success := true + for _, op := range ops { + w.append(fmt.Sprintf("\n── %s ──\n", op.Label)) + for _, cmd := range op.Cmds { + if !w.runCmd(cmd, op.Env) { + success = false + } + } + if op.Tmp != "" { + os.Remove(op.Tmp) + } + } + if success { + w.append("\n── Done ──\n") + } else { + w.append("\n── Completed with errors ──\n") + } + glib.IdleAdd(func() bool { + w.closeBtn.SetSensitive(true) + onDone() + return false + }) +} + +// ── Main window ─────────────────────────────────────────────────────────────── + +type mainWin struct { + *adw.ApplicationWindow + compiled map[string]map[string]string + binaries map[string]map[string]string + state map[string]bool + rows map[string]*gtk.Switch + applyBtn *gtk.Button +} + +func newMainWin(app *adw.Application) *mainWin { + w := &mainWin{ + ApplicationWindow: adw.NewApplicationWindow(&app.Application), + compiled: parseINI(filepath.Join(hampackDir(), "compile.conf")), + binaries: parseINI(filepath.Join(hampackDir(), "binaries.conf")), + state: loadState(), + rows: make(map[string]*gtk.Switch), + } + w.SetTitle("HamPack Manager") + w.SetDefaultSize(600, 720) + + toolbar := adw.NewToolbarView() + w.SetContent(toolbar) + + header := adw.NewHeaderBar() + toolbar.AddTopBar(header) + + w.applyBtn = gtk.NewButtonWithLabel("Apply Changes") + w.applyBtn.AddCSSClass("suggested-action") + w.applyBtn.ConnectClicked(func() { w.onApply() }) + header.PackEnd(w.applyBtn) + + page := adw.NewPreferencesPage() + toolbar.SetContent(page) + + utils, apps := parsePackages() + flatpaks := parseFlatpaks() + + windows := make([]string, len(windowsItems)) + for i, item := range windowsItems { + windows[i] = item[0] + } + + w.makeGroup(page, "System Utilities", "utility", utils) + w.makeGroup(page, "AUR Applications", "application", apps) + w.makeGroup(page, "Flatpaks", "flatpak", flatpaks) + w.makeGroup(page, "Compiled from Source", "compiled", sortedKeys(w.compiled)) + w.makeGroup(page, "Pre-built Binaries", "binary", sortedKeys(w.binaries)) + w.makeGroup(page, "Windows Apps (Wine)", "windows", windows) + + return w +} + +func (w *mainWin) makeGroup(page *adw.PreferencesPage, title, cat string, names []string) { + if len(names) == 0 { + return + } + group := adw.NewPreferencesGroup() + group.SetTitle(title) + page.Add(group) + + for _, name := range names { + key := cat + ":" + name + row := adw.NewActionRow() + row.SetTitle(name) + sw := gtk.NewSwitch() + sw.SetValign(gtk.AlignCenter) + sw.SetActive(w.state[key]) + row.AddSuffix(sw) + row.SetActivatableWidget(sw) + group.Add(row) + w.rows[key] = sw + } +} + +func (w *mainWin) onApply() { + newState := make(map[string]bool, len(w.rows)) + for k, sw := range w.rows { + newState[k] = sw.Active() + } + + ops := computeOps(w.state, newState, w.compiled, w.binaries) + + if len(ops) == 0 { + dlg := adw.NewAlertDialog("No changes", "Nothing to install or remove.") + dlg.AddResponse("ok", "OK") + dlg.Present(w) + return + } + + w.applyBtn.SetSensitive(false) + + out := newOutputWin(ops, func() { + w.state = newState + saveState(w.state) + w.applyBtn.SetSensitive(true) + }) + out.Present() +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +func main() { + app := adw.NewApplication("computer.young.HamPackManager", 0) + app.ConnectActivate(func() { + newMainWin(app).Present() + }) + os.Exit(app.Run(os.Args)) +} diff --git a/install-binaries.sh b/install-binaries.sh index 8e6c2f5..983e71a 100755 --- a/install-binaries.sh +++ b/install-binaries.sh @@ -2,7 +2,7 @@ # # Purpose: Download and install pre-built binaries from binaries.conf -CONF_FILE="$HOME/.local/share/HamPack/binaries.conf" +CONF_FILE="${HAMPACK_BINARIES_CONF:-$HOME/.local/share/HamPack/binaries.conf}" TMP_DIR="/tmp/hampack-build" VERSION_FILE="$HOME/.local/state/HamPack/.installed_versions" diff --git a/install-compiled.sh b/install-compiled.sh index ffccaad..9615789 100755 --- a/install-compiled.sh +++ b/install-compiled.sh @@ -2,7 +2,7 @@ # # Purpose : Compile and install applications from compile.conf -CONF_FILE="$HOME/.local/share/HamPack/compile.conf" +CONF_FILE="${HAMPACK_COMPILE_CONF:-$HOME/.local/share/HamPack/compile.conf}" TMP_DIR="/tmp/hampack-build" VERSION_FILE="$HOME/.local/state/HamPack/.installed_versions"