hampack app fixes

This commit is contained in:
David Young
2026-04-02 09:27:22 -06:00
parent 5afd4c077d
commit 453e7a7a9c
4 changed files with 17 additions and 5 deletions

8
hampack-src/go.mod Normal file
View File

@@ -0,0 +1,8 @@
module hampack
go 1.22
require (
github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085708-8e7f01e2c815
github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a
)

592
hampack-src/main.go Normal file
View File

@@ -0,0 +1,592 @@
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))
}