Files
LazyUpdateManager/internal/gui/server.go

389 lines
9.9 KiB
Go

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)
}