package gui import ( "context" "encoding/json" "errors" "fmt" "log" "net" "net/http" "os" "os/exec" "runtime" "strings" "syscall" "time" "lazy-update-manager/internal/config" "lazy-update-manager/internal/state" "lazy-update-manager/internal/updater" ) type Server struct { configPath string statePath string } type StatusResponse struct { Summary string `json:"summary"` Total int `json:"total"` IgnoredTotal int `json:"ignored_total"` Packages []updater.Package `json:"packages"` Warnings []string `json:"warnings"` Settings config.Config `json:"settings"` State state.Store `json:"state"` System SystemStatus `json:"system"` } type SystemStatus struct { AURHelper string `json:"aur_helper"` Terminal string `json:"terminal"` PacmanLocked bool `json:"pacman_locked"` DiskFree string `json:"disk_free"` DiskFreePercent int `json:"disk_free_percent"` Kernel string `json:"kernel"` } type ignoreRequest struct { Name string `json:"name"` Ignored bool `json:"ignored"` } func Run(configPath, statePath string, openBrowser bool) error { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return err } server := &Server{configPath: configPath, statePath: statePath} 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/ignore", server.ignore) 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, IgnoredPackages: cfg.IgnoredPackages, }) if err != nil { writeError(w, err, http.StatusInternalServerError) return } now := time.Now() store, err := state.Load(s.statePath) if err != nil { writeError(w, err, http.StatusInternalServerError) return } store.LastCheck = now store.LastSuccess = now store.LastUpdateCount = result.Total() store.LastSummary = result.Summary() if err := state.Save(s.statePath, store); 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(), IgnoredTotal: result.IgnoredTotal(), Packages: packages, Warnings: warnings, Settings: cfg, State: store, System: systemStatus(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) ignore(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req ignoreRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, err, http.StatusBadRequest) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { writeError(w, errors.New("package name is required"), http.StatusBadRequest) return } cfg, err := config.Load(s.configPath) if err != nil { writeError(w, err, http.StatusInternalServerError) return } cfg.IgnoredPackages = setIgnored(cfg.IgnoredPackages, req.Name, req.Ignored) if err := config.Save(s.configPath, cfg); err != nil { writeError(w, err, http.StatusInternalServerError) return } writeJSON(w, cfg) } 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 setIgnored(names []string, name string, ignored bool) []string { seen := map[string]bool{} next := []string{} for _, item := range names { item = strings.TrimSpace(item) if item == "" || item == name || seen[item] { continue } seen[item] = true next = append(next, item) } if ignored { next = append(next, name) } return next } func systemStatus(cfg config.Config) SystemStatus { terminal, _, _ := terminalCommand(cfg) return SystemStatus{ AURHelper: updater.AURHelper(), Terminal: terminal, PacmanLocked: fileExists("/var/lib/pacman/db.lck"), DiskFree: diskFree("/"), DiskFreePercent: diskFreePercent("/"), Kernel: kernelVersion(), } } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } func diskFree(path string) string { var stat syscall.Statfs_t if err := syscall.Statfs(path, &stat); err != nil { return "unknown" } bytes := stat.Bavail * uint64(stat.Bsize) return humanBytes(bytes) } func diskFreePercent(path string) int { var stat syscall.Statfs_t if err := syscall.Statfs(path, &stat); err != nil || stat.Blocks == 0 { return 0 } return int((stat.Bavail * 100) / stat.Blocks) } func humanBytes(bytes uint64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } value := float64(bytes) for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} { value = value / unit if value < unit { return fmt.Sprintf("%.1f %s", value, suffix) } } return fmt.Sprintf("%.1f PiB", value/unit) } func kernelVersion() string { out, err := exec.Command("uname", "-r").Output() if err != nil { return "unknown" } return strings.TrimSpace(string(out)) } 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) }