diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..701590a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,28 @@
+BINARY := lazy-update-manager
+PREFIX ?= $(HOME)/.local
+
+.PHONY: build install install-user-service enable-user-service test fmt clean
+
+build:
+ go build -buildvcs=false -o bin/$(BINARY) ./cmd/lazy-update-manager
+
+install: build
+ install -Dm755 bin/$(BINARY) $(PREFIX)/bin/$(BINARY)
+ install -Dm644 systemd/lazy-update-manager.desktop $(PREFIX)/share/applications/lazy-update-manager.desktop
+
+install-user-service: install
+ install -Dm644 systemd/lazy-update-manager.service $(HOME)/.config/systemd/user/lazy-update-manager.service
+ install -Dm644 systemd/lazy-update-manager.timer $(HOME)/.config/systemd/user/lazy-update-manager.timer
+ systemctl --user daemon-reload
+
+enable-user-service: install-user-service
+ systemctl --user enable --now lazy-update-manager.timer
+
+test:
+ go test ./...
+
+fmt:
+ gofmt -w ./cmd ./internal
+
+clean:
+ rm -rf bin
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b31dc75
--- /dev/null
+++ b/README.md
@@ -0,0 +1,89 @@
+# LazyUpdateManager
+
+LazyUpdateManager is a small update helper for Arch Linux and Hyprland. It checks for available package updates and sends a desktop notification at most once per week while updates are pending.
+
+## Features
+
+- Checks official repository updates with `checkupdates` when available, otherwise `pacman -Qu`
+- Checks AUR updates with `paru -Qua` or `yay -Qua` when either helper is installed
+- Sends notifications with `notify-send`, with `hyprctl notify` as fallback
+- Provides a graphical browser UI for checking and installing updates
+- Includes a systemd user timer that checks every two hours and reminds weekly
+- Provides an interactive `update` command
+
+## Requirements
+
+- Arch Linux
+- Go 1.22 or newer to build
+- `pacman-contrib` recommended for `checkupdates`
+- Optional: `paru` or `yay` for AUR updates
+- Optional: `libnotify` for `notify-send`
+
+## Build
+
+```sh
+make build
+```
+
+## Usage
+
+```sh
+./bin/lazy-update-manager status
+./bin/lazy-update-manager check
+./bin/lazy-update-manager check -quiet
+./bin/lazy-update-manager notify
+./bin/lazy-update-manager notify -force
+./bin/lazy-update-manager gui
+./bin/lazy-update-manager update
+```
+
+## Graphical Interface
+
+```sh
+./bin/lazy-update-manager gui
+```
+
+The GUI opens in your browser and lets you:
+
+- refresh the update list
+- start update installation in a terminal
+- enable or disable AUR checks
+- change the reminder interval
+- choose the terminal used for installing updates
+
+## Install
+
+```sh
+make install
+```
+
+This installs the binary to `~/.local/bin/lazy-update-manager`.
+
+It also installs a desktop launcher to `~/.local/share/applications/lazy-update-manager.desktop`.
+
+## Enable Weekly Reminder
+
+```sh
+make enable-user-service
+```
+
+The timer runs every two hours, but `lazy-update-manager notify` stores the last reminder timestamp in:
+
+```text
+~/.local/state/lazy-update-manager/state.json
+```
+
+That means you only get a reminder once per week while updates are available.
+
+Settings are stored in:
+
+```text
+~/.config/lazy-update-manager/config.json
+```
+
+## Check Timer
+
+```sh
+systemctl --user status lazy-update-manager.timer
+systemctl --user list-timers lazy-update-manager.timer
+```
diff --git a/cmd/lazy-update-manager/main.go b/cmd/lazy-update-manager/main.go
new file mode 100644
index 0000000..543229f
--- /dev/null
+++ b/cmd/lazy-update-manager/main.go
@@ -0,0 +1,255 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "lazy-update-manager/internal/config"
+ "lazy-update-manager/internal/gui"
+ "lazy-update-manager/internal/notify"
+ "lazy-update-manager/internal/state"
+ "lazy-update-manager/internal/updater"
+)
+
+func main() {
+ os.Exit(run(os.Args[1:]))
+}
+
+func run(args []string) int {
+ if len(args) == 0 {
+ args = []string{"status"}
+ }
+
+ switch args[0] {
+ case "status":
+ return status()
+ case "check":
+ return check(args[1:])
+ case "notify":
+ return notifyUpdates(args[1:])
+ case "gui":
+ return runGUI(args[1:])
+ case "update":
+ return update()
+ case "help", "-h", "--help":
+ usage()
+ return 0
+ default:
+ fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", args[0])
+ usage()
+ return 2
+ }
+}
+
+func status() int {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ cfg, err := config.Load(defaultConfigPath())
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+
+ result, err := updater.CheckWithOptions(ctx, updater.Options{CheckAUR: cfg.CheckAUR})
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+
+ printResult(result)
+ return 0
+}
+
+func check(args []string) int {
+ fs := flag.NewFlagSet("check", flag.ContinueOnError)
+ fs.SetOutput(os.Stderr)
+ quiet := fs.Bool("quiet", false, "only print the number of available updates")
+ if err := fs.Parse(args); err != nil {
+ return 2
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ cfg, err := config.Load(defaultConfigPath())
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+
+ result, err := updater.CheckWithOptions(ctx, updater.Options{CheckAUR: cfg.CheckAUR})
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+
+ if *quiet {
+ fmt.Println(result.Total())
+ return 0
+ }
+
+ printResult(result)
+ return 0
+}
+
+func notifyUpdates(args []string) int {
+ fs := flag.NewFlagSet("notify", flag.ContinueOnError)
+ fs.SetOutput(os.Stderr)
+ force := fs.Bool("force", false, "send a notification even if the weekly reminder was already shown")
+ if err := fs.Parse(args); err != nil {
+ return 2
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ cfg, err := config.Load(defaultConfigPath())
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+
+ result, err := updater.CheckWithOptions(ctx, updater.Options{CheckAUR: cfg.CheckAUR})
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ if result.Total() == 0 {
+ return 0
+ }
+
+ store, err := state.Load(defaultStatePath())
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+
+ now := time.Now()
+ reminderInterval := time.Duration(cfg.ReminderIntervalHours) * time.Hour
+ if !*force && now.Sub(store.LastReminder) < reminderInterval {
+ return 0
+ }
+
+ message := result.Summary()
+ if err := notify.Send("LazyUpdateManager", message); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+
+ store.LastReminder = now
+ store.LastUpdateCount = result.Total()
+ if err := state.Save(defaultStatePath(), store); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+
+ return 0
+}
+
+func runGUI(args []string) int {
+ fs := flag.NewFlagSet("gui", flag.ContinueOnError)
+ fs.SetOutput(os.Stderr)
+ noOpen := fs.Bool("no-open", false, "start the GUI server without opening a browser")
+ if err := fs.Parse(args); err != nil {
+ return 2
+ }
+
+ if err := gui.Run(defaultConfigPath(), !*noOpen); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ return 0
+}
+
+func update() int {
+ cfg, err := config.Load(defaultConfigPath())
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+
+ helper := updater.AURHelper()
+ if helper != "" && cfg.CheckAUR {
+ return runInteractive(helper, "-Syu")
+ }
+ return runInteractive("sudo", "pacman", "-Syu")
+}
+
+func runInteractive(name string, args ...string) int {
+ cmd := exec.Command(name, args...)
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ return exitErr.ExitCode()
+ }
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ return 0
+}
+
+func printResult(result updater.Result) {
+ fmt.Println(result.Summary())
+ for _, item := range result.Packages {
+ fmt.Printf("%-8s %s", item.Source, item.Name)
+ if item.Current != "" || item.Available != "" {
+ fmt.Printf(" %s -> %s", item.Current, item.Available)
+ }
+ fmt.Println()
+ }
+}
+
+func defaultStatePath() string {
+ stateHome := os.Getenv("XDG_STATE_HOME")
+ if stateHome == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return filepath.Join(".", "lazy-update-manager.json")
+ }
+ stateHome = filepath.Join(home, ".local", "state")
+ }
+ return filepath.Join(stateHome, "lazy-update-manager", "state.json")
+}
+
+func defaultConfigPath() string {
+ configHome := os.Getenv("XDG_CONFIG_HOME")
+ if configHome == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return filepath.Join(".", "lazy-update-manager.json")
+ }
+ configHome = filepath.Join(home, ".config")
+ }
+ return filepath.Join(configHome, "lazy-update-manager", "config.json")
+}
+
+func usage() {
+ fmt.Println(strings.TrimSpace(`
+LazyUpdateManager - Update helper for Arch / Hyprland
+
+Usage:
+ lazy-update-manager status
+ lazy-update-manager check [-quiet]
+ lazy-update-manager notify [-force]
+ lazy-update-manager gui [-no-open]
+ lazy-update-manager update
+
+Commands:
+ status Show available pacman and AUR updates
+ check Check updates, useful for scripts and status bars
+ notify Send a weekly desktop notification when updates exist
+ gui Start the graphical web interface
+ update Run paru/yay -Syu when available, otherwise sudo pacman -Syu
+`))
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..acaef69
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,65 @@
+package config
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+)
+
+type Config struct {
+ CheckAUR bool `json:"check_aur"`
+ ReminderIntervalHours int `json:"reminder_interval_hours"`
+ Terminal string `json:"terminal"`
+}
+
+func Default() Config {
+ return Config{
+ CheckAUR: true,
+ ReminderIntervalHours: 168,
+ Terminal: "auto",
+ }
+}
+
+func Load(path string) (Config, error) {
+ cfg := Default()
+
+ data, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return cfg, nil
+ }
+ if err != nil {
+ return cfg, err
+ }
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return cfg, err
+ }
+
+ cfg = normalize(cfg)
+ return cfg, nil
+}
+
+func Save(path string, cfg Config) error {
+ cfg = normalize(cfg)
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+ return os.WriteFile(path, data, 0o644)
+}
+
+func normalize(cfg Config) Config {
+ if cfg.ReminderIntervalHours <= 0 {
+ cfg.ReminderIntervalHours = Default().ReminderIntervalHours
+ }
+ if cfg.Terminal == "" {
+ cfg.Terminal = Default().Terminal
+ }
+ return cfg
+}
diff --git a/internal/gui/assets.go b/internal/gui/assets.go
new file mode 100644
index 0000000..cd7a48a
--- /dev/null
+++ b/internal/gui/assets.go
@@ -0,0 +1,437 @@
+package gui
+
+const indexHTML = `
+
+
+
+
+ LazyUpdateManager
+
+
+
+
+
+
+
LazyUpdateManager
+
Pruefe Updates...
+
+
+
+
+
+
+
+
+
+
+
Updates
+ 0
+
+
+
Keine Updates gefunden.
+
+
+
+ | Quelle |
+ Paket |
+ Aktuell |
+ Verfuegbar |
+
+
+
+
+
+
+
+
+
+
+
+`
+
+const appCSS = `
+:root {
+ color-scheme: dark;
+ --bg: #101114;
+ --panel: #191b20;
+ --panel-soft: #20242b;
+ --text: #f1f5f9;
+ --muted: #9aa4b2;
+ --line: #303640;
+ --accent: #42d392;
+ --accent-strong: #2ab67c;
+ --warn: #f5c451;
+ --danger: #ff6b6b;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+ color: var(--text);
+ background: var(--bg);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+.shell {
+ width: min(1180px, calc(100vw - 32px));
+ margin: 0 auto;
+ padding: 28px 0;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 18px;
+}
+
+h1,
+h2,
+p {
+ margin: 0;
+}
+
+h1 {
+ font-size: 28px;
+ font-weight: 720;
+}
+
+h2 {
+ font-size: 16px;
+ font-weight: 680;
+}
+
+#summary {
+ margin-top: 5px;
+ color: var(--muted);
+}
+
+.actions {
+ display: flex;
+ gap: 10px;
+}
+
+button {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ min-height: 40px;
+ padding: 0 14px;
+ color: var(--text);
+ background: var(--panel-soft);
+ cursor: pointer;
+}
+
+button:hover {
+ border-color: var(--accent);
+}
+
+button:disabled {
+ cursor: progress;
+ opacity: 0.65;
+}
+
+#installBtn,
+form button {
+ border-color: transparent;
+ background: var(--accent);
+ color: #07110d;
+ font-weight: 700;
+}
+
+#installBtn:hover,
+form button:hover {
+ background: var(--accent-strong);
+}
+
+.content {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 320px;
+ gap: 18px;
+ align-items: start;
+}
+
+.panel {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+}
+
+.panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 54px;
+ padding: 0 16px;
+ border-bottom: 1px solid var(--line);
+}
+
+#countBadge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 32px;
+ height: 26px;
+ border-radius: 999px;
+ color: #07110d;
+ background: var(--accent);
+ font-weight: 760;
+}
+
+.warnings {
+ margin: 14px 16px 0;
+ padding: 12px;
+ border: 1px solid rgba(245, 196, 81, 0.45);
+ border-radius: 8px;
+ color: var(--warn);
+ background: rgba(245, 196, 81, 0.08);
+}
+
+.empty {
+ padding: 44px 16px;
+ color: var(--muted);
+ text-align: center;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ vertical-align: middle;
+}
+
+th {
+ color: var(--muted);
+ font-size: 12px;
+ text-transform: uppercase;
+}
+
+td {
+ overflow-wrap: anywhere;
+}
+
+td:first-child {
+ width: 92px;
+ color: var(--accent);
+ font-weight: 700;
+}
+
+tr:last-child td {
+ border-bottom: 0;
+}
+
+form {
+ display: grid;
+ gap: 16px;
+ padding: 16px;
+}
+
+label {
+ display: grid;
+ gap: 8px;
+ color: var(--muted);
+}
+
+.toggle {
+ grid-template-columns: 18px 1fr;
+ align-items: center;
+ color: var(--text);
+}
+
+input,
+select {
+ width: 100%;
+ min-height: 40px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 0 10px;
+ color: var(--text);
+ background: var(--panel-soft);
+}
+
+input[type="checkbox"] {
+ width: 18px;
+ min-height: 18px;
+ accent-color: var(--accent);
+}
+
+small,
+#saveState {
+ color: var(--muted);
+}
+
+@media (max-width: 820px) {
+ .shell {
+ width: min(100vw - 20px, 1180px);
+ padding: 16px 0;
+ }
+
+ .topbar,
+ .content {
+ display: grid;
+ grid-template-columns: 1fr;
+ }
+
+ .actions {
+ grid-template-columns: 48px 1fr;
+ display: grid;
+ }
+
+ th:nth-child(3),
+ td:nth-child(3) {
+ display: none;
+ }
+}
+`
+
+const appJS = `
+const summary = document.querySelector("#summary");
+const countBadge = document.querySelector("#countBadge");
+const refreshBtn = document.querySelector("#refreshBtn");
+const installBtn = document.querySelector("#installBtn");
+const updatesTable = document.querySelector("#updatesTable");
+const updatesBody = document.querySelector("#updatesBody");
+const emptyState = document.querySelector("#emptyState");
+const warnings = document.querySelector("#warnings");
+const form = document.querySelector("#settingsForm");
+const checkAur = document.querySelector("#checkAur");
+const reminderHours = document.querySelector("#reminderHours");
+const terminal = document.querySelector("#terminal");
+const saveState = document.querySelector("#saveState");
+
+async function request(path, options = {}) {
+ const response = await fetch(path, {
+ headers: { "Content-Type": "application/json" },
+ ...options,
+ });
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Request failed");
+ }
+ return data;
+}
+
+function render(data) {
+ summary.textContent = data.summary;
+ countBadge.textContent = data.total;
+ installBtn.disabled = data.total === 0;
+
+ checkAur.checked = data.settings.check_aur;
+ reminderHours.value = data.settings.reminder_interval_hours;
+ terminal.value = data.settings.terminal || "auto";
+
+ updatesBody.replaceChildren();
+ for (const pkg of data.packages) {
+ const row = document.createElement("tr");
+ for (const value of [pkg.Source, pkg.Name, pkg.Current || "-", pkg.Available || "-"]) {
+ const cell = document.createElement("td");
+ cell.textContent = value;
+ row.appendChild(cell);
+ }
+ updatesBody.appendChild(row);
+ }
+
+ updatesTable.hidden = data.packages.length === 0;
+ emptyState.hidden = data.packages.length !== 0;
+
+ warnings.hidden = data.warnings.length === 0;
+ warnings.textContent = data.warnings.join("\\n");
+}
+
+async function loadStatus() {
+ refreshBtn.disabled = true;
+ summary.textContent = "Pruefe Updates...";
+ try {
+ render(await request("/api/status"));
+ } catch (error) {
+ summary.textContent = error.message;
+ } finally {
+ refreshBtn.disabled = false;
+ }
+}
+
+refreshBtn.addEventListener("click", loadStatus);
+
+installBtn.addEventListener("click", async () => {
+ installBtn.disabled = true;
+ try {
+ await request("/api/install", { method: "POST" });
+ summary.textContent = "Installation im Terminal gestartet.";
+ } catch (error) {
+ summary.textContent = error.message;
+ } finally {
+ installBtn.disabled = false;
+ }
+});
+
+form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ saveState.textContent = "Speichere...";
+ try {
+ await request("/api/settings", {
+ method: "PUT",
+ body: JSON.stringify({
+ check_aur: checkAur.checked,
+ reminder_interval_hours: Number(reminderHours.value),
+ terminal: terminal.value,
+ }),
+ });
+ saveState.textContent = "Gespeichert";
+ await loadStatus();
+ } catch (error) {
+ saveState.textContent = error.message;
+ }
+});
+
+loadStatus();
+`
diff --git a/internal/gui/server.go b/internal/gui/server.go
new file mode 100644
index 0000000..4f0ec6a
--- /dev/null
+++ b/internal/gui/server.go
@@ -0,0 +1,240 @@
+package gui
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "os/exec"
+ "runtime"
+ "strings"
+ "time"
+
+ "lazy-update-manager/internal/config"
+ "lazy-update-manager/internal/updater"
+)
+
+type Server struct {
+ configPath string
+}
+
+type StatusResponse struct {
+ Summary string `json:"summary"`
+ Total int `json:"total"`
+ Packages []updater.Package `json:"packages"`
+ Warnings []string `json:"warnings"`
+ Settings config.Config `json:"settings"`
+}
+
+func Run(configPath string, openBrowser bool) error {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return err
+ }
+
+ server := &Server{configPath: configPath}
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", server.index)
+ mux.HandleFunc("/app.css", server.css)
+ mux.HandleFunc("/app.js", server.js)
+ mux.HandleFunc("/api/status", server.status)
+ mux.HandleFunc("/api/settings", server.settings)
+ mux.HandleFunc("/api/install", server.install)
+
+ url := "http://" + listener.Addr().String()
+ fmt.Println("LazyUpdateManager GUI:", url)
+
+ if openBrowser {
+ go func() {
+ time.Sleep(250 * time.Millisecond)
+ if err := openURL(url); err != nil {
+ log.Printf("open browser: %v", err)
+ }
+ }()
+ }
+
+ return http.Serve(listener, mux)
+}
+
+func (s *Server) status(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet && r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ cfg, err := config.Load(s.configPath)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError)
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
+ defer cancel()
+
+ result, err := updater.CheckWithOptions(ctx, updater.Options{CheckAUR: cfg.CheckAUR})
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError)
+ return
+ }
+
+ packages := result.Packages
+ if packages == nil {
+ packages = []updater.Package{}
+ }
+ warnings := result.Warnings
+ if warnings == nil {
+ warnings = []string{}
+ }
+
+ writeJSON(w, StatusResponse{
+ Summary: result.Summary(),
+ Total: result.Total(),
+ Packages: packages,
+ Warnings: warnings,
+ Settings: cfg,
+ })
+}
+
+func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ cfg, err := config.Load(s.configPath)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, cfg)
+ case http.MethodPut:
+ var cfg config.Config
+ if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
+ writeError(w, err, http.StatusBadRequest)
+ return
+ }
+ if err := config.Save(s.configPath, cfg); err != nil {
+ writeError(w, err, http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, cfg)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *Server) install(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ cfg, err := config.Load(s.configPath)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError)
+ return
+ }
+
+ name, args, err := terminalCommand(cfg)
+ if err != nil {
+ writeError(w, err, http.StatusBadRequest)
+ return
+ }
+
+ cmd := exec.Command(name, args...)
+ if err := cmd.Start(); err != nil {
+ writeError(w, err, http.StatusInternalServerError)
+ return
+ }
+
+ writeJSON(w, map[string]string{"status": "started"})
+}
+
+func terminalCommand(cfg config.Config) (string, []string, error) {
+ updateCommand := "lazy-update-manager update; printf '\\nDone. Press enter to close... '; read _"
+ if helper := updater.AURHelper(); helper != "" && cfg.CheckAUR {
+ updateCommand = helper + " -Syu; printf '\\nDone. Press enter to close... '; read _"
+ }
+
+ candidates := []string{}
+ if cfg.Terminal != "" && cfg.Terminal != "auto" {
+ candidates = append(candidates, cfg.Terminal)
+ }
+ candidates = append(candidates, "foot", "kitty", "alacritty", "wezterm", "ghostty", "konsole", "xterm")
+
+ for _, terminal := range candidates {
+ if _, err := exec.LookPath(terminal); err != nil {
+ continue
+ }
+
+ switch terminal {
+ case "foot", "kitty", "ghostty":
+ return terminal, []string{"sh", "-lc", updateCommand}, nil
+ case "alacritty":
+ return terminal, []string{"-e", "sh", "-lc", updateCommand}, nil
+ case "wezterm":
+ return terminal, []string{"start", "--", "sh", "-lc", updateCommand}, nil
+ case "konsole":
+ return terminal, []string{"-e", "sh", "-lc", updateCommand}, nil
+ case "xterm":
+ return terminal, []string{"-e", "sh", "-lc", updateCommand}, nil
+ default:
+ return terminal, []string{"-e", "sh", "-lc", updateCommand}, nil
+ }
+ }
+
+ return "", nil, errors.New("no supported terminal found; set one in settings")
+}
+
+func openURL(url string) error {
+ switch runtime.GOOS {
+ case "linux":
+ return exec.Command("xdg-open", url).Start()
+ case "darwin":
+ return exec.Command("open", url).Start()
+ default:
+ return exec.Command("xdg-open", url).Start()
+ }
+}
+
+func writeJSON(w http.ResponseWriter, value any) {
+ securityHeaders(w)
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(value); err != nil {
+ log.Printf("write json: %v", err)
+ }
+}
+
+func writeError(w http.ResponseWriter, err error, status int) {
+ securityHeaders(w)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
+}
+
+func securityHeaders(w http.ResponseWriter) {
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+}
+
+func (s *Server) index(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ securityHeaders(w)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprint(w, strings.TrimSpace(indexHTML))
+}
+
+func (s *Server) css(w http.ResponseWriter, r *http.Request) {
+ securityHeaders(w)
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ fmt.Fprint(w, appCSS)
+}
+
+func (s *Server) js(w http.ResponseWriter, r *http.Request) {
+ securityHeaders(w)
+ w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+ fmt.Fprint(w, appJS)
+}
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
new file mode 100644
index 0000000..6c1d7a2
--- /dev/null
+++ b/internal/notify/notify.go
@@ -0,0 +1,18 @@
+package notify
+
+import (
+ "errors"
+ "os/exec"
+)
+
+func Send(title, body string) error {
+ if _, err := exec.LookPath("notify-send"); err == nil {
+ return exec.Command("notify-send", "-a", "LazyUpdateManager", title, body).Run()
+ }
+
+ if _, err := exec.LookPath("hyprctl"); err == nil {
+ return exec.Command("hyprctl", "notify", "1", "8000", "rgb(73daca)", title+": "+body).Run()
+ }
+
+ return errors.New("neither notify-send nor hyprctl is available")
+}
diff --git a/internal/state/state.go b/internal/state/state.go
new file mode 100644
index 0000000..44ca480
--- /dev/null
+++ b/internal/state/state.go
@@ -0,0 +1,43 @@
+package state
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+type Store struct {
+ LastReminder time.Time `json:"last_reminder"`
+ LastUpdateCount int `json:"last_update_count"`
+}
+
+func Load(path string) (Store, error) {
+ data, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return Store{}, nil
+ }
+ if err != nil {
+ return Store{}, err
+ }
+
+ var store Store
+ if err := json.Unmarshal(data, &store); err != nil {
+ return Store{}, err
+ }
+ return store, nil
+}
+
+func Save(path string, store Store) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(store, "", " ")
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+ return os.WriteFile(path, data, 0o644)
+}
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
new file mode 100644
index 0000000..5206478
--- /dev/null
+++ b/internal/updater/updater.go
@@ -0,0 +1,149 @@
+package updater
+
+import (
+ "bufio"
+ "context"
+ "os/exec"
+ "strings"
+)
+
+type Package struct {
+ Source string
+ Name string
+ Current string
+ Available string
+}
+
+type Result struct {
+ Packages []Package
+ Warnings []string
+}
+
+type Options struct {
+ CheckAUR bool
+}
+
+func (r Result) Total() int {
+ return len(r.Packages)
+}
+
+func (r Result) Summary() string {
+ total := r.Total()
+ switch total {
+ case 0:
+ return "No updates available."
+ case 1:
+ return "1 update available."
+ default:
+ return strings.TrimSpace(strings.Join([]string{itoa(total), "updates available."}, " "))
+ }
+}
+
+func Check(ctx context.Context) (Result, error) {
+ return CheckWithOptions(ctx, Options{CheckAUR: true})
+}
+
+func CheckWithOptions(ctx context.Context, opts Options) (Result, error) {
+ var result Result
+
+ pacman, err := checkPacman(ctx)
+ if err != nil {
+ result.Warnings = append(result.Warnings, err.Error())
+ }
+ result.Packages = append(result.Packages, pacman...)
+
+ if opts.CheckAUR {
+ aur, err := checkAUR(ctx)
+ if err != nil {
+ result.Warnings = append(result.Warnings, err.Error())
+ }
+ result.Packages = append(result.Packages, aur...)
+ }
+
+ return result, nil
+}
+
+func checkPacman(ctx context.Context) ([]Package, error) {
+ if _, err := exec.LookPath("checkupdates"); err == nil {
+ out, err := exec.CommandContext(ctx, "checkupdates").Output()
+ if err != nil {
+ return nil, nil
+ }
+ return parseUpdates("pacman", string(out)), nil
+ }
+
+ out, err := exec.CommandContext(ctx, "pacman", "-Qu").Output()
+ if err != nil {
+ return nil, nil
+ }
+ return parseUpdates("pacman", string(out)), nil
+}
+
+func checkAUR(ctx context.Context) ([]Package, error) {
+ helper := AURHelper()
+ if helper == "" {
+ return nil, nil
+ }
+
+ out, err := exec.CommandContext(ctx, helper, "-Qua").Output()
+ if err != nil {
+ return nil, nil
+ }
+ return parseUpdates("aur", string(out)), nil
+}
+
+func AURHelper() string {
+ for _, name := range []string{"paru", "yay"} {
+ if _, err := exec.LookPath(name); err == nil {
+ return name
+ }
+ }
+ return ""
+}
+
+func parseUpdates(source, output string) []Package {
+ var packages []Package
+ scanner := bufio.NewScanner(strings.NewReader(output))
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ packages = append(packages, parseLine(source, line))
+ }
+ return packages
+}
+
+func parseLine(source, line string) Package {
+ fields := strings.Fields(line)
+ pkg := Package{Source: source}
+ if len(fields) == 0 {
+ return pkg
+ }
+
+ pkg.Name = fields[0]
+ if len(fields) >= 4 && fields[2] == "->" {
+ pkg.Current = fields[1]
+ pkg.Available = fields[3]
+ return pkg
+ }
+ if len(fields) >= 3 && fields[1] == "->" {
+ pkg.Available = fields[2]
+ return pkg
+ }
+ return pkg
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ var buf [20]byte
+ i := len(buf)
+ for n > 0 {
+ i--
+ buf[i] = byte('0' + n%10)
+ n /= 10
+ }
+ return string(buf[i:])
+}
diff --git a/systemd/lazy-update-manager.desktop b/systemd/lazy-update-manager.desktop
new file mode 100644
index 0000000..730e91c
--- /dev/null
+++ b/systemd/lazy-update-manager.desktop
@@ -0,0 +1,8 @@
+[Desktop Entry]
+Type=Application
+Name=LazyUpdateManager
+Comment=Arch and Hyprland update helper
+Exec=lazy-update-manager gui
+Icon=system-software-update
+Terminal=false
+Categories=System;Utility;
diff --git a/systemd/lazy-update-manager.service b/systemd/lazy-update-manager.service
new file mode 100644
index 0000000..b83b3df
--- /dev/null
+++ b/systemd/lazy-update-manager.service
@@ -0,0 +1,6 @@
+[Unit]
+Description=LazyUpdateManager update reminder
+
+[Service]
+Type=oneshot
+ExecStart=%h/.local/bin/lazy-update-manager notify
diff --git a/systemd/lazy-update-manager.timer b/systemd/lazy-update-manager.timer
new file mode 100644
index 0000000..7b57782
--- /dev/null
+++ b/systemd/lazy-update-manager.timer
@@ -0,0 +1,11 @@
+[Unit]
+Description=Check for Arch updates and remind weekly
+
+[Timer]
+OnBootSec=10min
+OnUnitActiveSec=2h
+AccuracySec=15min
+Persistent=true
+
+[Install]
+WantedBy=timers.target