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.
+ + + + + + + + + + + +
+ + +
+
+ + +` + +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