diff --git a/cmd/appherder-gui/main.go b/cmd/appherder-gui/main.go new file mode 100644 index 0000000..4e3f835 --- /dev/null +++ b/cmd/appherder-gui/main.go @@ -0,0 +1,35 @@ +//go:build gtk + +package main + +import ( + "fmt" + "os" + + "github.com/alyraffauf/appherder/internal/appherder" + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gio/v2" +) + +var version = "dev" + +func main() { + app, err := appherder.NewApp() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + appID := "io.github.alyraffauf.AppHerder" + gtkApp := adw.NewApplication(appID, gio.ApplicationFlagsNone) + gtkApp.SetVersion(version) + + gtkApp.ConnectActivate(func() { + window := newMainWindow(gtkApp, app) + window.Present() + }) + + if code := gtkApp.Run(nil); code > 0 { + os.Exit(code) + } +} diff --git a/cmd/appherder-gui/main_window.go b/cmd/appherder-gui/main_window.go new file mode 100644 index 0000000..3b66cff --- /dev/null +++ b/cmd/appherder-gui/main_window.go @@ -0,0 +1,609 @@ +//go:build gtk + +package main + +import ( + "context" + "fmt" + "net/url" + "path/filepath" + "strings" + + "github.com/alyraffauf/appherder/internal/appherder" + "github.com/diamondburned/gotk4-adwaita/pkg/adw" + "github.com/diamondburned/gotk4/pkg/gio/v2" + "github.com/diamondburned/gotk4/pkg/glib/v2" + "github.com/diamondburned/gotk4/pkg/gtk/v4" + "github.com/diamondburned/gotk4/pkg/pango" +) + +type mainWindow struct { + *adw.ApplicationWindow + + app appherder.App + split *adw.NavigationSplitView + apps []appherder.AppInfo + list *gtk.ListBox + installBtn *gtk.MenuButton + menuBtn *gtk.MenuButton + busy int +} + +func newMainWindow(gtkApp *adw.Application, app appherder.App) *mainWindow { + win := &mainWindow{ + ApplicationWindow: adw.NewApplicationWindow(>kApp.Application), + app: app, + split: adw.NewNavigationSplitView(), + list: gtk.NewListBox(), + installBtn: gtk.NewMenuButton(), + menuBtn: gtk.NewMenuButton(), + } + + win.SetTitle("AppHerder") + win.SetDefaultSize(760, 520) + win.installActions() + + win.split.SetShowContent(true) + win.split.SetMinSidebarWidth(260) + win.split.SetMaxSidebarWidth(300) + win.split.SetSidebar(adw.NewNavigationPage(win.sidebarView(), "AppHerder")) + win.split.SetContent(adw.NewNavigationPage(emptyDetailsPage(), "AppHerder")) + win.installBreakpoints() + + win.SetContent(win.split) + + win.loadApps() + return win +} + +func (w *mainWindow) installBreakpoints() { + condition := adw.NewBreakpointConditionLength(adw.BreakpointConditionMaxWidth, 600, adw.LengthUnitSp) + breakpoint := adw.NewBreakpoint(condition) + breakpoint.AddSetterDirect(w.split.Object, "collapsed", glib.NewValue(true)) + breakpoint.AddSetterDirect(w.split.Object, "show-content", glib.NewValue(false)) + w.AddBreakpoint(breakpoint) +} + +func (w *mainWindow) sidebarView() *adw.ToolbarView { + title := adw.NewWindowTitle("AppHerder", "") + header := adw.NewHeaderBar() + header.SetShowEndTitleButtons(false) + header.SetTitleWidget(title) + header.PackStart(w.installBtn) + header.PackEnd(w.menuBtn) + + w.list.AddCSSClass("navigation-sidebar") + w.list.SetSelectionMode(gtk.SelectionSingle) + w.list.ConnectRowActivated(func(row *gtk.ListBoxRow) { + w.activateAppRow(row) + }) + w.list.ConnectRowSelected(func(row *gtk.ListBoxRow) { + w.selectAppRow(row) + }) + + scroller := gtk.NewScrolledWindow() + scroller.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic) + scroller.SetChild(w.list) + scroller.SetVExpand(true) + + toolbar := adw.NewToolbarView() + toolbar.AddTopBar(header) + toolbar.SetContent(scroller) + return toolbar +} + +func emptyDetailsPage() *adw.ToolbarView { + header := adw.NewHeaderBar() + + empty := adw.NewStatusPage() + empty.SetIconName("application-x-executable-symbolic") + empty.SetTitle("No AppImage Selected") + empty.SetDescription("Select an installed AppImage to view details.") + + toolbar := adw.NewToolbarView() + toolbar.AddTopBar(header) + toolbar.SetContent(empty) + return toolbar +} + +func (w *mainWindow) installActions() { + w.installBtn.SetIconName("list-add-symbolic") + w.installBtn.SetTooltipText("Install AppImage") + installMenu := gio.NewMenu() + installMenu.Append("Install from File", "win.install-file") + installMenu.Append("Install from URL", "win.install-url") + w.installBtn.SetMenuModel(installMenu) + + w.menuBtn.SetIconName("open-menu-symbolic") + w.menuBtn.SetTooltipText("Main Menu") + + menu := gio.NewMenu() + updateSection := gio.NewMenu() + updateSection.Append("Update All", "win.apply-upgrades") + menu.AppendSection("", updateSection) + aboutSection := gio.NewMenu() + aboutSection.Append("About AppHerder", "win.about") + menu.AppendSection("", aboutSection) + w.menuBtn.SetMenuModel(menu) + + w.addAction("apply-upgrades", w.applyUpgrades) + w.addAction("install-file", w.promptInstallFile) + w.addAction("install-url", w.promptInstallURL) + w.addAction("about", w.showAbout) +} + +func (w *mainWindow) addAction(name string, activate func()) { + action := gio.NewSimpleAction(name, nil) + action.ConnectActivate(func(parameter *glib.Variant) { + activate() + }) + w.AddAction(action) +} + +func (w *mainWindow) showAbout() { + about := adw.NewAboutDialog() + about.SetApplicationName("AppHerder") + about.SetApplicationIcon("application-x-executable-symbolic") + about.SetVersion(version) + about.SetDeveloperName("Aly Raffauf") + about.SetDevelopers([]string{"Aly Raffauf"}) + about.SetComments("Manage AppImages installed in your home directory.") + about.SetWebsite("https://github.com/alyraffauf/appherder") + about.SetIssueURL("https://github.com/alyraffauf/appherder/issues") + about.SetLicenseType(gtk.LicenseGPL30) + about.Present(w) +} + +func (w *mainWindow) loadApps() { + w.run("Refreshing app list", func() (string, error) { + infos, err := w.app.List() + if err != nil { + return "", err + } + w.idle(func() { + w.renderApps(infos) + }) + return "", nil + }) +} + +func (w *mainWindow) renderApps(infos []appherder.AppInfo) { + w.apps = infos + w.list.RemoveAll() + if len(infos) == 0 { + w.apps = nil + w.split.SetContent(adw.NewNavigationPage(emptyDetailsPage(), "AppHerder")) + return + } + for i, info := range infos { + w.list.Append(appListRow(info)) + if i == 0 { + w.showDetails(info, false) + } + } + if first := w.list.RowAtIndex(0); first != nil { + w.list.SelectRow(first) + } +} + +func (w *mainWindow) activateAppRow(row *gtk.ListBoxRow) { + w.showDetailsForRow(row, true) +} + +func (w *mainWindow) selectAppRow(row *gtk.ListBoxRow) { + w.showDetailsForRow(row, false) +} + +func (w *mainWindow) showDetailsForRow(row *gtk.ListBoxRow, reveal bool) { + if row == nil { + return + } + index := row.Index() + if index < 0 || index >= len(w.apps) { + return + } + w.showDetails(w.apps[index], reveal) +} + +func appListRow(info appherder.AppInfo) *gtk.ListBoxRow { + row := gtk.NewListBoxRow() + row.SetActivatable(true) + row.SetSelectable(true) + + box := gtk.NewBox(gtk.OrientationHorizontal, 12) + box.SetMarginTop(8) + box.SetMarginBottom(8) + box.SetMarginStart(12) + box.SetMarginEnd(12) + + icon := appIcon(info, 36) + icon.SetVAlign(gtk.AlignCenter) + box.Append(icon) + + name := gtk.NewLabel(info.Name) + name.SetXAlign(0) + name.SetEllipsize(pango.EllipsizeEnd) + name.SetLines(1) + name.SetHExpand(true) + name.SetVAlign(gtk.AlignCenter) + box.Append(name) + + row.SetChild(box) + return row +} + +func appSummary(info appherder.AppInfo) string { + if info.Filename != "" { + return info.Filename + } + return info.AppID +} + +func (w *mainWindow) showDetails(info appherder.AppInfo, reveal bool) { + w.split.SetContent(adw.NewNavigationPage(w.appDetailsView(info), info.Name)) + if reveal { + w.split.SetShowContent(true) + } +} + +func (w *mainWindow) appDetailsView(info appherder.AppInfo) *adw.ToolbarView { + header := adw.NewHeaderBar() + + hero := gtk.NewBox(gtk.OrientationVertical, 8) + hero.SetMarginTop(24) + hero.SetMarginBottom(20) + hero.SetMarginStart(24) + hero.SetMarginEnd(24) + hero.SetHAlign(gtk.AlignCenter) + hero.Append(appIcon(info, 88)) + + summary := gtk.NewBox(gtk.OrientationVertical, 4) + summary.SetHAlign(gtk.AlignCenter) + + name := gtk.NewLabel(info.Name) + name.AddCSSClass("title-1") + name.SetEllipsize(pango.EllipsizeEnd) + name.SetMaxWidthChars(28) + name.SetLines(1) + + subtitle := gtk.NewLabel(appSummary(info)) + subtitle.SetWrap(true) + subtitle.AddCSSClass("dim-label") + subtitle.SetEllipsize(pango.EllipsizeEnd) + subtitle.SetMaxWidthChars(34) + subtitle.SetLines(1) + + summary.Append(name) + summary.Append(subtitle) + + actions := gtk.NewBox(gtk.OrientationHorizontal, 8) + actions.SetMarginTop(10) + + launch := gtk.NewButtonWithLabel("Launch") + launch.AddCSSClass("pill") + launch.AddCSSClass("suggested-action") + launch.ConnectClicked(func() { w.launchApp(info) }) + actions.Append(launch) + + remove := gtk.NewButtonWithLabel("Remove") + remove.AddCSSClass("pill") + remove.AddCSSClass("destructive-action") + remove.ConnectClicked(func() { w.confirmUninstall(info) }) + actions.Append(remove) + + summary.Append(actions) + hero.Append(summary) + + details := adw.NewPreferencesGroup() + details.SetTitle("Details") + + for _, field := range []struct { + label string + value string + }{ + {"Version", orDash(info.Version)}, + {"Size", sizeOrDash(info.Size)}, + {"Update Source", sourceLabel(info.Source)}, + {"Signature", signatureLabel(info.Signature)}, + {"App ID", info.AppID}, + {"File", orDash(info.Filename)}, + } { + row := adw.NewActionRow() + row.SetTitle(field.label) + row.SetSubtitle(field.value) + details.Add(row) + } + + content := gtk.NewBox(gtk.OrientationVertical, 0) + content.Append(hero) + + clamp := adw.NewClamp() + clamp.SetMaximumSize(560) + clamp.SetTighteningThreshold(520) + clamp.SetChild(details) + clamp.SetMarginStart(24) + clamp.SetMarginEnd(24) + clamp.SetMarginBottom(24) + content.Append(clamp) + + scroller := gtk.NewScrolledWindow() + scroller.SetChild(content) + scroller.SetVExpand(true) + + toolbar := adw.NewToolbarView() + toolbar.AddTopBar(header) + toolbar.SetContent(scroller) + return toolbar +} + +func appIcon(info appherder.AppInfo, size int) *gtk.Image { + if info.Icon != "" && strings.HasPrefix(info.Icon, "/") { + image := gtk.NewImageFromFile(info.Icon) + image.SetPixelSize(size) + image.SetSizeRequest(size, size) + return image + } + iconName := info.Icon + if iconName == "" { + iconName = "application-x-executable-symbolic" + } + image := gtk.NewImageFromIconName(iconName) + image.SetPixelSize(size) + image.SetSizeRequest(size, size) + return image +} + +func (w *mainWindow) applyUpgrades() { + w.run("Installing updates", func() (string, error) { + checks, err := w.app.CheckUpgrades(context.Background()) + if err != nil { + return "", err + } + applied := w.app.ApplyUpgrades(context.Background(), checks) + upgraded, failed := summarizeApplied(applied) + w.idle(w.loadApps) + if upgraded == 0 && failed == 0 { + return "Everything is up to date", nil + } + return fmt.Sprintf("%d app%s upgraded, %d upgrade%s failed", upgraded, plural(upgraded), failed, plural(failed)), nil + }) +} + +func (w *mainWindow) launchApp(info appherder.AppInfo) { + w.run("Launching "+info.Name, func() (string, error) { + if err := w.app.Launch(appKey(info)); err != nil { + return "", err + } + return "", nil + }) +} + +func (w *mainWindow) promptInstallFile() { + dialog := gtk.NewFileDialog() + dialog.SetTitle("Install AppImage") + + filter := gtk.NewFileFilter() + filter.SetName("AppImages") + filter.AddSuffix("appimage") + filter.AddSuffix("AppImage") + + allFiles := gtk.NewFileFilter() + allFiles.SetName("All Files") + allFiles.AddPattern("*") + + filters := gio.NewListStore(gtk.GTypeFileFilter) + filters.Append(filter.Object) + filters.Append(allFiles.Object) + dialog.SetFilters(filters) + dialog.SetDefaultFilter(filter) + + dialog.Open(context.Background(), &w.Window, func(result gio.AsyncResulter) { + file, err := dialog.OpenFinish(result) + if err != nil || file == nil { + return + } + path := file.Path() + if path == "" { + w.showError("Cannot Install File", "Selected file has no local path.") + return + } + w.install(path) + }) +} + +func (w *mainWindow) promptInstallURL() { + entry := gtk.NewEntry() + entry.SetPlaceholderText("https://example.com/app.AppImage") + entry.SetHExpand(true) + + dialog := adw.NewAlertDialog("Install from URL", "Enter an HTTP or HTTPS AppImage URL.") + dialog.SetExtraChild(entry) + dialog.AddResponse("cancel", "Cancel") + dialog.AddResponse("install", "Install") + dialog.SetCloseResponse("cancel") + dialog.SetDefaultResponse("install") + dialog.ConnectResponse(func(response string) { + if response != "install" { + return + } + target := strings.TrimSpace(entry.Text()) + if target == "" { + w.showError("Cannot Install URL", "Enter a URL to install.") + return + } + if !looksLikeURL(target) { + w.showError("Cannot Install URL", "Enter an HTTP or HTTPS URL.") + return + } + w.install(target) + }) + dialog.Present(w) +} + +func (w *mainWindow) install(target string) { + w.run("Installing AppImage", func() (string, error) { + var name string + var err error + if looksLikeURL(target) { + name, err = w.app.InstallFromURL(context.Background(), target) + } else { + name, err = w.app.Install(target) + } + if err != nil { + return "", err + } + w.idle(w.loadApps) + return fmt.Sprintf("Installed %s", name), nil + }) +} + +func (w *mainWindow) confirmUninstall(info appherder.AppInfo) { + dialog := adw.NewAlertDialog("Remove "+info.Name+"?", "This removes the AppImage, launcher, icon, and appherder metadata.") + dialog.AddResponse("cancel", "Cancel") + dialog.AddResponse("remove", "Remove") + dialog.SetCloseResponse("cancel") + dialog.SetDefaultResponse("cancel") + dialog.SetResponseAppearance("remove", adw.ResponseDestructive) + dialog.ConnectResponse(func(response string) { + if response == "remove" { + w.uninstall(info) + } + }) + dialog.Present(w) +} + +func (w *mainWindow) uninstall(info appherder.AppInfo) { + w.run("Removing "+info.Name, func() (string, error) { + if err := w.app.Uninstall(appKey(info), false); err != nil { + return "", err + } + w.idle(w.loadApps) + return "Removed " + info.Name, nil + }) +} + +func (w *mainWindow) run(_ string, fn func() (string, error)) { + w.setBusy(1) + go func() { + _, err := fn() + w.idle(func() { + w.setBusy(-1) + if err != nil { + w.showError("Operation Failed", err.Error()) + return + } + }) + }() +} + +func (w *mainWindow) setBusy(delta int) { + w.busy += delta + if w.busy < 0 { + w.busy = 0 + } + sensitive := w.busy == 0 + w.installBtn.SetSensitive(sensitive) + w.menuBtn.SetSensitive(sensitive) +} + +func (w *mainWindow) idle(fn func()) { + glib.IdleAdd(fn) +} + +func (w *mainWindow) showError(title, message string) { + dialog := adw.NewAlertDialog(title, message) + dialog.AddResponse("close", "Close") + dialog.SetCloseResponse("close") + dialog.SetDefaultResponse("close") + dialog.Present(w) +} + +func looksLikeURL(s string) bool { + parsed, err := url.Parse(s) + return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" +} + +func summarizeApplied(applied []appherder.UpgradeApplied) (upgraded, failed int) { + for _, app := range applied { + if app.Err != nil { + failed++ + } else { + upgraded++ + } + } + return upgraded, failed +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} + +func humanSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for scaled := bytes / unit; scaled >= unit; scaled /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +func appKey(info appherder.AppInfo) string { + if info.AppID != "" { + return info.AppID + } + if info.Filename == "" { + return appherder.NormalizeAppName(info.Name) + } + return strings.TrimSuffix(filepath.Base(info.Filename), filepath.Ext(info.Filename)) +} + +func sizeOrDash(bytes int64) string { + if bytes <= 0 { + return "-" + } + return humanSize(bytes) +} + +func sourceLabel(source string) string { + switch source { + case "", "none": + return "No update source" + case "github": + return "GitHub" + case "gitlab": + return "GitLab" + case "zsync": + return "zsync" + case "static": + return "Static URL" + default: + return source + } +} + +func signatureLabel(signature string) string { + switch signature { + case "pinned": + return "Pinned signature" + case "signed": + return "Signed" + case "", "none": + return "Unsigned" + default: + return signature + } +} diff --git a/flake.nix b/flake.nix index 8c5b50d..50ec178 100644 --- a/flake.nix +++ b/flake.nix @@ -33,8 +33,18 @@ packages = with pkgs; [ go dwarfs + pkg-config + glib + gobject-introspection + gtk4 + libadwaita self.formatter.${system} ]; + + shellHook = '' + export GOCACHE="''${XDG_CACHE_HOME:-$HOME/.cache}/appherder/go-build" + mkdir -p "$GOCACHE" + ''; }; } ); @@ -42,9 +52,16 @@ formatter = forEachSupportedSystem ({pkgs, ...}: pkgs.alejandra); packages = forEachSupportedSystem ( - {pkgs, ...}: { - default = pkgs.callPackage ./package.nix {}; - } + {pkgs, ...}: let + appherder = pkgs.callPackage ./package.nix {}; + in + { + default = appherder; + inherit appherder; + } + // lib.optionalAttrs pkgs.stdenv.isLinux { + appherder-gui = pkgs.callPackage ./package-gui.nix {}; + } ); }; } diff --git a/go.mod b/go.mod index 7719c65..b21ba69 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,14 @@ require ( github.com/ProtonMail/go-crypto v1.4.1 github.com/adrg/xdg v0.5.3 github.com/alyraffauf/goxdgdesktop v0.1.0 + github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6 + github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a github.com/pelletier/go-toml/v2 v2.2.4 github.com/spf13/cobra v1.10.2 ) require ( + github.com/KarpelesLab/weak v0.1.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect @@ -21,6 +24,8 @@ require ( github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/ulikunitz/xz v0.5.15 // indirect + go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/sys v0.46.0 // indirect ) diff --git a/go.sum b/go.sum index 513098a..5dd731a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/CalebQ42/squashfs v1.4.1 h1:tBcFMQSRQvWcY50e9r9cv2uVzNf06fcUhly0LeZg8bI= github.com/CalebQ42/squashfs v1.4.1/go.mod h1:/As5wg6ScFFaab9SaNFNHyCOsd73Q5IFPOFJCVnwWzQ= +github.com/KarpelesLab/weak v0.1.1 h1:fNnlPo3aypS9tBzoEQluY13XyUfd/eWaSE/vMvo9s4g= +github.com/KarpelesLab/weak v0.1.1/go.mod h1:pzXsWs5f2bf+fpgHayTlBE1qJpO3MpJKo5sRaLu1XNw= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= @@ -11,6 +13,10 @@ github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6 h1:WzOC3KtvrC1hJMz3fbJBg0Ye50nt4Tafor+a/bBHNEA= +github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6/go.mod h1:ZzYiyPe0TqsukfPHi0sK/WwKzm0wIJdSRylLnuvAZNw= +github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a h1:dN2jYYZ71hFhoKFSn24pQdKWLZb/XDydBt8pEIkFjJo= +github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a/go.mod h1:O9K8+PGNFGJpAu8+u5D2Sn5Wae4hxWzHB+AeZNbV/2Q= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -38,8 +44,12 @@ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8 github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 h1:lGdhQUN/cnWdSH3291CUuxSEqc+AsGTiDxPP3r2J0l4= +go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/justfile b/justfile new file mode 100644 index 0000000..0aea9fe --- /dev/null +++ b/justfile @@ -0,0 +1,25 @@ +set dotenv-load + +GOFLAGS := "-trimpath" +GOCACHE := `printf '%s/appherder/go-build' "${XDG_CACHE_HOME:-$HOME/.cache}"` + +default: + just --list + +build: build-cli build-ui + +build-cli: + mkdir -p '{{GOCACHE}}' + nix develop -c env GOCACHE='{{GOCACHE}}' GOSUMDB=off go build {{GOFLAGS}} -o ./appherder ./cmd/appherder + +build-ui: + mkdir -p '{{GOCACHE}}' + nix develop -c env GOCACHE='{{GOCACHE}}' GOSUMDB=off go build {{GOFLAGS}} -tags gtk -o ./appherder-gui ./cmd/appherder-gui + +test: + mkdir -p '{{GOCACHE}}' + nix develop -c env GOCACHE='{{GOCACHE}}' GOSUMDB=off go test ./... + +test-ui: + mkdir -p '{{GOCACHE}}' + nix develop -c env GOCACHE='{{GOCACHE}}' GOSUMDB=off go test -tags gtk ./cmd/appherder-gui diff --git a/package-gui.nix b/package-gui.nix new file mode 100644 index 0000000..e659adb --- /dev/null +++ b/package-gui.nix @@ -0,0 +1,38 @@ +{ + buildGoModule, + glib, + gtk4, + lib, + libadwaita, + pkg-config, + wrapGAppsHook4, +}: let + version = "dev"; +in + buildGoModule { + pname = "appherder-gui"; + inherit version; + src = ./.; + vendorHash = "sha256-YoNtqb5dflJNCZBstAQxP458ktpUighC8uYuqFWjTyo="; + subPackages = ["cmd/appherder-gui"]; + tags = ["gtk"]; + ldflags = ["-X main.version=${version}"]; + + nativeBuildInputs = [ + pkg-config + wrapGAppsHook4 + ]; + + buildInputs = [ + glib + gtk4 + libadwaita + ]; + + meta = { + description = "GTK/libadwaita interface for AppHerder"; + license = lib.licenses.gpl3Only; + mainProgram = "appherder-gui"; + platforms = lib.platforms.linux; + }; + }