635 lines
17 KiB
Go
635 lines
17 KiB
Go
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
|
|
updateBtn *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)
|
|
|
|
w.updateBtn = gtk.NewButtonWithLabel("Run Update")
|
|
w.updateBtn.ConnectClicked(func() { w.onUpdate() })
|
|
header.PackStart(w.updateBtn)
|
|
|
|
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)
|
|
|
|
var actionRows []*adw.ActionRow
|
|
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
|
|
actionRows = append(actionRows, row)
|
|
}
|
|
|
|
expanded := true
|
|
chevron := gtk.NewImageFromIconName("pan-down-symbolic")
|
|
toggleBtn := gtk.NewButton()
|
|
toggleBtn.SetChild(chevron)
|
|
toggleBtn.AddCSSClass("flat")
|
|
toggleBtn.ConnectClicked(func() {
|
|
expanded = !expanded
|
|
for _, row := range actionRows {
|
|
row.SetVisible(expanded)
|
|
}
|
|
if expanded {
|
|
chevron.SetFromIconName("pan-down-symbolic")
|
|
} else {
|
|
chevron.SetFromIconName("pan-end-symbolic")
|
|
}
|
|
})
|
|
group.SetHeaderSuffix(toggleBtn)
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
func (w *mainWin) onUpdate() {
|
|
w.updateBtn.SetSensitive(false)
|
|
w.applyBtn.SetSensitive(false)
|
|
|
|
ops := []Op{{
|
|
Label: "Run Update",
|
|
Cmds: [][]string{{"bash", "-c", "wget -qO- https://gitea.young.computer/david/hampack/raw/branch/main/install.sh | bash"}},
|
|
Env: append(os.Environ(), "TERM=xterm"),
|
|
}}
|
|
|
|
newOutputWin(ops, func() {
|
|
w.updateBtn.SetSensitive(true)
|
|
w.applyBtn.SetSensitive(true)
|
|
}).Present()
|
|
}
|
|
|
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
func main() {
|
|
app := adw.NewApplication("computer.young.HamPack", 0)
|
|
app.ConnectActivate(func() {
|
|
gtk.WindowSetDefaultIconName("computer.young.HamPack")
|
|
newMainWin(app).Present()
|
|
})
|
|
os.Exit(app.Run(os.Args))
|
|
}
|