package main import ( "bufio" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "github.com/diamondburned/gotk4-adwaita/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 ────────────────────────────────────────────────────────────────────── // installedPacmanPackages returns a set of all installed pacman package names. func installedPacmanPackages() map[string]bool { out, err := exec.Command("pacman", "-Q").Output() if err != nil { return map[string]bool{} } installed := make(map[string]bool) for _, line := range strings.Split(string(out), "\n") { if fields := strings.Fields(line); len(fields) > 0 { installed[fields[0]] = true } } return installed } // installedFlatpaks returns a set of all installed Flatpak application IDs. func installedFlatpaks() map[string]bool { out, err := exec.Command("flatpak", "list", "--app", "--columns=application").Output() if err != nil { return map[string]bool{} } installed := make(map[string]bool) for _, line := range strings.Split(string(out), "\n") { if s := strings.TrimSpace(line); s != "" { installed[s] = true } } return installed } // detectInitialState checks actual installation status for every item. func detectInitialState( utils, apps, flatpaks []string, compiled, binaries map[string]map[string]string, ) map[string]bool { pacman := installedPacmanPackages() flatpak := installedFlatpaks() state := make(map[string]bool) for _, name := range utils { state["utility:"+name] = pacman[name] } for _, name := range apps { state["application:"+name] = pacman[name] } for _, id := range flatpaks { state["flatpak:"+id] = flatpak[id] } for name, fields := range compiled { path := expand(fields["install"]) _, err := os.Stat(path) state["compiled:"+name] = err == nil } for name, fields := range binaries { path := expand(fields["install"]) _, err := os.Stat(path) state["binary:"+name] = err == nil } for _, item := range windowsItems { _, err := os.Stat(item[1]) state["windows:"+item[0]] = err == nil } 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 { compiled := parseINI(filepath.Join(hampackDir(), "compile.conf")) binaries := parseINI(filepath.Join(hampackDir(), "binaries.conf")) utils, apps := parsePackages() flatpaks := parseFlatpaks() w := &mainWin{ ApplicationWindow: adw.NewApplicationWindow(&app.Application), compiled: compiled, binaries: binaries, state: detectInitialState(utils, apps, flatpaks, compiled, binaries), 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) 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.HamPack", 0) app.ConnectActivate(func() { newMainWin(app).Present() }) os.Exit(app.Run(os.Args)) }