kv-clearnup/backend/main.go
2026-01-31 21:17:55 +07:00

463 lines
11 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"github.com/kv/clearnup/backend/internal/apps"
"github.com/kv/clearnup/backend/internal/cleaner"
"github.com/kv/clearnup/backend/internal/scanner"
)
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/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)
fmt.Printf("🚀 Antigravity Backend running on http://localhost%s\n", 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
}
home, _ := os.UserHomeDir()
var targets []string
switch req.Category {
case "apps":
targets = []string{"/Applications", filepath.Join(home, "Applications")}
case "photos":
targets = []string{filepath.Join(home, "Pictures")}
case "icloud":
targets = []string{filepath.Join(home, "Library", "Mobile Documents")}
case "docs":
targets = []string{filepath.Join(home, "Documents")}
case "downloads":
targets = []string{filepath.Join(home, "Downloads")}
case "desktop":
targets = []string{filepath.Join(home, "Desktop")}
case "music":
targets = []string{filepath.Join(home, "Music")}
case "movies":
targets = []string{filepath.Join(home, "Movies")}
case "system":
targets = []string{filepath.Join(home, "Library", "Caches"), filepath.Join(home, "Library", "Logs"), filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")}
default:
json.NewEncoder(w).Encode([]scanner.ScanResult{})
return
}
// Reuse ScanPath logic inline or call a helper
// We'll just do a quick loop here since ScanPath in scanner.go was defined but I need to link it
// Actually I put ScanPath in scanner.go as FindLargeFiles wrapper.
var allResults []scanner.ScanResult
for _, t := range targets {
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
}
// Open Storage Settings
// macOS Ventura+: open x-apple.systempreferences:com.apple.settings.Storage
exec.Command("open", "x-apple.systempreferences:com.apple.settings.Storage").Run()
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
}
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
}
home, err := os.UserHomeDir()
if err != nil {
http.Error(w, "Cannot get home directory", http.StatusInternalServerError)
return
}
trashPath := filepath.Join(home, ".Trash")
// Get all items in trash and delete them
entries, err := os.ReadDir(trashPath)
if err != nil {
http.Error(w, "Cannot read trash", http.StatusInternalServerError)
return
}
for _, entry := range entries {
itemPath := filepath.Join(trashPath, entry.Name())
os.RemoveAll(itemPath)
}
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
func handleClearCache(w http.ResponseWriter, r *http.Request) {
enableCors(&w)
if r.Method == "OPTIONS" {
return
}
home, _ := os.UserHomeDir()
cachePath := filepath.Join(home, "Library", "Caches")
// Get size before clearing
sizeBefore := scanner.GetDirectorySize(cachePath)
// Clear cache directories (keep the Caches folder itself)
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
}
// Try to find docker executable
dockerPath, err := exec.LookPath("docker")
if err != nil {
// Try common locations
commonPaths := []string{
"/usr/local/bin/docker",
"/opt/homebrew/bin/docker",
"/Applications/Docker.app/Contents/Resources/bin/docker",
}
for _, p := range commonPaths {
if _, e := os.Stat(p); e == nil {
dockerPath = p
break
}
}
}
if dockerPath == "" {
json.NewEncoder(w).Encode(map[string]interface{}{
"cleared": 0,
"message": "Docker not found in PATH or common locations",
})
return
}
// Run docker system prune -af
cmd := exec.Command(dockerPath, "system", "prune", "-af")
output, err := cmd.CombinedOutput()
if err != nil {
json.NewEncoder(w).Encode(map[string]interface{}{
"cleared": 0,
"message": fmt.Sprintf("Docker cleanup failed: %s", err),
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"cleared": 1,
"message": string(output),
})
}
func handleSystemInfo(w http.ResponseWriter, r *http.Request) {
enableCors(&w)
if r.Method == "OPTIONS" {
return
}
// Structs for parsing system_profiler JSON
type HardwareItem struct {
MachineName string `json:"machine_name"`
ChipType string `json:"chip_type"`
PhysicalMemory string `json:"physical_memory"`
}
type SoftwareItem struct {
OSVersion string `json:"os_version"`
}
type SystemProfile struct {
Hardware []HardwareItem `json:"SPHardwareDataType"`
Software []SoftwareItem `json:"SPSoftwareDataType"`
}
cmd := exec.Command("system_profiler", "SPHardwareDataType", "SPSoftwareDataType", "-json")
output, err := cmd.Output()
if err != nil {
http.Error(w, "Failed to get system info", http.StatusInternalServerError)
return
}
var profile SystemProfile
if err := json.Unmarshal(output, &profile); err != nil {
http.Error(w, "Failed to parse system info", http.StatusInternalServerError)
return
}
response := map[string]string{
"model": "Unknown",
"chip": "Unknown",
"memory": "Unknown",
"os": "Unknown",
}
if len(profile.Hardware) > 0 {
response["model"] = profile.Hardware[0].MachineName
response["chip"] = profile.Hardware[0].ChipType
response["memory"] = profile.Hardware[0].PhysicalMemory
}
if len(profile.Software) > 0 {
response["os"] = profile.Software[0].OSVersion
}
json.NewEncoder(w).Encode(response)
}
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)
}
type AppDetailsRequest struct {
Path string `json:"path"`
}
func handleAppDetails(w http.ResponseWriter, r *http.Request) {
enableCors(&w)
if r.Method == "OPTIONS" {
return
}
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)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(details)
}
type AppActionRequest struct {
Files []string `json:"files"`
}
func handleAppAction(w http.ResponseWriter, r *http.Request) {
enableCors(&w)
if r.Method == "OPTIONS" {
return
}
var req AppActionRequest
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})
}