449 lines
11 KiB
Go
449 lines
11 KiB
Go
package gui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"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"`
|
|
}
|
|
|
|
type installRequest struct {
|
|
Packages []string `json:"packages"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var req installRequest
|
|
if r.Body != nil {
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
writeError(w, err, http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
name, args, err := terminalCommand(cfg, req.Packages)
|
|
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, nil)
|
|
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, packages []string) (string, []string, error) {
|
|
hold := ""
|
|
if cfg.KeepTerminalOpen {
|
|
hold = "; printf '\\nDone. Press enter to close... '; read _"
|
|
}
|
|
updateCommand := "lazy-update-manager update" + hold
|
|
if len(packages) > 0 {
|
|
selected := shellPackageList(packages)
|
|
if selected == "" {
|
|
return "", nil, errors.New("no valid packages selected")
|
|
}
|
|
updateCommand = "sudo pacman -S --needed " + selected + hold
|
|
if helper := updater.AURHelper(); helper != "" && cfg.CheckAUR {
|
|
updateCommand = helper + " -S --needed " + selected + hold
|
|
}
|
|
}
|
|
if helper := updater.AURHelper(); helper != "" && cfg.CheckAUR {
|
|
if len(packages) == 0 {
|
|
updateCommand = helper + " -Syu" + hold
|
|
}
|
|
}
|
|
|
|
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 shellPackageList(packages []string) string {
|
|
quoted := []string{}
|
|
seen := map[string]bool{}
|
|
for _, pkg := range packages {
|
|
pkg = strings.TrimSpace(pkg)
|
|
if pkg == "" || seen[pkg] || !validPackageName(pkg) {
|
|
continue
|
|
}
|
|
seen[pkg] = true
|
|
quoted = append(quoted, shellQuote(pkg))
|
|
}
|
|
return strings.Join(quoted, " ")
|
|
}
|
|
|
|
func validPackageName(value string) bool {
|
|
for _, r := range value {
|
|
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
|
|
continue
|
|
}
|
|
if strings.ContainsRune("@._+-", r) {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func shellQuote(value string) string {
|
|
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
|
}
|
|
|
|
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)
|
|
}
|