hampack-manager
This commit is contained in:
8
hampack-manager-src/go.mod
Normal file
8
hampack-manager-src/go.mod
Normal file
@@ -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
|
||||
)
|
||||
535
hampack-manager-src/main.go
Normal file
535
hampack-manager-src/main.go
Normal file
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user