515 lines
13 KiB
Go
515 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/kv/clearnup/backend/internal/apps"
|
|
"github.com/kv/clearnup/backend/internal/cleaner"
|
|
"github.com/kv/clearnup/backend/internal/platform"
|
|
"github.com/kv/clearnup/backend/internal/scanner"
|
|
)
|
|
|
|
//go:embed all:dist
|
|
var distFS embed.FS
|
|
|
|
const Port = ":36969"
|
|
|
|
func enableCors(w *http.ResponseWriter) {
|
|
(*w).Header().Set("Access-Control-Allow-Origin", "*")
|
|
(*w).Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
|
|
(*w).Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
|
}
|
|
|
|
func main() {
|
|
http.HandleFunc("/api/disk-usage", handleDiskUsage)
|
|
http.HandleFunc("/api/scan/user", handleScanUser)
|
|
http.HandleFunc("/api/scan/system", handleScanSystem) // Detailed list
|
|
http.HandleFunc("/api/scan/sizes", handleScanSizes) // Fast summary
|
|
http.HandleFunc("/api/scan/deepest", handleDeepestScan)
|
|
|
|
http.HandleFunc("/api/scan/category", handleScanCategory)
|
|
http.HandleFunc("/api/purge", handlePurge)
|
|
http.HandleFunc("/api/empty-trash", handleEmptyTrash)
|
|
http.HandleFunc("/api/clear-cache", handleClearCache)
|
|
http.HandleFunc("/api/clean-docker", handleCleanDocker)
|
|
http.HandleFunc("/api/clean-xcode", handleCleanXcode)
|
|
http.HandleFunc("/api/clean-homebrew", handleCleanHomebrew)
|
|
http.HandleFunc("/api/system-info", handleSystemInfo)
|
|
http.HandleFunc("/api/estimates", handleCleaningEstimates)
|
|
|
|
// App Uninstaller
|
|
http.HandleFunc("/api/apps", handleScanApps)
|
|
http.HandleFunc("/api/apps/details", handleAppDetails)
|
|
http.HandleFunc("/api/apps/action", handleAppAction)
|
|
http.HandleFunc("/api/apps/uninstall", handleAppUninstall)
|
|
|
|
// Static File Serving (SPA Support)
|
|
// Check if we are running with embedded files or local dist
|
|
// Priority: Embedded (Production) -> Local dist (Dev/Preview)
|
|
|
|
// Try to get a sub-fs for "dist" from the embedded FS
|
|
distRoot, err := fs.Sub(distFS, "dist")
|
|
if err == nil {
|
|
fmt.Println("📂 Serving embedded static files")
|
|
// Check if it's actually populated (sanity check)
|
|
if _, err := distRoot.Open("index.html"); err == nil {
|
|
fsrv := http.FileServer(http.FS(distRoot))
|
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if filepath.Ext(r.URL.Path) == "" {
|
|
// Read index.html from embedded
|
|
index, _ := distRoot.Open("index.html")
|
|
stat, _ := index.Stat()
|
|
http.ServeContent(w, r, "index.html", stat.ModTime(), index.(io.ReadSeeker))
|
|
return
|
|
}
|
|
fsrv.ServeHTTP(w, r)
|
|
})
|
|
} else {
|
|
// Fallback to local ./dist if embedded is empty (e.g. dev mode without build)
|
|
if _, err := os.Stat("dist"); err == nil {
|
|
fmt.Println("📂 Serving static files from local ./dist")
|
|
fs := http.FileServer(http.Dir("dist"))
|
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if filepath.Ext(r.URL.Path) == "" {
|
|
http.ServeFile(w, r, "dist/index.html")
|
|
return
|
|
}
|
|
fs.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Printf("🚀 Antigravity Backend running on http://localhost%s\n", Port)
|
|
|
|
// Open Browser if not in development mode
|
|
if os.Getenv("APP_ENV") != "development" {
|
|
go platform.OpenBrowser("http://localhost" + Port)
|
|
}
|
|
|
|
if err := http.ListenAndServe(Port, nil); err != nil {
|
|
fmt.Printf("Server failed: %s\n", err)
|
|
}
|
|
}
|
|
|
|
type ScanRequest struct {
|
|
Category string `json:"category"` // "apps", "photos", "icloud", "docs", "system"
|
|
}
|
|
|
|
func handleScanCategory(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
var req ScanRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
targets := scanner.GetScanTargets(req.Category)
|
|
if len(targets) == 0 {
|
|
json.NewEncoder(w).Encode([]scanner.ScanResult{})
|
|
return
|
|
}
|
|
|
|
var allResults []scanner.ScanResult
|
|
for _, t := range targets {
|
|
if t == "" {
|
|
continue
|
|
}
|
|
res, _ := scanner.FindLargeFiles(t, 10*1024*1024) // 10MB
|
|
allResults = append(allResults, res...)
|
|
}
|
|
|
|
// Sort
|
|
sort.Slice(allResults, func(i, j int) bool {
|
|
return allResults[i].Size > allResults[j].Size
|
|
})
|
|
if len(allResults) > 50 {
|
|
allResults = allResults[:50]
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(allResults)
|
|
}
|
|
|
|
func handleOpenSettings(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
if err := platform.OpenSettings(); err != nil {
|
|
fmt.Printf("Failed to open settings: %v\n", err)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func handleDiskUsage(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
usage, err := scanner.GetDiskUsage()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(usage)
|
|
}
|
|
|
|
func handleScanUser(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
files, err := scanner.ScanUserDocuments()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(files)
|
|
}
|
|
|
|
func handleScanSizes(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
sizes, err := scanner.GetCategorySizes()
|
|
if err != nil {
|
|
// Log but return empty
|
|
fmt.Println("Size scan error:", err)
|
|
json.NewEncoder(w).Encode(map[string]int64{})
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(sizes)
|
|
}
|
|
|
|
func handleScanSystem(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
files, err := scanner.ScanSystemData()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(files)
|
|
}
|
|
|
|
func handleDeepestScan(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
// Default to Documents for now, or parse body for path
|
|
home, _ := os.UserHomeDir()
|
|
target := filepath.Join(home, "Documents")
|
|
|
|
folders, err := scanner.FindHeavyFolders(target)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(folders)
|
|
}
|
|
|
|
type PurgeRequest struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
func handlePurge(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
var req PurgeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := cleaner.PurgePath(req.Path); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to purge: %s", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
|
}
|
|
|
|
func handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
if err := platform.EmptyTrash(); err != nil {
|
|
http.Error(w, fmt.Sprintf("Cannot empty trash: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
|
}
|
|
|
|
func handleClearCache(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
cachePath, err := platform.GetCachePath()
|
|
if err != nil {
|
|
http.Error(w, "Cannot get cache path", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get size before clearing
|
|
sizeBefore := scanner.GetDirectorySize(cachePath)
|
|
|
|
// Clear cache directories (keep the Caches folder itself if possible, or jus remove content)
|
|
entries, err := os.ReadDir(cachePath)
|
|
if err != nil {
|
|
http.Error(w, "Cannot read cache directory", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
itemPath := filepath.Join(cachePath, entry.Name())
|
|
os.RemoveAll(itemPath)
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(map[string]int64{"cleared": sizeBefore})
|
|
}
|
|
|
|
func handleCleanDocker(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
dockerPath, err := platform.GetDockerPath()
|
|
if err != nil {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"cleared": 0,
|
|
"message": "Docker not found",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Run docker system prune -af --volumes to clean images, containers, and volumes
|
|
cmd := exec.Command(dockerPath, "system", "prune", "-af", "--volumes")
|
|
output, err := cmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
message := string(output)
|
|
if message == "" || len(message) > 500 { // fallback if output is empty mapping or huge
|
|
message = err.Error()
|
|
}
|
|
// If the daemon isn't running, provide a helpful message
|
|
if strings.Contains(message, "connect: no such file or directory") || strings.Contains(message, "Is the docker daemon running") {
|
|
message = "Docker daemon is not running. Please start Docker to clean it."
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"cleared": 0,
|
|
"message": message,
|
|
})
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"cleared": 1,
|
|
"message": string(output),
|
|
})
|
|
}
|
|
|
|
func handleCleanXcode(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"cleared": 0, "message": "Could not find home directory"})
|
|
return
|
|
}
|
|
|
|
paths := []string{
|
|
filepath.Join(home, "Library/Developer/Xcode/DerivedData"),
|
|
filepath.Join(home, "Library/Developer/Xcode/iOS DeviceSupport"),
|
|
filepath.Join(home, "Library/Developer/Xcode/Archives"),
|
|
filepath.Join(home, "Library/Caches/com.apple.dt.Xcode"),
|
|
}
|
|
|
|
totalCleared := int64(0)
|
|
for _, p := range paths {
|
|
if stat, err := os.Stat(p); err == nil && stat.IsDir() {
|
|
size := scanner.GetDirectorySize(p)
|
|
if err := os.RemoveAll(p); err == nil {
|
|
totalCleared += size
|
|
}
|
|
}
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"cleared": totalCleared, "message": "Xcode Caches Cleared"})
|
|
}
|
|
|
|
func handleCleanHomebrew(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command("brew", "cleanup", "--prune=all")
|
|
output, err := cmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"cleared": 0,
|
|
"message": fmt.Sprintf("Brew cleanup failed: %s", string(output)),
|
|
})
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"cleared": 1,
|
|
"message": "Homebrew Cache Cleared",
|
|
})
|
|
}
|
|
|
|
func handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
info, err := platform.GetSystemInfo()
|
|
if err != nil {
|
|
http.Error(w, "Failed to get system info", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(info)
|
|
}
|
|
|
|
func handleCleaningEstimates(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
estimates, err := scanner.GetCleaningEstimates()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(estimates)
|
|
}
|
|
|
|
// App Uninstaller Handlers
|
|
|
|
func handleScanApps(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
appsList, err := apps.ScanApps()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(appsList)
|
|
}
|
|
|
|
func handleAppDetails(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
type AppDetailsRequest struct {
|
|
Path string `json:"path"`
|
|
BundleID string `json:"bundleID"`
|
|
}
|
|
var req AppDetailsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
details, err := apps.GetAppDetails(req.Path, req.BundleID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(details)
|
|
}
|
|
|
|
func handleAppAction(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Files []string `json:"files"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := apps.DeleteFiles(req.Files); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to delete files: %s", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
|
}
|
|
|
|
func handleAppUninstall(w http.ResponseWriter, r *http.Request) {
|
|
enableCors(&w)
|
|
if r.Method == "OPTIONS" {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Cmd string `json:"cmd"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := apps.RunUninstaller(req.Cmd); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to launch uninstaller: %s", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
|
}
|