feat: finalize app features, menubar icons, and release binaries

This commit is contained in:
vndangkhoa 2026-03-10 08:03:25 +07:00
parent 8e30ac6825
commit eb4a400736
34 changed files with 9886 additions and 9262 deletions

60
.gitignore vendored
View file

@ -1,30 +1,30 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Release Artifacts # Release Artifacts
Release/ Release/
release/ release/
*.zip *.zip
*.exe *.exe
backend/dist/ backend/dist/

136
README.md
View file

@ -1,68 +1,68 @@
# KV Clearnup (Antigravity) 🚀 # KV Clearnup (Antigravity) 🚀
A modern, high-performance system optimizer for macOS, built with **Electron**, **React**, and **Go**. A modern, high-performance system optimizer for macOS, built with **Electron**, **React**, and **Go**.
![App Screenshot](https://via.placeholder.com/800x500?text=Antigravity+Dashboard) ![App Screenshot](https://via.placeholder.com/800x500?text=Antigravity+Dashboard)
## Features ## Features
- **Flash Clean**: Instantly remove system caches, logs, and trash. - **Flash Clean**: Instantly remove system caches, logs, and trash.
- **Deep Clean**: Scan for large files and heavy folders. - **Deep Clean**: Scan for large files and heavy folders.
- **Real-time Monitoring**: Track disk usage and category sizes. - **Real-time Monitoring**: Track disk usage and category sizes.
- **Universal Binary**: Runs natively on both Apple Silicon (M1/M2/M3) and Intel Macs. - **Universal Binary**: Runs natively on both Apple Silicon (M1/M2/M3) and Intel Macs.
- **High Performance**: Heavy lifting is handled by a compiled Go backend. - **High Performance**: Heavy lifting is handled by a compiled Go backend.
## Prerequisites ## Prerequisites
- **Node.js** (v18+) - **Node.js** (v18+)
- **Go** (v1.20+) - **Go** (v1.20+)
- **pnpm** (preferred) or npm - **pnpm** (preferred) or npm
## Development ## Development
### 1. Install Dependencies ### 1. Install Dependencies
```bash ```bash
npm install npm install
``` ```
### 2. Run in Development Mode ### 2. Run in Development Mode
This starts the Go backend (port 36969) and the Vite/Electron frontend concurrently. This starts the Go backend (port 36969) and the Vite/Electron frontend concurrently.
```bash ```bash
./start-go.sh ./start-go.sh
``` ```
*Note: Do not run `npm run dev` directly if you want the backend to work. Use the script.* *Note: Do not run `npm run dev` directly if you want the backend to work. Use the script.*
## Building for Production ## Building for Production
To create a distributable `.dmg` file for macOS: To create a distributable `.dmg` file for macOS:
### 1. Build the App ### 1. Build the App
```bash ```bash
npm run build:mac npm run build:mac
``` ```
This command will: This command will:
1. Compile the Go backend for both `amd64` and `arm64`. 1. Compile the Go backend for both `amd64` and `arm64`.
2. Create a universal binary using `lipo`. 2. Create a universal binary using `lipo`.
3. Build the React frontend. 3. Build the React frontend.
4. Package the Electron app and bundle the backend. 4. Package the Electron app and bundle the backend.
5. Generate a universal `.dmg`. 5. Generate a universal `.dmg`.
### 2. Locate the Installer ### 2. Locate the Installer
The output file will be at: The output file will be at:
``` ```
release/KV Clearnup-1.0.0-universal.dmg release/KV Clearnup-1.0.0-universal.dmg
``` ```
## Running the App ## Running the App
1. **Mount the DMG**: Double-click the `.dmg` file in the `release` folder. 1. **Mount the DMG**: Double-click the `.dmg` file in the `release` folder.
2. **Install**: Drag the app to your `Applications` folder. 2. **Install**: Drag the app to your `Applications` folder.
3. **Launch**: Open "KV Clearnup" from Applications. 3. **Launch**: Open "KV Clearnup" from Applications.
*Troubleshooting*: If you see "System Extension Blocked" or similar OS warnings, go to **System Settings > Privacy & Security** and allow the application. *Troubleshooting*: If you see "System Extension Blocked" or similar OS warnings, go to **System Settings > Privacy & Security** and allow the application.
## Architecture ## Architecture
- **Frontend**: React, TypeScript, TailwindCSS, Framer Motion. - **Frontend**: React, TypeScript, TailwindCSS, Framer Motion.
- **Main Process**: Electron (TypeScript). - **Main Process**: Electron (TypeScript).
- **Backend**: Go (Golang) for file system operations and heavy scanning. - **Backend**: Go (Golang) for file system operations and heavy scanning.
- **Communication**: Electron uses `child_process` to spawn the Go binary. Frontend communicates with backend via HTTP (localhost:36969). - **Communication**: Electron uses `child_process` to spawn the Go binary. Frontend communicates with backend via HTTP (localhost:36969).
## License ## License
MIT MIT

Binary file not shown.

Binary file not shown.

View file

@ -1,22 +1,22 @@
package apps package apps
type AppInfo struct { type AppInfo struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
BundleID string `json:"bundleID"` // On Windows this can be ProductCode or Registry Key Name BundleID string `json:"bundleID"` // On Windows this can be ProductCode or Registry Key Name
UninstallString string `json:"uninstallString"` UninstallString string `json:"uninstallString"`
Size int64 `json:"size"` Size int64 `json:"size"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
} }
type AssociatedFile struct { type AssociatedFile struct {
Path string `json:"path"` Path string `json:"path"`
Type string `json:"type"` // "cache", "config", "log", "data" Type string `json:"type"` // "cache", "config", "log", "data"
Size int64 `json:"size"` Size int64 `json:"size"`
} }
type AppDetails struct { type AppDetails struct {
AppInfo AppInfo
Associated []AssociatedFile `json:"associated"` Associated []AssociatedFile `json:"associated"`
TotalSize int64 `json:"totalSize"` TotalSize int64 `json:"totalSize"`
} }

View file

@ -1,248 +1,248 @@
//go:build windows //go:build windows
package apps package apps
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall" "syscall"
"golang.org/x/sys/windows/registry" "golang.org/x/sys/windows/registry"
) )
// ScanApps returns a list of installed applications via Registry // ScanApps returns a list of installed applications via Registry
func ScanApps() ([]AppInfo, error) { func ScanApps() ([]AppInfo, error) {
var apps []AppInfo var apps []AppInfo
// Keys to search // Keys to search
// HKLM Software\Microsoft\Windows\CurrentVersion\Uninstall // HKLM Software\Microsoft\Windows\CurrentVersion\Uninstall
// HKLM Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall // HKLM Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall
// HKCU Software\Microsoft\Windows\CurrentVersion\Uninstall // HKCU Software\Microsoft\Windows\CurrentVersion\Uninstall
keys := []struct { keys := []struct {
hive registry.Key hive registry.Key
path string path string
}{ }{
{registry.LOCAL_MACHINE, `Software\Microsoft\Windows\CurrentVersion\Uninstall`}, {registry.LOCAL_MACHINE, `Software\Microsoft\Windows\CurrentVersion\Uninstall`},
{registry.LOCAL_MACHINE, `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`}, {registry.LOCAL_MACHINE, `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`},
{registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Uninstall`}, {registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Uninstall`},
} }
seen := make(map[string]bool) seen := make(map[string]bool)
for _, k := range keys { for _, k := range keys {
baseKey, err := registry.OpenKey(k.hive, k.path, registry.READ) baseKey, err := registry.OpenKey(k.hive, k.path, registry.READ)
if err != nil { if err != nil {
continue continue
} }
subkeys, err := baseKey.ReadSubKeyNames(-1) subkeys, err := baseKey.ReadSubKeyNames(-1)
baseKey.Close() baseKey.Close()
if err != nil { if err != nil {
continue continue
} }
for _, subkeyName := range subkeys { for _, subkeyName := range subkeys {
appKey, err := registry.OpenKey(k.hive, k.path+`\`+subkeyName, registry.READ) appKey, err := registry.OpenKey(k.hive, k.path+`\`+subkeyName, registry.READ)
if err != nil { if err != nil {
continue continue
} }
displayName, _, err := appKey.GetStringValue("DisplayName") displayName, _, err := appKey.GetStringValue("DisplayName")
if err != nil || displayName == "" { if err != nil || displayName == "" {
appKey.Close() appKey.Close()
continue continue
} }
// Define installLocation explicitly // Define installLocation explicitly
installLocation, _, _ := appKey.GetStringValue("InstallLocation") installLocation, _, _ := appKey.GetStringValue("InstallLocation")
uninstallString, _, _ := appKey.GetStringValue("UninstallString") uninstallString, _, _ := appKey.GetStringValue("UninstallString")
quietUninstallString, _, _ := appKey.GetStringValue("QuietUninstallString") quietUninstallString, _, _ := appKey.GetStringValue("QuietUninstallString")
if uninstallString == "" && quietUninstallString != "" { if uninstallString == "" && quietUninstallString != "" {
uninstallString = quietUninstallString uninstallString = quietUninstallString
} }
// Debug Log // Debug Log
if strings.Contains(displayName, "Foxit") { if strings.Contains(displayName, "Foxit") {
fmt.Printf("found Foxit: %s | UninstallString: %s\n", displayName, uninstallString) fmt.Printf("found Foxit: %s | UninstallString: %s\n", displayName, uninstallString)
} }
// Deduplication: If we've seen this Name + Location combination, skip it. // Deduplication: If we've seen this Name + Location combination, skip it.
// This handles the common case of 32-bit apps appearing in both HKLM and WOW6432Node. // This handles the common case of 32-bit apps appearing in both HKLM and WOW6432Node.
dedupKey := displayName + "|" + strings.ToLower(installLocation) dedupKey := displayName + "|" + strings.ToLower(installLocation)
if seen[dedupKey] { if seen[dedupKey] {
appKey.Close() appKey.Close()
continue continue
} }
seen[dedupKey] = true seen[dedupKey] = true
// Try to get size from registry (EstimatedSize is in KB) // Try to get size from registry (EstimatedSize is in KB)
sizeVal, _, errSize := appKey.GetIntegerValue("EstimatedSize") sizeVal, _, errSize := appKey.GetIntegerValue("EstimatedSize")
var sizeBytes int64 var sizeBytes int64
if errSize == nil { if errSize == nil {
sizeBytes = int64(sizeVal) * 1024 sizeBytes = int64(sizeVal) * 1024
} }
// Construct Full Registry Key Path as BundleID for later use // Construct Full Registry Key Path as BundleID for later use
hiveName := "HKLM" hiveName := "HKLM"
if k.hive == registry.CURRENT_USER { if k.hive == registry.CURRENT_USER {
hiveName = "HKCU" hiveName = "HKCU"
} }
fullRegPath := hiveName + `\` + k.path + `\` + subkeyName fullRegPath := hiveName + `\` + k.path + `\` + subkeyName
apps = append(apps, AppInfo{ apps = append(apps, AppInfo{
Name: displayName, Name: displayName,
Path: installLocation, Path: installLocation,
BundleID: fullRegPath, BundleID: fullRegPath,
UninstallString: uninstallString, UninstallString: uninstallString,
Size: sizeBytes, Size: sizeBytes,
}) })
appKey.Close() appKey.Close()
} }
} }
return apps, nil return apps, nil
} }
// GetAppDetails finds all associated files (simplified for Windows) // GetAppDetails finds all associated files (simplified for Windows)
func GetAppDetails(appPath, bundleID string) (*AppDetails, error) { func GetAppDetails(appPath, bundleID string) (*AppDetails, error) {
// appPath might come from ScanApps which set it to InstallLocation. // appPath might come from ScanApps which set it to InstallLocation.
// bundleID is used as the Registry Key Path. // bundleID is used as the Registry Key Path.
// Re-construct basic info // Re-construct basic info
info := AppInfo{ info := AppInfo{
Name: filepath.Base(appPath), Name: filepath.Base(appPath),
Path: appPath, Path: appPath,
BundleID: bundleID, BundleID: bundleID,
// UninstallString is hard to recover if not passed, but usually we call GetAppDetails after ScanApps which has it. // UninstallString is hard to recover if not passed, but usually we call GetAppDetails after ScanApps which has it.
// For now leave empty, or we'd need to re-query registry if bundleID is a registry path. // For now leave empty, or we'd need to re-query registry if bundleID is a registry path.
Size: 0, Size: 0,
} }
if appPath == "" && bundleID != "" { if appPath == "" && bundleID != "" {
// Fallback name if path is empty // Fallback name if path is empty
parts := strings.Split(bundleID, `\`) parts := strings.Split(bundleID, `\`)
if len(parts) > 0 { if len(parts) > 0 {
info.Name = parts[len(parts)-1] info.Name = parts[len(parts)-1]
} }
} }
details := &AppDetails{ details := &AppDetails{
AppInfo: info, AppInfo: info,
TotalSize: 0, TotalSize: 0,
} }
// 1. Scan File System // 1. Scan File System
if appPath != "" { if appPath != "" {
var size int64 var size int64
filepath.WalkDir(appPath, func(_ string, d os.DirEntry, err error) error { filepath.WalkDir(appPath, func(_ string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() { if err == nil && !d.IsDir() {
i, _ := d.Info() i, _ := d.Info()
size += i.Size() size += i.Size()
} }
return nil return nil
}) })
details.AppInfo.Size = size details.AppInfo.Size = size
details.TotalSize = size details.TotalSize = size
// Add the main folder as associated data // Add the main folder as associated data
details.Associated = append(details.Associated, AssociatedFile{ details.Associated = append(details.Associated, AssociatedFile{
Path: appPath, Path: appPath,
Type: "data", Type: "data",
Size: size, Size: size,
}) })
} }
// 2. Add Registry Key (Uninstall Entry) // 2. Add Registry Key (Uninstall Entry)
if bundleID != "" && (strings.HasPrefix(bundleID, "HKLM") || strings.HasPrefix(bundleID, "HKCU")) { if bundleID != "" && (strings.HasPrefix(bundleID, "HKLM") || strings.HasPrefix(bundleID, "HKCU")) {
// We treat the registry key as a "file" with special type and 0 size // We treat the registry key as a "file" with special type and 0 size
details.Associated = append(details.Associated, AssociatedFile{ details.Associated = append(details.Associated, AssociatedFile{
Path: "REG:" + bundleID, Path: "REG:" + bundleID,
Type: "registry", // New type Type: "registry", // New type
Size: 0, // Registry entries are negligible in size Size: 0, // Registry entries are negligible in size
}) })
} }
return details, nil return details, nil
} }
// DeleteFiles removes the requested paths // DeleteFiles removes the requested paths
func DeleteFiles(paths []string) error { func DeleteFiles(paths []string) error {
for _, p := range paths { for _, p := range paths {
if p == "" { if p == "" {
continue continue
} }
// Registry Deletion // Registry Deletion
if strings.HasPrefix(p, "REG:") { if strings.HasPrefix(p, "REG:") {
regPath := strings.TrimPrefix(p, "REG:") regPath := strings.TrimPrefix(p, "REG:")
deleteRegistryKey(regPath) deleteRegistryKey(regPath)
continue continue
} }
// Safety checks // Safety checks
if p == "C:\\" || p == "c:\\" || if p == "C:\\" || p == "c:\\" ||
p == "C:\\Windows" || strings.HasPrefix(strings.ToLower(p), "c:\\windows") { p == "C:\\Windows" || strings.HasPrefix(strings.ToLower(p), "c:\\windows") {
continue continue
} }
err := os.RemoveAll(p) err := os.RemoveAll(p)
if err != nil { if err != nil {
// Log error but continue? Or return? // Log error but continue? Or return?
// return err // return err
// On Windows file locking is common, best effort // On Windows file locking is common, best effort
} }
} }
return nil return nil
} }
func deleteRegistryKey(fullPath string) error { func deleteRegistryKey(fullPath string) error {
var hive registry.Key var hive registry.Key
var subPath string var subPath string
if strings.HasPrefix(fullPath, "HKLM\\") { if strings.HasPrefix(fullPath, "HKLM\\") {
hive = registry.LOCAL_MACHINE hive = registry.LOCAL_MACHINE
subPath = strings.TrimPrefix(fullPath, "HKLM\\") subPath = strings.TrimPrefix(fullPath, "HKLM\\")
} else if strings.HasPrefix(fullPath, "HKCU\\") { } else if strings.HasPrefix(fullPath, "HKCU\\") {
hive = registry.CURRENT_USER hive = registry.CURRENT_USER
subPath = strings.TrimPrefix(fullPath, "HKCU\\") subPath = strings.TrimPrefix(fullPath, "HKCU\\")
} else { } else {
return nil return nil
} }
// Provide parent key and subkey name to DeleteKey // Provide parent key and subkey name to DeleteKey
// path: Software\...\Uninstall\AppGUID // path: Software\...\Uninstall\AppGUID
lastSlash := strings.LastIndex(subPath, `\`) lastSlash := strings.LastIndex(subPath, `\`)
if lastSlash == -1 { if lastSlash == -1 {
return nil return nil
} }
parentPath := subPath[:lastSlash] parentPath := subPath[:lastSlash]
keyName := subPath[lastSlash+1:] keyName := subPath[lastSlash+1:]
k, err := registry.OpenKey(hive, parentPath, registry.WRITE) k, err := registry.OpenKey(hive, parentPath, registry.WRITE)
if err != nil { if err != nil {
return err return err
} }
defer k.Close() defer k.Close()
return registry.DeleteKey(k, keyName) return registry.DeleteKey(k, keyName)
} }
// RunUninstaller executes the uninstall command // RunUninstaller executes the uninstall command
func RunUninstaller(cmdString string) error { func RunUninstaller(cmdString string) error {
fmt.Printf("RunUninstaller Called with: %s\n", cmdString) fmt.Printf("RunUninstaller Called with: %s\n", cmdString)
cmd := exec.Command("cmd", "/C", cmdString) cmd := exec.Command("cmd", "/C", cmdString)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: false} // Show window so user can click next cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: false} // Show window so user can click next
err := cmd.Start() err := cmd.Start()
if err != nil { if err != nil {
fmt.Printf("RunUninstaller Error: %v\n", err) fmt.Printf("RunUninstaller Error: %v\n", err)
return err return err
} }
fmt.Printf("RunUninstaller Started Successfully\n") fmt.Printf("RunUninstaller Started Successfully\n")
return nil return nil
} }

View file

@ -1,8 +1,8 @@
package platform package platform
type SystemInfo struct { type SystemInfo struct {
Model string `json:"model"` Model string `json:"model"`
Chip string `json:"chip"` Chip string `json:"chip"`
Memory string `json:"memory"` Memory string `json:"memory"`
OS string `json:"os"` OS string `json:"os"`
} }

View file

@ -1,114 +1,114 @@
//go:build darwin //go:build darwin
package platform package platform
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
) )
func OpenSettings() error { func OpenSettings() error {
return exec.Command("open", "x-apple.systempreferences:com.apple.settings.Storage").Run() return exec.Command("open", "x-apple.systempreferences:com.apple.settings.Storage").Run()
} }
func GetSystemInfo() (*SystemInfo, error) { func GetSystemInfo() (*SystemInfo, error) {
// Structs for parsing system_profiler JSON // Structs for parsing system_profiler JSON
type HardwareItem struct { type HardwareItem struct {
MachineName string `json:"machine_name"` MachineName string `json:"machine_name"`
ChipType string `json:"chip_type"` ChipType string `json:"chip_type"`
PhysicalMemory string `json:"physical_memory"` PhysicalMemory string `json:"physical_memory"`
} }
type SoftwareItem struct { type SoftwareItem struct {
OSVersion string `json:"os_version"` OSVersion string `json:"os_version"`
} }
type SystemProfile struct { type SystemProfile struct {
Hardware []HardwareItem `json:"SPHardwareDataType"` Hardware []HardwareItem `json:"SPHardwareDataType"`
Software []SoftwareItem `json:"SPSoftwareDataType"` Software []SoftwareItem `json:"SPSoftwareDataType"`
} }
cmd := exec.Command("system_profiler", "SPHardwareDataType", "SPSoftwareDataType", "-json") cmd := exec.Command("system_profiler", "SPHardwareDataType", "SPSoftwareDataType", "-json")
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var profile SystemProfile var profile SystemProfile
if err := json.Unmarshal(output, &profile); err != nil { if err := json.Unmarshal(output, &profile); err != nil {
return nil, err return nil, err
} }
info := &SystemInfo{ info := &SystemInfo{
Model: "Unknown", Model: "Unknown",
Chip: "Unknown", Chip: "Unknown",
Memory: "Unknown", Memory: "Unknown",
OS: "Unknown", OS: "Unknown",
} }
if len(profile.Hardware) > 0 { if len(profile.Hardware) > 0 {
info.Model = profile.Hardware[0].MachineName info.Model = profile.Hardware[0].MachineName
info.Chip = profile.Hardware[0].ChipType info.Chip = profile.Hardware[0].ChipType
info.Memory = profile.Hardware[0].PhysicalMemory info.Memory = profile.Hardware[0].PhysicalMemory
} }
if len(profile.Software) > 0 { if len(profile.Software) > 0 {
info.OS = profile.Software[0].OSVersion info.OS = profile.Software[0].OSVersion
} }
return info, nil return info, nil
} }
func EmptyTrash() error { func EmptyTrash() error {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return err return err
} }
trashPath := filepath.Join(home, ".Trash") trashPath := filepath.Join(home, ".Trash")
entries, err := os.ReadDir(trashPath) entries, err := os.ReadDir(trashPath)
if err != nil { if err != nil {
return err return err
} }
for _, entry := range entries { for _, entry := range entries {
itemPath := filepath.Join(trashPath, entry.Name()) itemPath := filepath.Join(trashPath, entry.Name())
os.RemoveAll(itemPath) os.RemoveAll(itemPath)
} }
return nil return nil
} }
func GetCachePath() (string, error) { func GetCachePath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(home, "Library", "Caches"), nil return filepath.Join(home, "Library", "Caches"), nil
} }
func GetDockerPath() (string, error) { func GetDockerPath() (string, error) {
dockerPath, err := exec.LookPath("docker") dockerPath, err := exec.LookPath("docker")
if err != nil { if err != nil {
// Try common locations // Try common locations
commonPaths := []string{ commonPaths := []string{
"/usr/local/bin/docker", "/usr/local/bin/docker",
"/opt/homebrew/bin/docker", "/opt/homebrew/bin/docker",
"/Applications/Docker.app/Contents/Resources/bin/docker", "/Applications/Docker.app/Contents/Resources/bin/docker",
} }
for _, p := range commonPaths { for _, p := range commonPaths {
if _, e := os.Stat(p); e == nil { if _, e := os.Stat(p); e == nil {
dockerPath = p dockerPath = p
return dockerPath, nil return dockerPath, nil
} }
} }
} }
if dockerPath != "" { if dockerPath != "" {
return dockerPath, nil return dockerPath, nil
} }
return "", fmt.Errorf("docker not found") return "", fmt.Errorf("docker not found")
} }
func OpenBrowser(url string) error { func OpenBrowser(url string) error {
return exec.Command("open", url).Start() return exec.Command("open", url).Start()
} }

View file

@ -1,106 +1,106 @@
//go:build windows //go:build windows
package platform package platform
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
) )
func OpenSettings() error { func OpenSettings() error {
// Open Windows Settings -> Storage // Open Windows Settings -> Storage
// ms-settings:storagesense // ms-settings:storagesense
return exec.Command("cmd", "/c", "start", "ms-settings:storagesense").Run() return exec.Command("cmd", "/c", "start", "ms-settings:storagesense").Run()
} }
func GetSystemInfo() (*SystemInfo, error) { func GetSystemInfo() (*SystemInfo, error) {
// Use systeminfo or wmic // Use systeminfo or wmic
// simpler: generic info // simpler: generic info
info := &SystemInfo{ info := &SystemInfo{
Model: "PC", Model: "PC",
Chip: "Unknown", Chip: "Unknown",
Memory: "Unknown", Memory: "Unknown",
OS: "Windows", OS: "Windows",
} }
// Helper to run powershell and get string result // Helper to run powershell and get string result
runPS := func(cmd string) string { runPS := func(cmd string) string {
out, err := exec.Command("powershell", "-NoProfile", "-Command", cmd).Output() out, err := exec.Command("powershell", "-NoProfile", "-Command", cmd).Output()
if err != nil { if err != nil {
return "" return ""
} }
return strings.TrimSpace(string(out)) return strings.TrimSpace(string(out))
} }
// 1. Get OS Name (Simplified) // 1. Get OS Name (Simplified)
// Get-CimInstance Win32_OperatingSystem | Select-Object -ExpandProperty Caption // Get-CimInstance Win32_OperatingSystem | Select-Object -ExpandProperty Caption
osName := runPS("(Get-CimInstance Win32_OperatingSystem).Caption") osName := runPS("(Get-CimInstance Win32_OperatingSystem).Caption")
if osName != "" { if osName != "" {
info.OS = strings.TrimPrefix(osName, "Microsoft ") info.OS = strings.TrimPrefix(osName, "Microsoft ")
} }
// 2. Get Memory (in GB) // 2. Get Memory (in GB)
// [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB) // [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB)
mem := runPS("[math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB)") mem := runPS("[math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB)")
if mem != "" { if mem != "" {
info.Memory = mem + " GB" info.Memory = mem + " GB"
} }
// 3. Get CPU Name // 3. Get CPU Name
// (Get-CimInstance Win32_Processor).Name // (Get-CimInstance Win32_Processor).Name
cpu := runPS("(Get-CimInstance Win32_Processor).Name") cpu := runPS("(Get-CimInstance Win32_Processor).Name")
if cpu != "" { if cpu != "" {
// Cleanup CPU string (remove extra spaces) // Cleanup CPU string (remove extra spaces)
info.Chip = strings.Join(strings.Fields(cpu), " ") info.Chip = strings.Join(strings.Fields(cpu), " ")
} }
return info, nil return info, nil
} }
func EmptyTrash() error { func EmptyTrash() error {
// PowerShell to empty Recycle Bin // PowerShell to empty Recycle Bin
// Clear-RecycleBin -Force -ErrorAction SilentlyContinue // Clear-RecycleBin -Force -ErrorAction SilentlyContinue
// PowerShell to empty Recycle Bin // PowerShell to empty Recycle Bin
// Clear-RecycleBin -Force -ErrorAction SilentlyContinue // Clear-RecycleBin -Force -ErrorAction SilentlyContinue
// We use ExecutionPolicy Bypass to avoid permission issues. // We use ExecutionPolicy Bypass to avoid permission issues.
// We also catch errors to prevent 500s on empty bins. // We also catch errors to prevent 500s on empty bins.
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "Clear-RecycleBin -Force -ErrorAction SilentlyContinue") cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "Clear-RecycleBin -Force -ErrorAction SilentlyContinue")
// If it returns an error, it might be due to permissions or being already empty. // If it returns an error, it might be due to permissions or being already empty.
// We can ignore the error for now to check if that fixes the User's 500. // We can ignore the error for now to check if that fixes the User's 500.
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
// Log it but return nil effectively? // Log it but return nil effectively?
// For now, let's return nil because 'Empty Trash' is best-effort. // For now, let's return nil because 'Empty Trash' is best-effort.
// If the user really has a permission issue, it acts as a no-op which is better than a crash. // If the user really has a permission issue, it acts as a no-op which is better than a crash.
fmt.Printf("EmptyTrash warning: %v\n", err) fmt.Printf("EmptyTrash warning: %v\n", err)
return nil return nil
} }
return nil return nil
} }
func GetCachePath() (string, error) { func GetCachePath() (string, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(home, "AppData", "Local", "Temp"), nil return filepath.Join(home, "AppData", "Local", "Temp"), nil
} }
func GetDockerPath() (string, error) { func GetDockerPath() (string, error) {
path, err := exec.LookPath("docker") path, err := exec.LookPath("docker")
if err == nil { if err == nil {
return path, nil return path, nil
} }
// Common Windows path? // Common Windows path?
return "", fmt.Errorf("docker not found") return "", fmt.Errorf("docker not found")
} }
func OpenBrowser(url string) error { func OpenBrowser(url string) error {
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
} }

View file

@ -1,92 +1,92 @@
package scanner package scanner
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
) )
type ScanResult struct { type ScanResult struct {
Path string `json:"path"` Path string `json:"path"`
Size int64 `json:"size"` Size int64 `json:"size"`
IsDirectory bool `json:"isDirectory"` IsDirectory bool `json:"isDirectory"`
} }
type DiskUsage struct { type DiskUsage struct {
Name string `json:"name"` // e.g. "Local Disk (C:)" Name string `json:"name"` // e.g. "Local Disk (C:)"
TotalGB string `json:"totalGB"` TotalGB string `json:"totalGB"`
UsedGB string `json:"usedGB"` UsedGB string `json:"usedGB"`
FreeGB string `json:"freeGB"` FreeGB string `json:"freeGB"`
} }
type CategorySizes struct { type CategorySizes struct {
Documents int64 `json:"documents"` // Personal Docs only Documents int64 `json:"documents"` // Personal Docs only
Downloads int64 `json:"downloads"` Downloads int64 `json:"downloads"`
Desktop int64 `json:"desktop"` Desktop int64 `json:"desktop"`
Music int64 `json:"music"` Music int64 `json:"music"`
Movies int64 `json:"movies"` Movies int64 `json:"movies"`
System int64 `json:"system"` System int64 `json:"system"`
Trash int64 `json:"trash"` Trash int64 `json:"trash"`
Apps int64 `json:"apps"` Apps int64 `json:"apps"`
Photos int64 `json:"photos"` Photos int64 `json:"photos"`
ICloud int64 `json:"icloud"` // Or OneDrive on Windows? ICloud int64 `json:"icloud"` // Or OneDrive on Windows?
Archives int64 `json:"archives"` Archives int64 `json:"archives"`
VirtualMachines int64 `json:"virtual_machines"` VirtualMachines int64 `json:"virtual_machines"`
Games int64 `json:"games"` Games int64 `json:"games"`
AI int64 `json:"ai"` AI int64 `json:"ai"`
Docker int64 `json:"docker"` Docker int64 `json:"docker"`
Cache int64 `json:"cache"` Cache int64 `json:"cache"`
} }
type CleaningEstimates struct { type CleaningEstimates struct {
FlashEst int64 `json:"flash_est"` FlashEst int64 `json:"flash_est"`
DeepEst int64 `json:"deep_est"` DeepEst int64 `json:"deep_est"`
} }
// FindLargeFiles walks a directory and returns files > threshold // FindLargeFiles walks a directory and returns files > threshold
func FindLargeFiles(root string, threshold int64) ([]ScanResult, error) { func FindLargeFiles(root string, threshold int64) ([]ScanResult, error) {
var results []ScanResult var results []ScanResult
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil { if err != nil {
return nil // Skip errors return nil // Skip errors
} }
// Skip hidden files/dirs (except .Trash maybe, but let's skip all . for now) // Skip hidden files/dirs (except .Trash maybe, but let's skip all . for now)
if strings.HasPrefix(d.Name(), ".") { if strings.HasPrefix(d.Name(), ".") {
if d.IsDir() { if d.IsDir() {
return filepath.SkipDir return filepath.SkipDir
} }
return nil return nil
} }
// Skip node_modules explicitly // Skip node_modules explicitly
if d.IsDir() && d.Name() == "node_modules" { if d.IsDir() && d.Name() == "node_modules" {
return filepath.SkipDir return filepath.SkipDir
} }
if !d.IsDir() { if !d.IsDir() {
info, err := d.Info() info, err := d.Info()
if err == nil && info.Size() > threshold { if err == nil && info.Size() > threshold {
results = append(results, ScanResult{ results = append(results, ScanResult{
Path: path, Path: path,
Size: info.Size(), Size: info.Size(),
IsDirectory: false, IsDirectory: false,
}) })
} }
} }
return nil return nil
}) })
// Sort by size desc // Sort by size desc
sort.Slice(results, func(i, j int) bool { sort.Slice(results, func(i, j int) bool {
return results[i].Size > results[j].Size return results[i].Size > results[j].Size
}) })
// Return top 50 // Return top 50
if len(results) > 50 { if len(results) > 50 {
return results[:50], err return results[:50], err
} }
return results, err return results, err
} }

View file

@ -1,435 +1,435 @@
//go:build windows //go:build windows
package scanner package scanner
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
"unsafe" "unsafe"
// Added missing import // Added missing import
) )
// GetDiskUsage using GetDiskFreeSpaceExW // GetDiskUsage using GetDiskFreeSpaceExW
// GetDiskUsage returns usage for all fixed drives // GetDiskUsage returns usage for all fixed drives
func GetDiskUsage() ([]*DiskUsage, error) { func GetDiskUsage() ([]*DiskUsage, error) {
kernel32 := syscall.NewLazyDLL("kernel32.dll") kernel32 := syscall.NewLazyDLL("kernel32.dll")
getDiskFreeSpaceEx := kernel32.NewProc("GetDiskFreeSpaceExW") getDiskFreeSpaceEx := kernel32.NewProc("GetDiskFreeSpaceExW")
getLogicalDrives := kernel32.NewProc("GetLogicalDrives") getLogicalDrives := kernel32.NewProc("GetLogicalDrives")
var usages []*DiskUsage var usages []*DiskUsage
// Get logical drives bitmask // Get logical drives bitmask
ret, _, _ := getLogicalDrives.Call() ret, _, _ := getLogicalDrives.Call()
if ret == 0 { if ret == 0 {
return nil, fmt.Errorf("GetLogicalDrives failed") return nil, fmt.Errorf("GetLogicalDrives failed")
} }
drivesBitmask := uint32(ret) drivesBitmask := uint32(ret)
toGB := func(bytes int64) string { toGB := func(bytes int64) string {
gb := float64(bytes) / 1024 / 1024 / 1024 gb := float64(bytes) / 1024 / 1024 / 1024
return fmt.Sprintf("%.2f", gb) return fmt.Sprintf("%.2f", gb)
} }
for i := 0; i < 26; i++ { for i := 0; i < 26; i++ {
if drivesBitmask&(1<<uint(i)) != 0 { if drivesBitmask&(1<<uint(i)) != 0 {
driveLetter := string(rune('A' + i)) driveLetter := string(rune('A' + i))
root := driveLetter + ":\\" root := driveLetter + ":\\"
// Check drive type? strictly speaking GetDiskFreeSpaceEx works on network too. // Check drive type? strictly speaking GetDiskFreeSpaceEx works on network too.
// Ideally check GetDriveType to avoid floppy/cd, but usually no biggie if we just check free space. // Ideally check GetDriveType to avoid floppy/cd, but usually no biggie if we just check free space.
var freeBytesAvailable, totalNumberOfBytes, totalNumberOfFreeBytes int64 var freeBytesAvailable, totalNumberOfBytes, totalNumberOfFreeBytes int64
pathPtr, _ := syscall.UTF16PtrFromString(root) pathPtr, _ := syscall.UTF16PtrFromString(root)
r, _, _ := getDiskFreeSpaceEx.Call( r, _, _ := getDiskFreeSpaceEx.Call(
uintptr(unsafe.Pointer(pathPtr)), uintptr(unsafe.Pointer(pathPtr)),
uintptr(unsafe.Pointer(&freeBytesAvailable)), uintptr(unsafe.Pointer(&freeBytesAvailable)),
uintptr(unsafe.Pointer(&totalNumberOfBytes)), uintptr(unsafe.Pointer(&totalNumberOfBytes)),
uintptr(unsafe.Pointer(&totalNumberOfFreeBytes)), uintptr(unsafe.Pointer(&totalNumberOfFreeBytes)),
) )
if r != 0 && totalNumberOfBytes > 0 { if r != 0 && totalNumberOfBytes > 0 {
usedBytes := totalNumberOfBytes - totalNumberOfFreeBytes usedBytes := totalNumberOfBytes - totalNumberOfFreeBytes
usages = append(usages, &DiskUsage{ usages = append(usages, &DiskUsage{
Name: fmt.Sprintf("Local Disk (%s:)", driveLetter), Name: fmt.Sprintf("Local Disk (%s:)", driveLetter),
TotalGB: toGB(totalNumberOfBytes), TotalGB: toGB(totalNumberOfBytes),
UsedGB: toGB(usedBytes), UsedGB: toGB(usedBytes),
FreeGB: toGB(totalNumberOfFreeBytes), FreeGB: toGB(totalNumberOfFreeBytes),
}) })
} }
} }
} }
return usages, nil return usages, nil
} }
// GetDirectorySize walks the directory to calculate size (Windows doesn't have `du`) // GetDirectorySize walks the directory to calculate size (Windows doesn't have `du`)
func GetDirectorySize(path string) int64 { func GetDirectorySize(path string) int64 {
var size int64 var size int64
filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error { filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
if err != nil { if err != nil {
return nil return nil
} }
if !d.IsDir() { if !d.IsDir() {
info, err := d.Info() info, err := d.Info()
if err == nil { if err == nil {
size += info.Size() size += info.Size()
} }
} }
return nil return nil
}) })
return size return size
} }
// FindHeavyFolders finds large directories // FindHeavyFolders finds large directories
func FindHeavyFolders(root string) ([]ScanResult, error) { func FindHeavyFolders(root string) ([]ScanResult, error) {
// Basic implementation: Walk max 2 levels deep and calculate sizes // Basic implementation: Walk max 2 levels deep and calculate sizes
var results []ScanResult var results []ScanResult
// depth 0 = root // depth 0 = root
// depth 1 = children of root // depth 1 = children of root
// depth 2 = children of children // depth 2 = children of children
entries, err := os.ReadDir(root) entries, err := os.ReadDir(root)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var wg sync.WaitGroup var wg sync.WaitGroup
var mu sync.Mutex var mu sync.Mutex
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() { if entry.IsDir() {
path := filepath.Join(root, entry.Name()) path := filepath.Join(root, entry.Name())
wg.Add(1) wg.Add(1)
go func(p string) { go func(p string) {
defer wg.Done() defer wg.Done()
s := GetDirectorySize(p) s := GetDirectorySize(p)
mu.Lock() mu.Lock()
results = append(results, ScanResult{ results = append(results, ScanResult{
Path: p, Path: p,
Size: s, Size: s,
IsDirectory: true, IsDirectory: true,
}) })
mu.Unlock() mu.Unlock()
}(path) }(path)
} }
} }
wg.Wait() wg.Wait()
// Sort by size desc // Sort by size desc
sort.Slice(results, func(i, j int) bool { sort.Slice(results, func(i, j int) bool {
return results[i].Size > results[j].Size return results[i].Size > results[j].Size
}) })
if len(results) > 50 { if len(results) > 50 {
return results[:50], nil return results[:50], nil
} }
return results, nil return results, nil
} }
func ScanUserDocuments() ([]ScanResult, error) { func ScanUserDocuments() ([]ScanResult, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return nil, err return nil, err
} }
targets := []string{ targets := []string{
filepath.Join(home, "Documents"), filepath.Join(home, "Documents"),
filepath.Join(home, "Downloads"), filepath.Join(home, "Downloads"),
filepath.Join(home, "Desktop"), filepath.Join(home, "Desktop"),
} }
var allResults []ScanResult var allResults []ScanResult
for _, t := range targets { for _, t := range targets {
res, _ := FindLargeFiles(t, 10*1024*1024) // 10MB res, _ := FindLargeFiles(t, 10*1024*1024) // 10MB
allResults = append(allResults, res...) allResults = append(allResults, res...)
} }
sort.Slice(allResults, func(i, j int) bool { sort.Slice(allResults, func(i, j int) bool {
return allResults[i].Size > allResults[j].Size return allResults[i].Size > allResults[j].Size
}) })
if len(allResults) > 50 { if len(allResults) > 50 {
return allResults[:50], nil return allResults[:50], nil
} }
return allResults, nil return allResults, nil
} }
func ScanSystemData() ([]ScanResult, error) { func ScanSystemData() ([]ScanResult, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Windows System/Temp locations // Windows System/Temp locations
// %Temp%, Prefetch (admin only, careful), AppData/Local/Temp // %Temp%, Prefetch (admin only, careful), AppData/Local/Temp
targets := []string{ targets := []string{
filepath.Join(home, "AppData", "Local", "Temp"), filepath.Join(home, "AppData", "Local", "Temp"),
os.Getenv("TEMP"), os.Getenv("TEMP"),
// "C:\\Windows\\Temp", // Requires Admin, maybe skip for now or handle error // "C:\\Windows\\Temp", // Requires Admin, maybe skip for now or handle error
} }
var allResults []ScanResult var allResults []ScanResult
for _, t := range targets { for _, t := range targets {
if t == "" { if t == "" {
continue continue
} }
res, _ := FindLargeFiles(t, 10*1024*1024) res, _ := FindLargeFiles(t, 10*1024*1024)
allResults = append(allResults, res...) allResults = append(allResults, res...)
} }
sort.Slice(allResults, func(i, j int) bool { sort.Slice(allResults, func(i, j int) bool {
return allResults[i].Size > allResults[j].Size return allResults[i].Size > allResults[j].Size
}) })
if len(allResults) > 50 { if len(allResults) > 50 {
return allResults[:50], nil return allResults[:50], nil
} }
return allResults, nil return allResults, nil
} }
func GetCategorySizes() (*CategorySizes, error) { func GetCategorySizes() (*CategorySizes, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return nil, err return nil, err
} }
docPath := filepath.Join(home, "Documents") docPath := filepath.Join(home, "Documents")
downPath := filepath.Join(home, "Downloads") downPath := filepath.Join(home, "Downloads")
deskPath := filepath.Join(home, "Desktop") deskPath := filepath.Join(home, "Desktop")
musicPath := filepath.Join(home, "Music") musicPath := filepath.Join(home, "Music")
moviesPath := filepath.Join(home, "Videos") // Windows uses Videos moviesPath := filepath.Join(home, "Videos") // Windows uses Videos
photos := filepath.Join(home, "Pictures") photos := filepath.Join(home, "Pictures")
// AppData is roughly Library // AppData is roughly Library
localAppData := filepath.Join(home, "AppData", "Local") localAppData := filepath.Join(home, "AppData", "Local")
temp := filepath.Join(localAppData, "Temp") temp := filepath.Join(localAppData, "Temp")
// Parallel fetch // Parallel fetch
type result struct { type result struct {
name string name string
size int64 size int64
} }
c := make(chan result) c := make(chan result)
// Checks: docs, down, desk, music, movies, temp, photos, archives, vms, games, ai, docker, cache // Checks: docs, down, desk, music, movies, temp, photos, archives, vms, games, ai, docker, cache
totalChecks := 13 totalChecks := 13
check := func(name, p string) { check := func(name, p string) {
c <- result{name, GetDirectorySize(p)} c <- result{name, GetDirectorySize(p)}
} }
go check("docs", docPath) go check("docs", docPath)
go check("down", downPath) go check("down", downPath)
go check("desk", deskPath) go check("desk", deskPath)
go check("music", musicPath) go check("music", musicPath)
go check("movies", moviesPath) go check("movies", moviesPath)
// Temp is part of Cache now, but let's keep it separate or sum it up // Temp is part of Cache now, but let's keep it separate or sum it up
// System/Temp logic from before: // System/Temp logic from before:
go check("temp", temp) go check("temp", temp)
go check("photos", photos) go check("photos", photos)
// Scan specific common folders for Archives and VMs // Scan specific common folders for Archives and VMs
go func() { go func() {
// Archives: Zip, Rar, 7z in Downloads and Documents // Archives: Zip, Rar, 7z in Downloads and Documents
size := ScanExtensions(downPath, []string{".zip", ".rar", ".7z", ".tar", ".gz", ".xz"}) size := ScanExtensions(downPath, []string{".zip", ".rar", ".7z", ".tar", ".gz", ".xz"})
size += ScanExtensions(docPath, []string{".zip", ".rar", ".7z", ".tar", ".gz", ".xz"}) size += ScanExtensions(docPath, []string{".zip", ".rar", ".7z", ".tar", ".gz", ".xz"})
c <- result{"archives", size} c <- result{"archives", size}
}() }()
go func() { go func() {
// VMs / Disk Images: ISO, VHDX, VMDK in Downloads and Documents // VMs / Disk Images: ISO, VHDX, VMDK in Downloads and Documents
size := ScanExtensions(downPath, []string{".iso", ".vdi", ".vmdk", ".qcow2", ".vhdx", ".img", ".dsk"}) size := ScanExtensions(downPath, []string{".iso", ".vdi", ".vmdk", ".qcow2", ".vhdx", ".img", ".dsk"})
size += ScanExtensions(docPath, []string{".iso", ".vdi", ".vmdk", ".qcow2", ".vhdx", ".img", ".dsk"}) size += ScanExtensions(docPath, []string{".iso", ".vdi", ".vmdk", ".qcow2", ".vhdx", ".img", ".dsk"})
c <- result{"vms", size} c <- result{"vms", size}
}() }()
// Games // Games
go func() { go func() {
var size int64 var size int64
// Common Game Paths // Common Game Paths
paths := []string{ paths := []string{
`C:\Program Files (x86)\Steam\steamapps\common`, `C:\Program Files (x86)\Steam\steamapps\common`,
`C:\Program Files\Epic Games`, `C:\Program Files\Epic Games`,
`C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\games`, `C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\games`,
`C:\Program Files\EA Games`, `C:\Program Files\EA Games`,
filepath.Join(home, "AppData", "Roaming", ".minecraft"), filepath.Join(home, "AppData", "Roaming", ".minecraft"),
} }
for _, p := range paths { for _, p := range paths {
size += GetDirectorySize(p) size += GetDirectorySize(p)
} }
c <- result{"games", size} c <- result{"games", size}
}() }()
// AI // AI
go func() { go func() {
var size int64 var size int64
// 1. Common Installation Paths // 1. Common Installation Paths
paths := []string{ paths := []string{
`C:\ComfyUI`, `C:\ComfyUI`,
`C:\ai\ComfyUI`, `C:\ai\ComfyUI`,
filepath.Join(home, "ComfyUI"), filepath.Join(home, "ComfyUI"),
filepath.Join(home, "stable-diffusion-webui"), filepath.Join(home, "stable-diffusion-webui"),
filepath.Join(home, "webui"), filepath.Join(home, "webui"),
// Common Model Caches // Common Model Caches
filepath.Join(home, ".cache", "huggingface"), filepath.Join(home, ".cache", "huggingface"),
filepath.Join(home, ".ollama", "models"), filepath.Join(home, ".ollama", "models"),
filepath.Join(home, ".lmstudio", "models"), filepath.Join(home, ".lmstudio", "models"),
} }
for _, p := range paths { for _, p := range paths {
size += GetDirectorySize(p) size += GetDirectorySize(p)
} }
// 2. Loose Model Files (Deep Scan) // 2. Loose Model Files (Deep Scan)
// Look for .safetensors, .ckpt, .gguf, .pt, .pth, .bin, .onnx in Downloads and Documents // Look for .safetensors, .ckpt, .gguf, .pt, .pth, .bin, .onnx in Downloads and Documents
aiExtensions := []string{".safetensors", ".ckpt", ".gguf", ".pt", ".pth", ".bin", ".onnx"} aiExtensions := []string{".safetensors", ".ckpt", ".gguf", ".pt", ".pth", ".bin", ".onnx"}
size += ScanExtensions(downPath, aiExtensions) size += ScanExtensions(downPath, aiExtensions)
size += ScanExtensions(docPath, aiExtensions) size += ScanExtensions(docPath, aiExtensions)
c <- result{"ai", size} c <- result{"ai", size}
}() }()
// Docker // Docker
go func() { go func() {
var size int64 var size int64
// Docker Desktop Default WSL Data // Docker Desktop Default WSL Data
dockerPath := filepath.Join(localAppData, "Docker", "wsl", "data", "ext4.vhdx") dockerPath := filepath.Join(localAppData, "Docker", "wsl", "data", "ext4.vhdx")
info, err := os.Stat(dockerPath) info, err := os.Stat(dockerPath)
if err == nil { if err == nil {
size = info.Size() size = info.Size()
} }
c <- result{"docker", size} c <- result{"docker", size}
}() }()
// Cache (Browser + System Temp) // Cache (Browser + System Temp)
go func() { go func() {
var size int64 var size int64
// System Temp // System Temp
size += GetDirectorySize(os.Getenv("TEMP")) size += GetDirectorySize(os.Getenv("TEMP"))
// Chrome Cache // Chrome Cache
size += GetDirectorySize(filepath.Join(localAppData, "Google", "Chrome", "User Data", "Default", "Cache")) size += GetDirectorySize(filepath.Join(localAppData, "Google", "Chrome", "User Data", "Default", "Cache"))
// Edge Cache // Edge Cache
size += GetDirectorySize(filepath.Join(localAppData, "Microsoft", "Edge", "User Data", "Default", "Cache")) size += GetDirectorySize(filepath.Join(localAppData, "Microsoft", "Edge", "User Data", "Default", "Cache"))
// Brave Cache // Brave Cache
size += GetDirectorySize(filepath.Join(localAppData, "BraveSoftware", "Brave-Browser", "User Data", "Default", "Cache")) size += GetDirectorySize(filepath.Join(localAppData, "BraveSoftware", "Brave-Browser", "User Data", "Default", "Cache"))
// Opera Cache // Opera Cache
size += GetDirectorySize(filepath.Join(localAppData, "Opera Software", "Opera Stable", "Cache")) size += GetDirectorySize(filepath.Join(localAppData, "Opera Software", "Opera Stable", "Cache"))
// Firefox Cache // Firefox Cache
size += GetDirectorySize(filepath.Join(localAppData, "Mozilla", "Firefox", "Profiles")) // Scan all profiles for cache? Usually in Local/Mozilla/Firefox/Profiles/<profile>/cache2 size += GetDirectorySize(filepath.Join(localAppData, "Mozilla", "Firefox", "Profiles")) // Scan all profiles for cache? Usually in Local/Mozilla/Firefox/Profiles/<profile>/cache2
// Firefox requires walking profiles in LocalAppData // Firefox requires walking profiles in LocalAppData
mozPath := filepath.Join(localAppData, "Mozilla", "Firefox", "Profiles") mozPath := filepath.Join(localAppData, "Mozilla", "Firefox", "Profiles")
entries, _ := os.ReadDir(mozPath) entries, _ := os.ReadDir(mozPath)
for _, e := range entries { for _, e := range entries {
if e.IsDir() { if e.IsDir() {
size += GetDirectorySize(filepath.Join(mozPath, e.Name(), "cache2")) size += GetDirectorySize(filepath.Join(mozPath, e.Name(), "cache2"))
} }
} }
c <- result{"cache", size} c <- result{"cache", size}
}() }()
sizes := &CategorySizes{} sizes := &CategorySizes{}
for i := 0; i < totalChecks; i++ { for i := 0; i < totalChecks; i++ {
res := <-c res := <-c
switch res.name { switch res.name {
case "docs": case "docs":
sizes.Documents = res.size sizes.Documents = res.size
case "down": case "down":
sizes.Downloads = res.size sizes.Downloads = res.size
case "desk": case "desk":
sizes.Desktop = res.size sizes.Desktop = res.size
case "music": case "music":
sizes.Music = res.size sizes.Music = res.size
case "movies": case "movies":
sizes.Movies = res.size sizes.Movies = res.size
case "temp": case "temp":
// Keeping legacy System field for now, maybe map to part of Cache or System logs? // Keeping legacy System field for now, maybe map to part of Cache or System logs?
sizes.System = res.size sizes.System = res.size
case "photos": case "photos":
sizes.Photos = res.size sizes.Photos = res.size
case "archives": case "archives":
sizes.Archives = res.size sizes.Archives = res.size
case "vms": case "vms":
sizes.VirtualMachines = res.size sizes.VirtualMachines = res.size
case "games": case "games":
sizes.Games = res.size sizes.Games = res.size
case "ai": case "ai":
sizes.AI = res.size sizes.AI = res.size
case "docker": case "docker":
sizes.Docker = res.size sizes.Docker = res.size
case "cache": case "cache":
sizes.Cache = res.size sizes.Cache = res.size
} }
} }
return sizes, nil return sizes, nil
} }
// ScanExtensions walks a directory and sums up size of files with matching extensions // ScanExtensions walks a directory and sums up size of files with matching extensions
func ScanExtensions(root string, exts []string) int64 { func ScanExtensions(root string, exts []string) int64 {
var total int64 var total int64
extMap := make(map[string]bool) extMap := make(map[string]bool)
for _, e := range exts { for _, e := range exts {
extMap[strings.ToLower(e)] = true extMap[strings.ToLower(e)] = true
} }
filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error { filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error {
if err != nil { if err != nil {
return nil return nil
} }
if !d.IsDir() { if !d.IsDir() {
ext := strings.ToLower(filepath.Ext(d.Name())) ext := strings.ToLower(filepath.Ext(d.Name()))
if extMap[ext] { if extMap[ext] {
info, err := d.Info() info, err := d.Info()
if err == nil { if err == nil {
total += info.Size() total += info.Size()
} }
} }
} }
return nil return nil
}) })
return total return total
} }
func GetCleaningEstimates() (*CleaningEstimates, error) { func GetCleaningEstimates() (*CleaningEstimates, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Flash Clean: Temp files // Flash Clean: Temp files
temp := filepath.Join(home, "AppData", "Local", "Temp") temp := filepath.Join(home, "AppData", "Local", "Temp")
// Deep Clean: Downloads // Deep Clean: Downloads
downloads := filepath.Join(home, "Downloads") downloads := filepath.Join(home, "Downloads")
type result struct { type result struct {
name string name string
size int64 size int64
} }
c := make(chan result) c := make(chan result)
go func() { c <- result{"temp", GetDirectorySize(temp)} }() go func() { c <- result{"temp", GetDirectorySize(temp)} }()
go func() { c <- result{"downloads", GetDirectorySize(downloads)} }() go func() { c <- result{"downloads", GetDirectorySize(downloads)} }()
estimates := &CleaningEstimates{} estimates := &CleaningEstimates{}
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
res := <-c res := <-c
switch res.name { switch res.name {
case "temp": case "temp":
estimates.FlashEst = res.size estimates.FlashEst = res.size
case "downloads": case "downloads":
estimates.DeepEst = res.size estimates.DeepEst = res.size
} }
} }
return estimates, nil return estimates, nil
} }

View file

@ -1,38 +1,40 @@
//go:build darwin //go:build darwin
package scanner package scanner
import ( import (
"os" "os"
"path/filepath" "path/filepath"
) )
func GetScanTargets(category string) []string { func GetScanTargets(category string) []string {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
switch category { switch category {
case "apps": case "apps":
return []string{"/Applications", filepath.Join(home, "Applications")} return []string{"/Applications", filepath.Join(home, "Applications")}
case "photos": case "photos":
return []string{filepath.Join(home, "Pictures")} return []string{filepath.Join(home, "Pictures")}
case "icloud": case "icloud":
return []string{filepath.Join(home, "Library", "Mobile Documents")} return []string{filepath.Join(home, "Library", "Mobile Documents")}
case "docs": case "docs":
return []string{filepath.Join(home, "Documents")} return []string{filepath.Join(home, "Documents")}
case "downloads": case "downloads":
return []string{filepath.Join(home, "Downloads")} return []string{filepath.Join(home, "Downloads")}
case "desktop": case "desktop":
return []string{filepath.Join(home, "Desktop")} return []string{filepath.Join(home, "Desktop")}
case "music": case "music":
return []string{filepath.Join(home, "Music")} return []string{filepath.Join(home, "Music")}
case "movies": case "movies":
return []string{filepath.Join(home, "Movies")} return []string{filepath.Join(home, "Movies")}
case "system": case "system":
return []string{ return []string{
filepath.Join(home, "Library", "Caches"), filepath.Join(home, "Library", "Caches"),
filepath.Join(home, "Library", "Logs"), filepath.Join(home, "Library", "Logs"),
filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData"), filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData"),
} }
default: case "trash":
return []string{} return []string{filepath.Join(home, ".Trash")}
} default:
} return []string{}
}
}

View file

@ -1,90 +1,90 @@
package scanner package scanner
import ( import (
"os" "os"
"path/filepath" "path/filepath"
) )
func GetScanTargets(category string) []string { func GetScanTargets(category string) []string {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
switch category { switch category {
case "apps": case "apps":
// Windows apps are dispersed (Program Files), usually read-only. We don't file-scan them usually. // Windows apps are dispersed (Program Files), usually read-only. We don't file-scan them usually.
return []string{ return []string{
os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles"),
os.Getenv("ProgramFiles(x86)"), os.Getenv("ProgramFiles(x86)"),
filepath.Join(os.Getenv("LocalAppData"), "Programs"), filepath.Join(os.Getenv("LocalAppData"), "Programs"),
} }
case "photos": case "photos":
return []string{filepath.Join(home, "Pictures")} return []string{filepath.Join(home, "Pictures")}
case "icloud": case "icloud":
// iCloudDrive? // iCloudDrive?
return []string{filepath.Join(home, "iCloudDrive")} return []string{filepath.Join(home, "iCloudDrive")}
case "docs": case "docs":
return []string{filepath.Join(home, "Documents")} return []string{filepath.Join(home, "Documents")}
case "downloads": case "downloads":
return []string{filepath.Join(home, "Downloads")} return []string{filepath.Join(home, "Downloads")}
case "desktop": case "desktop":
return []string{filepath.Join(home, "Desktop")} return []string{filepath.Join(home, "Desktop")}
case "music": case "music":
return []string{filepath.Join(home, "Music")} return []string{filepath.Join(home, "Music")}
case "movies": case "movies":
return []string{filepath.Join(home, "Videos")} return []string{filepath.Join(home, "Videos")}
case "system": case "system":
return []string{ return []string{
filepath.Join(home, "AppData", "Local", "Temp"), filepath.Join(home, "AppData", "Local", "Temp"),
filepath.Join(home, "AppData", "Local", "Microsoft", "Windows", "INetCache"), // IE/Edge cache filepath.Join(home, "AppData", "Local", "Microsoft", "Windows", "INetCache"), // IE/Edge cache
filepath.Join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Cache"), filepath.Join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Cache"),
filepath.Join(home, "AppData", "Local", "Mozilla", "Firefox", "Profiles"), filepath.Join(home, "AppData", "Local", "Mozilla", "Firefox", "Profiles"),
filepath.Join(home, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default", "Cache"), filepath.Join(home, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default", "Cache"),
filepath.Join(home, "AppData", "Local", "Opera Software", "Opera Stable", "Cache"), filepath.Join(home, "AppData", "Local", "Opera Software", "Opera Stable", "Cache"),
} }
case "cache": case "cache":
return []string{ return []string{
os.Getenv("TEMP"), os.Getenv("TEMP"),
filepath.Join(home, "AppData", "Local", "Temp"), filepath.Join(home, "AppData", "Local", "Temp"),
filepath.Join(home, "AppData", "Local", "Microsoft", "Windows", "INetCache"), filepath.Join(home, "AppData", "Local", "Microsoft", "Windows", "INetCache"),
filepath.Join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Cache"), filepath.Join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Cache"),
filepath.Join(home, "AppData", "Local", "Mozilla", "Firefox", "Profiles"), filepath.Join(home, "AppData", "Local", "Mozilla", "Firefox", "Profiles"),
filepath.Join(home, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default", "Cache"), filepath.Join(home, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default", "Cache"),
filepath.Join(home, "AppData", "Local", "Opera Software", "Opera Stable", "Cache"), filepath.Join(home, "AppData", "Local", "Opera Software", "Opera Stable", "Cache"),
} }
case "games": case "games":
return []string{ return []string{
`C:\Program Files (x86)\Steam\steamapps\common`, `C:\Program Files (x86)\Steam\steamapps\common`,
`C:\Program Files\Epic Games`, `C:\Program Files\Epic Games`,
`C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\games`, `C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\games`,
`C:\Program Files\EA Games`, `C:\Program Files\EA Games`,
filepath.Join(home, "AppData", "Roaming", ".minecraft"), filepath.Join(home, "AppData", "Roaming", ".minecraft"),
} }
case "ai": case "ai":
return []string{ return []string{
`C:\ComfyUI`, `C:\ComfyUI`,
`C:\ai\ComfyUI`, `C:\ai\ComfyUI`,
filepath.Join(home, "ComfyUI"), filepath.Join(home, "ComfyUI"),
filepath.Join(home, "stable-diffusion-webui"), filepath.Join(home, "stable-diffusion-webui"),
filepath.Join(home, "webui"), filepath.Join(home, "webui"),
filepath.Join(home, ".cache", "huggingface"), filepath.Join(home, ".cache", "huggingface"),
filepath.Join(home, ".ollama", "models"), filepath.Join(home, ".ollama", "models"),
filepath.Join(home, ".lmstudio", "models"), filepath.Join(home, ".lmstudio", "models"),
} }
case "docker": case "docker":
return []string{ return []string{
filepath.Join(os.Getenv("LocalAppData"), "Docker", "wsl", "data"), filepath.Join(os.Getenv("LocalAppData"), "Docker", "wsl", "data"),
} }
case "archives": case "archives":
// Archives usually scattered, but main ones in Downloads // Archives usually scattered, but main ones in Downloads
return []string{ return []string{
filepath.Join(home, "Downloads"), filepath.Join(home, "Downloads"),
filepath.Join(home, "Documents"), filepath.Join(home, "Documents"),
} }
case "vms": case "vms":
return []string{ return []string{
filepath.Join(home, "Downloads"), filepath.Join(home, "Downloads"),
filepath.Join(home, "Documents"), filepath.Join(home, "Documents"),
filepath.Join(home, "VirtualBox VMs"), filepath.Join(home, "VirtualBox VMs"),
} }
default: default:
return []string{} return []string{}
} }
} }

View file

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

View file

@ -1,25 +1,25 @@
$p = Start-Process -FilePath ".\kv-cleanup.exe" -PassThru -NoNewWindow $p = Start-Process -FilePath ".\kv-cleanup.exe" -PassThru -NoNewWindow
Start-Sleep -Seconds 3 Start-Sleep -Seconds 3
try { try {
Write-Host "`n=== Disk Usage ===" Write-Host "`n=== Disk Usage ==="
$disk = Invoke-RestMethod -Uri "http://localhost:36969/api/disk-usage" $disk = Invoke-RestMethod -Uri "http://localhost:36969/api/disk-usage"
Write-Host "Total: $($disk.totalGB) GB, Free: $($disk.freeGB) GB" Write-Host "Total: $($disk.totalGB) GB, Free: $($disk.freeGB) GB"
Write-Host "`n=== System Info ===" Write-Host "`n=== System Info ==="
$sys = Invoke-RestMethod -Uri "http://localhost:36969/api/system-info" $sys = Invoke-RestMethod -Uri "http://localhost:36969/api/system-info"
Write-Host "OS: $($sys.os)" Write-Host "OS: $($sys.os)"
Write-Host "Memory: $($sys.memory)" Write-Host "Memory: $($sys.memory)"
Write-Host "`n=== Apps (First 3) ===" Write-Host "`n=== Apps (First 3) ==="
$apps = Invoke-RestMethod -Uri "http://localhost:36969/api/apps" $apps = Invoke-RestMethod -Uri "http://localhost:36969/api/apps"
$apps | Select-Object -First 3 | Format-Table Name, Path $apps | Select-Object -First 3 | Format-Table Name, Path
Write-Host "`n=== Scan Downloads ===" Write-Host "`n=== Scan Downloads ==="
$scan = Invoke-RestMethod -Uri "http://localhost:36969/api/scan/category" -Method Post -Body '{"category": "downloads"}' -ContentType "application/json" $scan = Invoke-RestMethod -Uri "http://localhost:36969/api/scan/category" -Method Post -Body '{"category": "downloads"}' -ContentType "application/json"
$scan | Select-Object -First 3 | Format-Table Path, Size $scan | Select-Object -First 3 | Format-Table Path, Size
} catch { } catch {
Write-Host "Error: $_" Write-Host "Error: $_"
} finally { } finally {
Stop-Process -Id $p.Id -Force Stop-Process -Id $p.Id -Force
} }

View file

@ -1,62 +1,62 @@
# build-release.ps1 # build-release.ps1
# Builds a portable SINGLE-FILE release for Windows and Mac # Builds a portable SINGLE-FILE release for Windows and Mac
Write-Host "Starting Portable Release Build..." -ForegroundColor Cyan Write-Host "Starting Portable Release Build..." -ForegroundColor Cyan
# 1. Clean previous build # 1. Clean previous build
if (Test-Path "Release") { Remove-Item "Release" -Recurse -Force } if (Test-Path "Release") { Remove-Item "Release" -Recurse -Force }
if (Test-Path "backend\dist") { Remove-Item "backend\dist" -Recurse -Force } if (Test-Path "backend\dist") { Remove-Item "backend\dist" -Recurse -Force }
New-Item -ItemType Directory -Force -Path "Release" | Out-Null New-Item -ItemType Directory -Force -Path "Release" | Out-Null
New-Item -ItemType Directory -Force -Path "Release\Windows" | Out-Null New-Item -ItemType Directory -Force -Path "Release\Windows" | Out-Null
New-Item -ItemType Directory -Force -Path "Release\Mac" | Out-Null New-Item -ItemType Directory -Force -Path "Release\Mac" | Out-Null
# 2. Build Frontend # 2. Build Frontend
Write-Host "Building Frontend (Vite)..." -ForegroundColor Yellow Write-Host "Building Frontend (Vite)..." -ForegroundColor Yellow
$pkgManager = "pnpm" $pkgManager = "pnpm"
if (-not (Get-Command "pnpm" -ErrorAction SilentlyContinue)) { $pkgManager = "npm" } if (-not (Get-Command "pnpm" -ErrorAction SilentlyContinue)) { $pkgManager = "npm" }
Invoke-Expression "$pkgManager install" Invoke-Expression "$pkgManager install"
Invoke-Expression "$pkgManager run build" Invoke-Expression "$pkgManager run build"
if (-not (Test-Path "dist")) { if (-not (Test-Path "dist")) {
Write-Host "Frontend build failed: 'dist' folder not found." -ForegroundColor Red Write-Host "Frontend build failed: 'dist' folder not found." -ForegroundColor Red
exit 1 exit 1
} }
# 3. Move dist to backend/dist (for embedding) # 3. Move dist to backend/dist (for embedding)
Write-Host "Moving frontend to backend for embedding..." -ForegroundColor Cyan Write-Host "Moving frontend to backend for embedding..." -ForegroundColor Cyan
Copy-Item -Path "dist" -Destination "backend\dist" -Recurse Copy-Item -Path "dist" -Destination "backend\dist" -Recurse
# 4. Build Backend # 4. Build Backend
Write-Host "Building Backend..." -ForegroundColor Yellow Write-Host "Building Backend..." -ForegroundColor Yellow
# Windows Build # Windows Build
Write-Host " Windows (amd64)..." -ForegroundColor Cyan Write-Host " Windows (amd64)..." -ForegroundColor Cyan
$env:GOOS = "windows"; $env:GOARCH = "amd64" $env:GOOS = "windows"; $env:GOARCH = "amd64"
go build -ldflags "-s -w -H=windowsgui" -o "Release\Windows\Antigravity.exe" backend/main.go go build -ldflags "-s -w -H=windowsgui" -o "Release\Windows\Antigravity.exe" backend/main.go
# Mac Build (Cross-compile) # Mac Build (Cross-compile)
Write-Host " macOS (amd64 & arm64)..." -ForegroundColor Cyan Write-Host " macOS (amd64 & arm64)..." -ForegroundColor Cyan
$env:GOOS = "darwin"; $env:GOARCH = "amd64" $env:GOOS = "darwin"; $env:GOARCH = "amd64"
go build -ldflags "-s -w" -o "Release\Mac\Antigravity-Intel" backend/main.go go build -ldflags "-s -w" -o "Release\Mac\Antigravity-Intel" backend/main.go
$env:GOARCH = "arm64" $env:GOARCH = "arm64"
go build -ldflags "-s -w" -o "Release\Mac\Antigravity-AppleSilicon" backend/main.go go build -ldflags "-s -w" -o "Release\Mac\Antigravity-AppleSilicon" backend/main.go
# Cleanup backend/dist # Cleanup backend/dist
Remove-Item "backend\dist" -Recurse -Force Remove-Item "backend\dist" -Recurse -Force
# 5. Success Message & Zipping # 5. Success Message & Zipping
Write-Host "Build Complete!" -ForegroundColor Green Write-Host "Build Complete!" -ForegroundColor Green
# Zip Windows # Zip Windows
if (Test-Path "Release\Antigravity-Windows.zip") { Remove-Item "Release\Antigravity-Windows.zip" } if (Test-Path "Release\Antigravity-Windows.zip") { Remove-Item "Release\Antigravity-Windows.zip" }
Compress-Archive -Path "Release\Windows\*" -DestinationPath "Release\Antigravity-Windows.zip" -Force Compress-Archive -Path "Release\Windows\*" -DestinationPath "Release\Antigravity-Windows.zip" -Force
Write-Host "Created Windows Zip: Release\Antigravity-Windows.zip" -ForegroundColor Green Write-Host "Created Windows Zip: Release\Antigravity-Windows.zip" -ForegroundColor Green
# Zip Mac # Zip Mac
if (Test-Path "Release\Antigravity-Mac.zip") { Remove-Item "Release\Antigravity-Mac.zip" } if (Test-Path "Release\Antigravity-Mac.zip") { Remove-Item "Release\Antigravity-Mac.zip" }
Compress-Archive -Path "Release\Mac\*" -DestinationPath "Release\Antigravity-Mac.zip" -Force Compress-Archive -Path "Release\Mac\*" -DestinationPath "Release\Antigravity-Mac.zip" -Force
Write-Host "Created Mac Zip: Release\Antigravity-Mac.zip" -ForegroundColor Green Write-Host "Created Mac Zip: Release\Antigravity-Mac.zip" -ForegroundColor Green
Write-Host "Artifacts are in the 'Release' folder." -ForegroundColor White Write-Host "Artifacts are in the 'Release' folder." -ForegroundColor White

467
dist-electron/main.cjs Normal file
View file

@ -0,0 +1,467 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
// electron/main.ts
var import_electron = require("electron");
var import_path3 = __toESM(require("path"), 1);
var import_child_process3 = require("child_process");
// electron/features/scanner.ts
var import_promises = __toESM(require("fs/promises"), 1);
var import_path = __toESM(require("path"), 1);
var import_os = __toESM(require("os"), 1);
var import_child_process = require("child_process");
var import_util = __toESM(require("util"), 1);
async function scanDirectory(rootDir, maxDepth = 5) {
const results = [];
async function traverse(currentPath, depth) {
if (depth > maxDepth) return;
try {
const entries = await import_promises.default.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = import_path.default.join(currentPath, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "vendor" || entry.name === ".venv") {
try {
const stats = await import_promises.default.stat(fullPath);
results.push({
path: fullPath,
size: 0,
// Calculating size is expensive, might do lazily or separate task
lastAccessed: stats.atime,
type: entry.name
});
continue;
} catch (e) {
console.error(`Error stat-ing ${fullPath}`, e);
}
} else if (!entry.name.startsWith(".")) {
await traverse(fullPath, depth + 1);
}
}
}
} catch (error) {
console.error(`Error scanning ${currentPath}`, error);
}
}
await traverse(rootDir, 0);
return results;
}
async function findLargeFiles(rootDir, threshold = 100 * 1024 * 1024) {
const results = [];
async function traverse(currentPath) {
try {
const stats = await import_promises.default.stat(currentPath);
if (stats.size > threshold && !stats.isDirectory()) {
results.push({ path: currentPath, size: stats.size, isDirectory: false });
return;
}
if (stats.isDirectory()) {
if (import_path.default.basename(currentPath) === "node_modules") return;
const entries = await import_promises.default.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".") && entry.name !== ".Trash") continue;
await traverse(import_path.default.join(currentPath, entry.name));
}
}
} catch (e) {
}
}
await traverse(rootDir);
return results.sort((a, b) => b.size - a.size);
}
async function getDeepDiveSummary() {
const home = import_os.default.homedir();
const targets = [
import_path.default.join(home, "Downloads"),
import_path.default.join(home, "Documents"),
import_path.default.join(home, "Desktop"),
import_path.default.join(home, "Library/Application Support")
];
const results = [];
for (const t of targets) {
console.log(`Scanning ${t}...`);
const large = await findLargeFiles(t, 50 * 1024 * 1024);
console.log(`Found ${large.length} large files in ${t}`);
results.push(...large);
}
return results.slice(0, 20);
}
var execPromise = import_util.default.promisify(import_child_process.exec);
async function getDiskUsage() {
try {
const { stdout } = await execPromise("df -k /");
const lines = stdout.trim().split("\n");
if (lines.length < 2) return null;
const parts = lines[1].split(/\s+/);
const total = parseInt(parts[1]) * 1024;
const used = parseInt(parts[2]) * 1024;
const available = parseInt(parts[3]) * 1024;
return {
totalGB: (total / 1024 / 1024 / 1024).toFixed(2),
usedGB: (used / 1024 / 1024 / 1024).toFixed(2),
freeGB: (available / 1024 / 1024 / 1024).toFixed(2)
};
} catch (e) {
console.error("Error getting disk usage:", e);
return null;
}
}
async function findHeavyFolders(rootDir) {
try {
console.log(`Deepest scan on: ${rootDir}`);
const { stdout } = await execPromise(`du -k -d 2 "${rootDir}" | sort -nr | head -n 50`);
const lines = stdout.trim().split("\n");
const results = lines.map((line) => {
const trimmed = line.trim();
const firstSpace = trimmed.indexOf(" ");
const match = trimmed.match(/^(\d+)\s+(.+)$/);
if (!match) return null;
const sizeK = parseInt(match[1]);
const fullPath = match[2];
return {
path: fullPath,
size: sizeK * 1024,
// Convert KB to Bytes
isDirectory: true
};
}).filter((item) => item !== null && item.path !== rootDir);
return results;
} catch (e) {
console.error("Deepest scan failed:", e);
return [];
}
}
// electron/features/updater.ts
var import_child_process2 = require("child_process");
var import_util2 = __toESM(require("util"), 1);
var execAsync = import_util2.default.promisify(import_child_process2.exec);
async function disableAutoUpdates(password) {
const cmds = [
"sudo -S softwareupdate --schedule off",
"sudo -S defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool false",
"sudo -S defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -bool false",
"sudo -S defaults write /Library/Preferences/com.apple.commerce AutoUpdate -bool false"
];
try {
await execWithSudo("softwareupdate --schedule off");
return true;
} catch (error) {
console.error("Failed to disable updates", error);
return false;
}
}
async function execWithSudo(command) {
const script = `do shell script "${command}" with administrator privileges`;
return execAsync(`osascript -e '${script}'`);
}
// electron/features/cleaner.ts
var import_promises2 = __toESM(require("fs/promises"), 1);
var import_path2 = __toESM(require("path"), 1);
var import_os2 = __toESM(require("os"), 1);
async function clearCaches() {
const cacheDir = import_path2.default.join(import_os2.default.homedir(), "Library/Caches");
try {
const entries = await import_promises2.default.readdir(cacheDir);
let freedSpace = 0;
for (const entry of entries) {
const fullPath = import_path2.default.join(cacheDir, entry);
await import_promises2.default.rm(fullPath, { recursive: true, force: true });
}
return true;
} catch (error) {
console.error("Error clearing caches", error);
return false;
}
}
async function purgePath(targetPath) {
try {
await import_promises2.default.rm(targetPath, { recursive: true, force: true });
return true;
} catch (e) {
console.error(`Failed to purge ${targetPath}`, e);
return false;
}
}
async function cleanupDocker() {
try {
const { exec: exec3 } = await import("child_process");
const util3 = await import("util");
const execAsync2 = util3.promisify(exec3);
await execAsync2("docker system prune -a --volumes -f");
return true;
} catch (e) {
console.error("Failed to cleanup docker:", e);
return false;
}
}
async function cleanupTmp() {
const tmpDir = import_os2.default.tmpdir();
let success = true;
try {
const entries = await import_promises2.default.readdir(tmpDir);
for (const entry of entries) {
try {
await import_promises2.default.rm(import_path2.default.join(tmpDir, entry), { recursive: true, force: true });
} catch (e) {
console.warn(`Skipped ${entry}`);
}
}
} catch (e) {
console.error("Failed to access tmp dir:", e);
success = false;
}
return success;
}
async function cleanupXcode() {
try {
const home = import_os2.default.homedir();
const paths = [
import_path2.default.join(home, "Library/Developer/Xcode/DerivedData"),
import_path2.default.join(home, "Library/Developer/Xcode/iOS DeviceSupport"),
import_path2.default.join(home, "Library/Developer/Xcode/Archives"),
import_path2.default.join(home, "Library/Caches/com.apple.dt.Xcode")
];
for (const p of paths) {
try {
await import_promises2.default.rm(p, { recursive: true, force: true });
} catch (e) {
console.warn(`Failed to clean ${p}`, e);
}
}
return true;
} catch (e) {
console.error("Failed to cleanup Xcode:", e);
return false;
}
}
async function cleanupTurnkey() {
try {
const home = import_os2.default.homedir();
const paths = [
import_path2.default.join(home, ".npm/_cacache"),
import_path2.default.join(home, ".yarn/cache"),
import_path2.default.join(home, "Library/pnpm/store"),
// Mac default for pnpm store if not configured otherwise
import_path2.default.join(home, ".cache/yarn"),
import_path2.default.join(home, ".gradle/caches")
];
for (const p of paths) {
try {
await import_promises2.default.rm(p, { recursive: true, force: true });
} catch (e) {
console.warn(`Failed to clean ${p}`, e);
}
}
return true;
} catch (e) {
console.error("Failed to cleanup package managers:", e);
return false;
}
}
// electron/main.ts
var mainWindow = null;
var backendProcess = null;
var tray = null;
var startBackend = () => {
if (process.env.NODE_ENV === "development") {
console.log("Development mode: Backend should be running via start-go.sh");
return;
}
const backendPath = import_path3.default.join(process.resourcesPath, "backend");
console.log("Starting backend from:", backendPath);
try {
backendProcess = (0, import_child_process3.spawn)(backendPath, [], {
stdio: "inherit"
});
backendProcess.on("error", (err) => {
console.error("Failed to start backend:", err);
});
backendProcess.on("exit", (code, signal) => {
console.log(`Backend exited with code ${code} and signal ${signal}`);
});
} catch (error) {
console.error("Error spawning backend:", error);
}
};
function createTray() {
const iconPath = import_path3.default.join(__dirname, "../dist/tray/tray-iconTemplate.png");
let finalIconPath = iconPath;
if (process.env.NODE_ENV === "development") {
finalIconPath = import_path3.default.join(__dirname, "../public/tray/tray-iconTemplate.png");
}
let image = import_electron.nativeImage.createFromPath(finalIconPath);
image.setTemplateImage(true);
tray = new import_electron.Tray(image.resize({ width: 18, height: 18 }));
tray.setToolTip("Antigravity Cleaner");
updateTrayMenu("Initializing...");
}
var isDockVisible = true;
function updateTrayMenu(statusText) {
if (!tray) return;
const contextMenu = import_electron.Menu.buildFromTemplate([
{ label: `Storage: ${statusText}`, enabled: false },
{ type: "separator" },
{
label: "Open Dashboard",
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
}
},
{
label: "Free Up Storage",
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
}
},
{ type: "separator" },
{
label: "Show Dock Icon",
type: "checkbox",
checked: isDockVisible,
click: (menuItem) => {
isDockVisible = menuItem.checked;
if (isDockVisible) {
import_electron.app.dock.show();
} else {
import_electron.app.dock.hide();
}
}
},
{ type: "separator" },
{ label: "Quit", click: () => import_electron.app.quit() }
]);
tray.setContextMenu(contextMenu);
tray.setTitle(statusText);
}
function createWindow() {
mainWindow = new import_electron.BrowserWindow({
width: 1200,
height: 800,
backgroundColor: "#FFFFFF",
// Helps prevent white flash
webPreferences: {
preload: import_path3.default.join(__dirname, "preload.cjs"),
nodeIntegration: true,
contextIsolation: true
}
});
const isDev = process.env.NODE_ENV === "development";
const port = process.env.PORT || 5173;
if (isDev) {
mainWindow.loadURL(`http://localhost:${port}`);
} else {
mainWindow.loadFile(import_path3.default.join(__dirname, "../dist/index.html"));
}
mainWindow.on("closed", () => {
mainWindow = null;
});
}
import_electron.app.whenReady().then(() => {
import_electron.ipcMain.handle("scan-directory", async (event, path4) => {
return scanDirectory(path4);
});
import_electron.ipcMain.handle("deep-dive-scan", async () => {
return getDeepDiveSummary();
});
import_electron.ipcMain.handle("get-disk-usage", async () => {
return getDiskUsage();
});
import_electron.ipcMain.handle("deepest-scan", async (event, targetPath) => {
const target = targetPath || import_path3.default.join(import_electron.app.getPath("home"), "Documents");
return findHeavyFolders(target);
});
import_electron.ipcMain.handle("disable-updates", async () => {
return disableAutoUpdates();
});
import_electron.ipcMain.handle("clean-system", async () => {
return clearCaches();
});
import_electron.ipcMain.handle("cleanup-docker", async () => {
return cleanupDocker();
});
import_electron.ipcMain.handle("cleanup-tmp", async () => {
return cleanupTmp();
});
import_electron.ipcMain.handle("cleanup-xcode", async () => {
return cleanupXcode();
});
import_electron.ipcMain.handle("cleanup-turnkey", async () => {
return cleanupTurnkey();
});
import_electron.ipcMain.handle("purge-path", async (event, targetPath) => {
return purgePath(targetPath);
});
import_electron.ipcMain.handle("update-tray-title", (event, title) => {
if (tray) {
tray.setTitle(title);
updateTrayMenu(title);
}
});
import_electron.ipcMain.handle("get-app-icon", async (event, appPath) => {
try {
const icon = await import_electron.app.getFileIcon(appPath, { size: "normal" });
return icon.toDataURL();
} catch (e) {
console.error("Failed to get icon for:", appPath, e);
return "";
return "";
}
});
import_electron.ipcMain.handle("update-tray-icon", (event, dataUrl) => {
if (tray && dataUrl) {
const image = import_electron.nativeImage.createFromDataURL(dataUrl);
tray.setImage(image.resize({ width: 22, height: 22 }));
}
});
createWindow();
createTray();
startBackend();
});
import_electron.app.on("will-quit", () => {
if (backendProcess) {
console.log("Killing backend process...");
backendProcess.kill();
backendProcess = null;
}
});
import_electron.app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
import_electron.app.quit();
}
});
import_electron.app.on("activate", () => {
if (mainWindow === null) {
createWindow();
}
});

20
dist-electron/preload.cjs Normal file
View file

@ -0,0 +1,20 @@
"use strict";
// electron/preload.ts
var import_electron = require("electron");
import_electron.contextBridge.exposeInMainWorld("electronAPI", {
scanDirectory: (path) => import_electron.ipcRenderer.invoke("scan-directory", path),
disableUpdates: () => import_electron.ipcRenderer.invoke("disable-updates"),
cleanSystem: () => import_electron.ipcRenderer.invoke("clean-system"),
purgePath: (path) => import_electron.ipcRenderer.invoke("purge-path", path),
cleanupDocker: () => import_electron.ipcRenderer.invoke("cleanup-docker"),
cleanupTmp: () => import_electron.ipcRenderer.invoke("cleanup-tmp"),
cleanupXcode: () => import_electron.ipcRenderer.invoke("cleanup-xcode"),
cleanupTurnkey: () => import_electron.ipcRenderer.invoke("cleanup-turnkey"),
deepDiveScan: () => import_electron.ipcRenderer.invoke("deep-dive-scan"),
getDiskUsage: () => import_electron.ipcRenderer.invoke("get-disk-usage"),
deepestScan: (path) => import_electron.ipcRenderer.invoke("deepest-scan", path),
updateTrayTitle: (title) => import_electron.ipcRenderer.invoke("update-tray-title", title),
getAppIcon: (path) => import_electron.ipcRenderer.invoke("get-app-icon", path),
updateTrayIcon: (dataUrl) => import_electron.ipcRenderer.invoke("update-tray-icon", dataUrl)
});

View file

@ -49,12 +49,14 @@ function createTray() {
// Check if dist/tray exists, if not try public/tray (dev mode) // Check if dist/tray exists, if not try public/tray (dev mode)
let finalIconPath = iconPath; let finalIconPath = iconPath;
if (!fs.existsSync(iconPath)) { if (process.env.NODE_ENV === 'development') {
finalIconPath = path.join(__dirname, '../public/tray/tray-iconTemplate.png'); finalIconPath = path.join(__dirname, '../public/tray/tray-iconTemplate.png');
} }
const image = nativeImage.createFromPath(finalIconPath); let image = nativeImage.createFromPath(finalIconPath);
tray = new Tray(image.resize({ width: 16, height: 16 })); image.setTemplateImage(true);
tray = new Tray(image.resize({ width: 18, height: 18 }));
tray.setToolTip('Antigravity Cleaner'); tray.setToolTip('Antigravity Cleaner');
updateTrayMenu('Initializing...'); updateTrayMenu('Initializing...');

View file

@ -0,0 +1,20 @@
const { app, nativeImage } = require('electron');
const fs = require('fs');
const path = require('path');
app.whenReady().then(() => {
const iconPath = path.join(__dirname, '../../build/icon.png');
const image = nativeImage.createFromPath(iconPath);
const resized = image.resize({ width: 22, height: 22, quality: 'best' });
const pngPath = path.join(__dirname, '../../public/tray/tray-icon.png');
const pngPath2 = path.join(__dirname, '../../public/tray/tray-iconTemplate.png');
const pngBuffer = resized.toPNG();
fs.writeFileSync(pngPath, pngBuffer);
fs.writeFileSync(pngPath2, pngBuffer);
console.log('Saved resized built icon to', pngPath);
app.quit();
});

View file

@ -0,0 +1,18 @@
const { app, nativeImage } = require('electron');
const fs = require('fs');
const path = require('path');
app.whenReady().then(() => {
const svgBuffer = fs.readFileSync('/tmp/tray-iconTemplate.svg');
const image = nativeImage.createFromBuffer(svgBuffer, { scaleFactor: 2.0 });
const pngPath = path.join(__dirname, '../../public/tray/tray-iconTemplate.png');
const pngPath2 = path.join(__dirname, '../../public/tray/tray-icon.png');
const pngBuffer = image.toPNG();
fs.writeFileSync(pngPath, pngBuffer);
fs.writeFileSync(pngPath2, pngBuffer);
console.log('Saved transparent PNG template to', pngPath);
app.quit();
});

10
go.mod
View file

@ -1,5 +1,5 @@
module github.com/kv/clearnup module github.com/kv/clearnup
go 1.25.4 go 1.25.4
require golang.org/x/sys v0.40.0 // indirect require golang.org/x/sys v0.40.0 // indirect

View file

@ -1,81 +1,87 @@
{ {
"name": "Lumina", "name": "Lumina",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"main": "dist-electron/main.cjs", "main": "dist-electron/main.cjs",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:electron": "node scripts/build-electron.mjs && concurrently -k \"vite\" \"wait-on tcp:5173 && cross-env NODE_ENV=development electron dist-electron/main.cjs\"", "dev:electron": "node scripts/build-electron.mjs && concurrently -k \"vite\" \"wait-on tcp:5173 && cross-env NODE_ENV=development electron dist-electron/main.cjs\"",
"electron:build": "node scripts/build-electron.mjs", "electron:build": "node scripts/build-electron.mjs",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:go:mac": "sh scripts/build-go.sh", "build:go:mac": "sh scripts/build-go.sh",
"build:mac": "pnpm run build:go:mac && pnpm run build && pnpm run electron:build && electron-builder --mac --universal", "build:mac": "pnpm run build:go:mac && pnpm run build && pnpm run electron:build && electron-builder --mac --universal",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"preinstall": "node scripts/check-pnpm.js" "preinstall": "node scripts/check-pnpm.js"
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.29.2", "framer-motion": "^12.29.2",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"electron": "^33.2.1", "electron": "^33.2.1",
"electron-builder": "^26.4.0", "electron-builder": "^26.4.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4", "vite": "^7.2.4",
"wait-on": "^8.0.1" "wait-on": "^8.0.1"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"electron", "electron",
"esbuild" "esbuild"
] ]
}, },
"build": { "build": {
"appId": "com.kv.clearnup", "appId": "com.kv.clearnup",
"productName": "KV Clearnup", "productName": "KV Clearnup",
"directories": { "directories": {
"output": "release" "output": "release"
}, },
"compression": "maximum", "compression": "maximum",
"mac": { "mac": {
"target": [ "target": [
"dmg" "dmg"
], ],
"icon": "build/icon.png", "icon": "build/icon.png",
"category": "public.app-category.utilities", "category": "public.app-category.utilities",
"hardenedRuntime": false "hardenedRuntime": false
}, },
"files": [ "win": {
"dist/**/*", "target": [
"dist-electron/**/*", "portable"
"package.json" ],
], "icon": "build/icon.png"
"extraResources": [ },
{ "files": [
"from": "backend/dist/universal/backend", "dist/**/*",
"to": "backend" "dist-electron/**/*",
} "package.json"
] ],
} "extraResources": [
{
"from": "backend/dist/universal/backend",
"to": "backend"
}
]
}
} }

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 KiB

After

Width:  |  Height:  |  Size: 0 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 0 B

View file

@ -1,207 +1,227 @@
const API_BASE = "http://localhost:36969/api"; const API_BASE = "http://localhost:36969/api";
export interface ScanResult { export interface ScanResult {
path: string; path: string;
size: number; size: number;
isDirectory: boolean; isDirectory: boolean;
} }
export interface DiskUsage { export interface DiskUsage {
name: string; name: string;
totalGB: string; totalGB: string;
usedGB: string; usedGB: string;
freeGB: string; freeGB: string;
} }
export interface CategorySizes { export interface CategorySizes {
documents: number; documents: number;
downloads: number; downloads: number;
desktop: number; desktop: number;
music: number; music: number;
movies: number; movies: number;
system: number; system: number;
trash: number; trash: number;
apps: number; apps: number;
photos: number; photos: number;
icloud: number; icloud: number;
archives?: number; archives?: number;
virtual_machines?: number; virtual_machines?: number;
games?: number; games?: number;
ai?: number; ai?: number;
docker?: number; docker?: number;
cache?: number; cache?: number;
} }
export interface SystemInfo { export interface SystemInfo {
model: string; model: string;
chip: string; chip: string;
memory: string; memory: string;
os: string; os: string;
} }
export interface CleaningEstimates { export interface CleaningEstimates {
flash_est: number; flash_est: number;
deep_est: number; deep_est: number;
} }
// Uninstaller Types // Uninstaller Types
export interface AppInfo { export interface AppInfo {
name: string; name: string;
path: string; path: string;
bundleID: string; bundleID: string;
uninstallString?: string; uninstallString?: string;
size: number; size: number;
icon?: string; icon?: string;
} }
export interface AssociatedFile { export interface AssociatedFile {
path: string; path: string;
type: 'cache' | 'config' | 'log' | 'data' | 'registry'; type: 'cache' | 'config' | 'log' | 'data' | 'registry';
size: number; size: number;
} }
export interface AppDetails extends AppInfo { export interface AppDetails extends AppInfo {
associated: AssociatedFile[]; associated: AssociatedFile[];
totalSize: number; totalSize: number;
} }
export const API = { export const API = {
getDiskUsage: async (): Promise<DiskUsage[] | null> => { getDiskUsage: async (): Promise<DiskUsage[] | null> => {
try { try {
const res = await fetch(`${API_BASE}/disk-usage`); const res = await fetch(`${API_BASE}/disk-usage`);
if (!res.ok) throw new Error("Failed to fetch disk usage"); if (!res.ok) throw new Error("Failed to fetch disk usage");
return await res.json(); return await res.json();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return null; return null;
} }
}, },
scanCategory: async (category: string): Promise<ScanResult[]> => { scanCategory: async (category: string): Promise<ScanResult[]> => {
const res = await fetch(`${API_BASE}/scan/category`, { const res = await fetch(`${API_BASE}/scan/category`, {
method: "POST", method: "POST",
body: JSON.stringify({ category }) body: JSON.stringify({ category })
}); });
return await res.json() || []; return await res.json() || [];
}, },
openSettings: async () => { openSettings: async () => {
await fetch(`${API_BASE}/open-settings`, { method: "POST" }); await fetch(`${API_BASE}/open-settings`, { method: "POST" });
}, },
getCategorySizes: async (): Promise<CategorySizes> => { getCategorySizes: async (): Promise<CategorySizes> => {
try { try {
const res = await fetch(`${API_BASE}/scan/sizes?t=${Date.now()}`); const res = await fetch(`${API_BASE}/scan/sizes?t=${Date.now()}`);
return await res.json(); return await res.json();
} catch { } catch {
return { documents: 0, downloads: 0, desktop: 0, music: 0, movies: 0, system: 0, trash: 0, apps: 0, photos: 0, icloud: 0 }; return { documents: 0, downloads: 0, desktop: 0, music: 0, movies: 0, system: 0, trash: 0, apps: 0, photos: 0, icloud: 0 };
} }
}, },
deepestScan: async (): Promise<ScanResult[]> => { deepestScan: async (): Promise<ScanResult[]> => {
const res = await fetch(`${API_BASE}/scan/deepest`, { method: "POST" }); const res = await fetch(`${API_BASE}/scan/deepest`, { method: "POST" });
return await res.json() || []; return await res.json() || [];
}, },
purgePath: async (path: string): Promise<boolean> => { purgePath: async (path: string): Promise<boolean> => {
try { try {
const res = await fetch(`${API_BASE}/purge`, { const res = await fetch(`${API_BASE}/purge`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }), body: JSON.stringify({ path }),
}); });
return res.ok; return res.ok;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return false; return false;
} }
}, },
emptyTrash: async (): Promise<boolean> => { emptyTrash: async (): Promise<boolean> => {
try { try {
const res = await fetch(`${API_BASE}/empty-trash`, { method: "POST" }); const res = await fetch(`${API_BASE}/empty-trash`, { method: "POST" });
return res.ok; return res.ok;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return false; return false;
} }
}, },
clearCache: async (): Promise<{ cleared: number }> => { clearCache: async (): Promise<{ cleared: number }> => {
try { try {
const res = await fetch(`${API_BASE}/clear-cache`, { method: "POST" }); const res = await fetch(`${API_BASE}/clear-cache`, { method: "POST" });
return await res.json(); return await res.json();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return { cleared: 0 }; return { cleared: 0 };
} }
}, },
cleanDocker: async (): Promise<{ cleared: number; message: string }> => { cleanDocker: async (): Promise<{ cleared: number; message: string }> => {
try { try {
const res = await fetch(`${API_BASE}/clean-docker`, { method: "POST" }); const res = await fetch(`${API_BASE}/clean-docker`, { method: "POST" });
return await res.json(); return await res.json();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return { cleared: 0, message: "Docker cleanup failed" }; return { cleared: 0, message: "Docker cleanup failed" };
} }
}, },
getSystemInfo: async (): Promise<SystemInfo | null> => { cleanXcode: async (): Promise<{ cleared: number; message: string }> => {
try { try {
const res = await fetch(`${API_BASE}/system-info`); const res = await fetch(`${API_BASE}/clean-xcode`, { method: "POST" });
return await res.json(); return await res.json();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return null; return { cleared: 0, message: "Xcode cleanup failed" };
} }
}, },
getEstimates: async (): Promise<CleaningEstimates | null> => { cleanHomebrew: async (): Promise<{ cleared: number; message: string }> => {
try { try {
const res = await fetch(`${API_BASE}/estimates`); const res = await fetch(`${API_BASE}/clean-homebrew`, { method: "POST" });
return await res.json(); return await res.json();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return null; return { cleared: 0, message: "Homebrew cleanup failed" };
} }
}, },
// Uninstaller APIs getSystemInfo: async (): Promise<SystemInfo | null> => {
getApps: async (): Promise<AppInfo[]> => { try {
const res = await fetch(`${API_BASE}/apps`); const res = await fetch(`${API_BASE}/system-info`);
return res.json(); return await res.json();
}, } catch (e) {
console.error(e);
getAppDetails: async (path: string, bundleID?: string): Promise<AppDetails> => { return null;
const res = await fetch(`${API_BASE}/apps/details`, { }
method: 'POST', },
body: JSON.stringify({ path, bundleID }),
}); getEstimates: async (): Promise<CleaningEstimates | null> => {
return res.json(); try {
}, const res = await fetch(`${API_BASE}/estimates`);
return await res.json();
deleteAppFiles: async (files: string[]): Promise<void> => { } catch (e) {
const res = await fetch(`${API_BASE}/apps/action`, { console.error(e);
method: 'POST', return null;
body: JSON.stringify({ files }), }
}); },
if (!res.ok) throw new Error("Failed to delete files");
}, // Uninstaller APIs
getApps: async (): Promise<AppInfo[]> => {
uninstallApp: async (cmd: string): Promise<void> => { const res = await fetch(`${API_BASE}/apps`);
const res = await fetch(`${API_BASE}/apps/uninstall`, { return res.json();
method: 'POST', },
body: JSON.stringify({ cmd }),
}); getAppDetails: async (path: string, bundleID?: string): Promise<AppDetails> => {
if (!res.ok) throw new Error("Failed to launch uninstaller"); const res = await fetch(`${API_BASE}/apps/details`, {
}, method: 'POST',
body: JSON.stringify({ path, bundleID }),
getAppIcon: async (path: string): Promise<string> => { });
// Fallback or use Electron bridge directly return res.json();
if (window.electronAPI?.getAppIcon) { },
return window.electronAPI.getAppIcon(path);
} deleteAppFiles: async (files: string[]): Promise<void> => {
return ''; const res = await fetch(`${API_BASE}/apps/action`, {
} method: 'POST',
}; body: JSON.stringify({ files }),
});
if (!res.ok) throw new Error("Failed to delete files");
},
uninstallApp: async (cmd: string): Promise<void> => {
const res = await fetch(`${API_BASE}/apps/uninstall`, {
method: 'POST',
body: JSON.stringify({ cmd }),
});
if (!res.ok) throw new Error("Failed to launch uninstaller");
},
getAppIcon: async (path: string): Promise<string> => {
// Fallback or use Electron bridge directly
if (window.electronAPI?.getAppIcon) {
return window.electronAPI.getAppIcon(path);
}
return '';
}
};

File diff suppressed because it is too large Load diff

View file

@ -1,328 +1,328 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { ArrowLeft, Trash2, RefreshCw, Eraser, FileText, Settings, Database, Folder, AlertTriangle } from 'lucide-react'; import { ArrowLeft, Trash2, RefreshCw, Eraser, FileText, Settings, Database, Folder, AlertTriangle } from 'lucide-react';
import { API } from '../../api/client'; import { API } from '../../api/client';
import type { AppInfo, AppDetails } from '../../api/client'; import type { AppInfo, AppDetails } from '../../api/client';
import { GlassCard } from '../ui/GlassCard'; import { GlassCard } from '../ui/GlassCard';
import { GlassButton } from '../ui/GlassButton'; import { GlassButton } from '../ui/GlassButton';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
interface Props { interface Props {
app: AppInfo; app: AppInfo;
onBack: () => void; onBack: () => void;
onUninstall: () => void; onUninstall: () => void;
} }
export function AppDetailsView({ app, onBack, onUninstall }: Props) { export function AppDetailsView({ app, onBack, onUninstall }: Props) {
const [details, setDetails] = useState<AppDetails | null>(null); const [details, setDetails] = useState<AppDetails | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const toast = useToast(); const toast = useToast();
useEffect(() => { useEffect(() => {
loadDetails(); loadDetails();
}, [app.path]); }, [app.path]);
const loadDetails = async () => { const loadDetails = async () => {
try { try {
setLoading(true); setLoading(true);
const data = await API.getAppDetails(app.path, app.bundleID); const data = await API.getAppDetails(app.path, app.bundleID);
setDetails(data); setDetails(data);
// Select all by default // Select all by default
const allFiles = new Set<string>(); const allFiles = new Set<string>();
allFiles.add(data.path); // Main app bundle allFiles.add(data.path); // Main app bundle
data.associated.forEach(f => allFiles.add(f.path)); data.associated.forEach(f => allFiles.add(f.path));
setSelectedFiles(allFiles); setSelectedFiles(allFiles);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.addToast({ type: 'error', title: 'Error', message: 'Failed to load app details' }); toast.addToast({ type: 'error', title: 'Error', message: 'Failed to load app details' });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const formatSize = (bytes: number) => { const formatSize = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB']; const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes; let size = bytes;
let unitIndex = 0; let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) { while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024; size /= 1024;
unitIndex++; unitIndex++;
} }
return `${size.toFixed(1)} ${units[unitIndex]}`; return `${size.toFixed(1)} ${units[unitIndex]}`;
}; };
const toggleFile = (path: string) => { const toggleFile = (path: string) => {
const newSet = new Set(selectedFiles); const newSet = new Set(selectedFiles);
if (newSet.has(path)) { if (newSet.has(path)) {
newSet.delete(path); newSet.delete(path);
} else { } else {
newSet.add(path); newSet.add(path);
} }
setSelectedFiles(newSet); setSelectedFiles(newSet);
}; };
const getIconForType = (type: string) => { const getIconForType = (type: string) => {
switch (type) { switch (type) {
case 'config': return <Settings className="w-4 h-4 text-gray-400" />; case 'config': return <Settings className="w-4 h-4 text-gray-400" />;
case 'cache': return <Database className="w-4 h-4 text-yellow-400" />; case 'cache': return <Database className="w-4 h-4 text-yellow-400" />;
case 'log': return <FileText className="w-4 h-4 text-blue-400" />; case 'log': return <FileText className="w-4 h-4 text-blue-400" />;
case 'registry': return <Settings className="w-4 h-4 text-purple-400" />; case 'registry': return <Settings className="w-4 h-4 text-purple-400" />;
default: return <Folder className="w-4 h-4 text-gray-400" />; default: return <Folder className="w-4 h-4 text-gray-400" />;
} }
}; };
const handleAction = async (action: 'uninstall' | 'reset' | 'cache') => { const handleAction = async (action: 'uninstall' | 'reset' | 'cache') => {
if (!details) return; if (!details) return;
// Special handling for Uninstall with official uninstaller // Special handling for Uninstall with official uninstaller
if (action === 'uninstall' && details.uninstallString) { if (action === 'uninstall' && details.uninstallString) {
const confirmed = await toast.confirm( const confirmed = await toast.confirm(
`Run Uninstaller?`, `Run Uninstaller?`,
`This will launch the official uninstaller for ${details.name}.` `This will launch the official uninstaller for ${details.name}.`
); );
if (!confirmed) return; if (!confirmed) return;
try { try {
setProcessing(true); setProcessing(true);
await API.uninstallApp(details.uninstallString); await API.uninstallApp(details.uninstallString);
toast.addToast({ type: 'success', title: 'Success', message: 'Uninstaller launched' }); toast.addToast({ type: 'success', title: 'Success', message: 'Uninstaller launched' });
// We don't automatically close the view or reload apps because the uninstaller is external // We don't automatically close the view or reload apps because the uninstaller is external
// But we can go back // But we can go back
onBack(); onBack();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.addToast({ type: 'error', title: 'Error', message: 'Failed to launch uninstaller' }); toast.addToast({ type: 'error', title: 'Error', message: 'Failed to launch uninstaller' });
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
return; return;
} }
let filesToDelete: string[] = []; let filesToDelete: string[] = [];
if (action === 'uninstall') { if (action === 'uninstall') {
filesToDelete = Array.from(selectedFiles); filesToDelete = Array.from(selectedFiles);
} else if (action === 'reset') { } else if (action === 'reset') {
// Reset: Config + Data only, keep App // Reset: Config + Data only, keep App
filesToDelete = details.associated filesToDelete = details.associated
.filter(f => f.type === 'config' || f.type === 'data') .filter(f => f.type === 'config' || f.type === 'data')
.map(f => f.path); .map(f => f.path);
} else if (action === 'cache') { } else if (action === 'cache') {
// Cache: Caches + Logs // Cache: Caches + Logs
filesToDelete = details.associated filesToDelete = details.associated
.filter(f => f.type === 'cache' || f.type === 'log') .filter(f => f.type === 'cache' || f.type === 'log')
.map(f => f.path); .map(f => f.path);
} }
if (filesToDelete.length === 0) { if (filesToDelete.length === 0) {
toast.addToast({ type: 'info', title: 'Info', message: 'No files selected for this action' }); toast.addToast({ type: 'info', title: 'Info', message: 'No files selected for this action' });
return; return;
} }
// Confirmation (Simple browser confirm for now, better UI later) // Confirmation (Simple browser confirm for now, better UI later)
const confirmed = await toast.confirm( const confirmed = await toast.confirm(
`Delete ${filesToDelete.length} items?`, `Delete ${filesToDelete.length} items?`,
'This cannot be undone.' 'This cannot be undone.'
); );
if (!confirmed) return; if (!confirmed) return;
try { try {
setProcessing(true); setProcessing(true);
await API.deleteAppFiles(filesToDelete); await API.deleteAppFiles(filesToDelete);
toast.addToast({ type: 'success', title: 'Success', message: 'Cleaned up successfully' }); toast.addToast({ type: 'success', title: 'Success', message: 'Cleaned up successfully' });
if (action === 'uninstall') { if (action === 'uninstall') {
onUninstall(); onUninstall();
} else { } else {
loadDetails(); // Reload to show remaining files loadDetails(); // Reload to show remaining files
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.addToast({ type: 'error', title: 'Error', message: 'Failed to delete files' }); toast.addToast({ type: 'error', title: 'Error', message: 'Failed to delete files' });
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
}; };
if (loading || !details) { if (loading || !details) {
return ( return (
<div className="flex flex-col items-center justify-center py-20 text-gray-400"> <div className="flex flex-col items-center justify-center py-20 text-gray-400">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-4" /> <div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-4" />
<p>Analyzing application structure...</p> <p>Analyzing application structure...</p>
</div> </div>
); );
} }
const totalSelectedSize = Array.from(selectedFiles).reduce((acc, path) => { const totalSelectedSize = Array.from(selectedFiles).reduce((acc, path) => {
if (path === details.path) return acc + details.size; if (path === details.path) return acc + details.size;
const assoc = details.associated.find(f => f.path === path); const assoc = details.associated.find(f => f.path === path);
return acc + (assoc ? assoc.size : 0); return acc + (assoc ? assoc.size : 0);
}, 0); }, 0);
return ( return (
<div className="space-y-6 max-w-6xl mx-auto p-6 animate-in fade-in slide-in-from-right-4 duration-300"> <div className="space-y-6 max-w-6xl mx-auto p-6 animate-in fade-in slide-in-from-right-4 duration-300">
<header className="flex items-center gap-4 mb-8"> <header className="flex items-center gap-4 mb-8">
<button <button
onClick={onBack} onClick={onBack}
className="p-2.5 bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white shadow-sm hover:shadow-md" className="p-2.5 bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white shadow-sm hover:shadow-md"
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
</button> </button>
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{details.name} {details.name}
</h1> </h1>
<p className="text-gray-500 dark:text-gray-400 text-sm font-mono mt-1">{details.bundleID}</p> <p className="text-gray-500 dark:text-gray-400 text-sm font-mono mt-1">{details.bundleID}</p>
</div> </div>
</header> </header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* File List */} {/* File List */}
<div className="lg:col-span-2 space-y-4"> <div className="lg:col-span-2 space-y-4">
<GlassCard className="overflow-hidden"> <GlassCard className="overflow-hidden">
<div className="p-5 border-b border-gray-100 dark:border-white/5 flex items-center justify-between bg-gray-50/50 dark:bg-white/5"> <div className="p-5 border-b border-gray-100 dark:border-white/5 flex items-center justify-between bg-gray-50/50 dark:bg-white/5">
<span className="font-semibold text-gray-900 dark:text-gray-200">Application Bundle & Data</span> <span className="font-semibold text-gray-900 dark:text-gray-200">Application Bundle & Data</span>
<span className="text-sm font-medium px-3 py-1 rounded-full bg-blue-100/50 dark:bg-blue-500/20 text-blue-600 dark:text-blue-300"> <span className="text-sm font-medium px-3 py-1 rounded-full bg-blue-100/50 dark:bg-blue-500/20 text-blue-600 dark:text-blue-300">
{formatSize(totalSelectedSize)} selected {formatSize(totalSelectedSize)} selected
</span> </span>
</div> </div>
<div className="p-3 space-y-1"> <div className="p-3 space-y-1">
{/* Main App */} {/* Main App */}
<div <div
className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer border ${selectedFiles.has(details.path) className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer border ${selectedFiles.has(details.path)
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/30' ? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/30'
: 'hover:bg-gray-50 dark:hover:bg-white/5 border-transparent' : 'hover:bg-gray-50 dark:hover:bg-white/5 border-transparent'
}`} }`}
onClick={() => toggleFile(details.path)} onClick={() => toggleFile(details.path)}
> >
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${selectedFiles.has(details.path) <div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${selectedFiles.has(details.path)
? 'bg-blue-500 border-blue-500 text-white' ? 'bg-blue-500 border-blue-500 text-white'
: 'border-gray-300 dark:border-gray-600' : 'border-gray-300 dark:border-gray-600'
}`}> }`}>
{selectedFiles.has(details.path) && <Folder className="w-3 h-3" />} {selectedFiles.has(details.path) && <Folder className="w-3 h-3" />}
</div> </div>
<div className="p-2 rounded-lg bg-gray-100 dark:bg-white/5 text-gray-500 dark:text-gray-400"> <div className="p-2 rounded-lg bg-gray-100 dark:bg-white/5 text-gray-500 dark:text-gray-400">
<PackageIcon className="w-6 h-6" /> <PackageIcon className="w-6 h-6" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">Application Bundle</div> <div className="text-sm font-semibold text-gray-900 dark:text-gray-100">Application Bundle</div>
<div className="text-xs text-gray-500 truncate mt-0.5">{details.path}</div> <div className="text-xs text-gray-500 truncate mt-0.5">{details.path}</div>
</div> </div>
<span className="text-sm font-mono font-medium text-gray-600 dark:text-gray-400">{formatSize(details.size)}</span> <span className="text-sm font-mono font-medium text-gray-600 dark:text-gray-400">{formatSize(details.size)}</span>
</div> </div>
{/* Associated Files */} {/* Associated Files */}
{details.associated.map((file) => ( {details.associated.map((file) => (
<div <div
key={file.path} key={file.path}
className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer border ${selectedFiles.has(file.path) className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer border ${selectedFiles.has(file.path)
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/30' ? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/30'
: 'hover:bg-gray-50 dark:hover:bg-white/5 border-transparent' : 'hover:bg-gray-50 dark:hover:bg-white/5 border-transparent'
}`} }`}
onClick={() => toggleFile(file.path)} onClick={() => toggleFile(file.path)}
> >
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${selectedFiles.has(file.path) <div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${selectedFiles.has(file.path)
? 'bg-blue-500 border-blue-500 text-white' ? 'bg-blue-500 border-blue-500 text-white'
: 'border-gray-300 dark:border-gray-600' : 'border-gray-300 dark:border-gray-600'
}`}> }`}>
{selectedFiles.has(file.path) && <Folder className="w-3 h-3" />} {selectedFiles.has(file.path) && <Folder className="w-3 h-3" />}
</div> </div>
<div className="p-2 rounded-lg bg-gray-100 dark:bg-white/5"> <div className="p-2 rounded-lg bg-gray-100 dark:bg-white/5">
{getIconForType(file.type)} {getIconForType(file.type)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 capitalize">{file.type}</div> <div className="text-sm font-semibold text-gray-900 dark:text-gray-100 capitalize">{file.type}</div>
<div className="text-xs text-gray-500 truncate mt-0.5">{file.path}</div> <div className="text-xs text-gray-500 truncate mt-0.5">{file.path}</div>
</div> </div>
<span className="text-sm font-mono font-medium text-gray-600 dark:text-gray-400">{formatSize(file.size)}</span> <span className="text-sm font-mono font-medium text-gray-600 dark:text-gray-400">{formatSize(file.size)}</span>
</div> </div>
))} ))}
</div> </div>
</GlassCard> </GlassCard>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="space-y-6"> <div className="space-y-6">
<GlassCard className="p-5 space-y-5"> <GlassCard className="p-5 space-y-5">
<h3 className="font-semibold text-gray-900 dark:text-gray-200">Cleanup Actions</h3> <h3 className="font-semibold text-gray-900 dark:text-gray-200">Cleanup Actions</h3>
<GlassButton <GlassButton
variant="danger" variant="danger"
className="w-full justify-start gap-4 p-4 h-auto" className="w-full justify-start gap-4 p-4 h-auto"
onClick={() => handleAction('uninstall')} onClick={() => handleAction('uninstall')}
disabled={processing} disabled={processing}
> >
<div className="p-2 bg-white/20 rounded-lg"> <div className="p-2 bg-white/20 rounded-lg">
<Trash2 className="w-5 h-5" /> <Trash2 className="w-5 h-5" />
</div> </div>
<div className="flex flex-col items-start text-left"> <div className="flex flex-col items-start text-left">
<span className="font-semibold text-base">Uninstall</span> <span className="font-semibold text-base">Uninstall</span>
<span className="text-xs opacity-90 font-normal">Remove {selectedFiles.size} selected items</span> <span className="text-xs opacity-90 font-normal">Remove {selectedFiles.size} selected items</span>
</div> </div>
</GlassButton> </GlassButton>
<div className="h-px bg-gray-100 dark:bg-white/10 my-2" /> <div className="h-px bg-gray-100 dark:bg-white/10 my-2" />
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
<button <button
onClick={() => handleAction('reset')} onClick={() => handleAction('reset')}
disabled={processing} disabled={processing}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-white/10 group" className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-white/10 group"
> >
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 group-hover:bg-blue-100 dark:group-hover:bg-blue-500/20 transition-colors"> <div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 group-hover:bg-blue-100 dark:group-hover:bg-blue-500/20 transition-colors">
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
</div> </div>
<div className="flex flex-col items-start text-left"> <div className="flex flex-col items-start text-left">
<span className="font-medium text-gray-900 dark:text-gray-200 text-sm">Reset Application</span> <span className="font-medium text-gray-900 dark:text-gray-200 text-sm">Reset Application</span>
<span className="text-xs text-gray-500">Delete config & data only</span> <span className="text-xs text-gray-500">Delete config & data only</span>
</div> </div>
</button> </button>
<button <button
onClick={() => handleAction('cache')} onClick={() => handleAction('cache')}
disabled={processing} disabled={processing}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-white/10 group" className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-white/10 group"
> >
<div className="p-2 rounded-lg bg-orange-50 dark:bg-orange-500/10 text-orange-600 dark:text-orange-400 group-hover:bg-orange-100 dark:group-hover:bg-orange-500/20 transition-colors"> <div className="p-2 rounded-lg bg-orange-50 dark:bg-orange-500/10 text-orange-600 dark:text-orange-400 group-hover:bg-orange-100 dark:group-hover:bg-orange-500/20 transition-colors">
<Eraser className="w-4 h-4" /> <Eraser className="w-4 h-4" />
</div> </div>
<div className="flex flex-col items-start text-left"> <div className="flex flex-col items-start text-left">
<span className="font-medium text-gray-900 dark:text-gray-200 text-sm">Clear Cache</span> <span className="font-medium text-gray-900 dark:text-gray-200 text-sm">Clear Cache</span>
<span className="text-xs text-gray-500">Remove temporary files</span> <span className="text-xs text-gray-500">Remove temporary files</span>
</div> </div>
</button> </button>
</div> </div>
</GlassCard> </GlassCard>
<div className="p-4 rounded-xl bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 flex gap-3"> <div className="p-4 rounded-xl bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 flex gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 shrink-0 mt-0.5" /> <AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 shrink-0 mt-0.5" />
<p className="text-xs text-yellow-800 dark:text-yellow-200/80 leading-relaxed font-medium"> <p className="text-xs text-yellow-800 dark:text-yellow-200/80 leading-relaxed font-medium">
Deleted files cannot be recovered. Ensure you have backups of important data before uninstalling applications. Deleted files cannot be recovered. Ensure you have backups of important data before uninstalling applications.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
function PackageIcon({ className }: { className?: string }) { function PackageIcon({ className }: { className?: string }) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className={className} className={className}
> >
<path d="m16.5 9.4-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /> <path d="m16.5 9.4-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" /> <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" /> <line x1="12" y1="22.08" x2="12" y2="12" />
</svg> </svg>
) )
} }

View file

@ -1,212 +1,212 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Search, Package, LayoutGrid, List, ArrowUpDown } from 'lucide-react'; import { Search, Package, LayoutGrid, List, ArrowUpDown } from 'lucide-react';
import { API } from '../../api/client'; import { API } from '../../api/client';
import type { AppInfo } from '../../api/client'; import type { AppInfo } from '../../api/client';
import { GlassCard } from '../ui/GlassCard'; import { GlassCard } from '../ui/GlassCard';
import { AppDetailsView } from './AppDetails'; import { AppDetailsView } from './AppDetails';
export function AppsView() { export function AppsView() {
const [apps, setApps] = useState<AppInfo[]>([]); const [apps, setApps] = useState<AppInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null); const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortMode, setSortMode] = useState<'size' | 'name'>('size'); const [sortMode, setSortMode] = useState<'size' | 'name'>('size');
useEffect(() => { useEffect(() => {
loadApps(); loadApps();
}, []); }, []);
const loadApps = async () => { const loadApps = async () => {
try { try {
setLoading(true); setLoading(true);
const data = await API.getApps(); const data = await API.getApps();
// Initial load - sort logic handled in render/memo usually but let's just set raw data // Initial load - sort logic handled in render/memo usually but let's just set raw data
// Populate icons // Populate icons
const appsWithIcons = await Promise.all(data.map(async (app) => { const appsWithIcons = await Promise.all(data.map(async (app) => {
const icon = await API.getAppIcon(app.path); const icon = await API.getAppIcon(app.path);
return { ...app, icon }; return { ...app, icon };
})); }));
setApps(appsWithIcons); setApps(appsWithIcons);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const formatSize = (bytes: number) => { const formatSize = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB']; const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes; let size = bytes;
let unitIndex = 0; let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) { while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024; size /= 1024;
unitIndex++; unitIndex++;
} }
return `${size.toFixed(1)} ${units[unitIndex]}`; return `${size.toFixed(1)} ${units[unitIndex]}`;
}; };
const sortedApps = [...apps].filter(app => const sortedApps = [...apps].filter(app =>
app.name.toLowerCase().includes(search.toLowerCase()) app.name.toLowerCase().includes(search.toLowerCase())
).sort((a, b) => { ).sort((a, b) => {
if (sortMode === 'size') { if (sortMode === 'size') {
return b.size - a.size; return b.size - a.size;
} else { } else {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
} }
}); });
if (selectedApp) { if (selectedApp) {
return ( return (
<AppDetailsView <AppDetailsView
app={selectedApp} app={selectedApp}
onBack={() => setSelectedApp(null)} onBack={() => setSelectedApp(null)}
onUninstall={() => { onUninstall={() => {
loadApps(); loadApps();
setSelectedApp(null); setSelectedApp(null);
}} }}
/> />
); );
} }
return ( return (
<div className="space-y-6 max-w-7xl mx-auto p-6 h-full flex flex-col"> <div className="space-y-6 max-w-7xl mx-auto p-6 h-full flex flex-col">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4 shrink-0"> <header className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4 shrink-0">
<div> <div>
<h1 className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-400 dark:from-blue-300 dark:to-purple-300"> <h1 className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-400 dark:from-blue-300 dark:to-purple-300">
App Uninstaller App Uninstaller
</h1> </h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">Scan and remove applications completely</p> <p className="text-gray-500 dark:text-gray-400 mt-1">Scan and remove applications completely</p>
</div> </div>
<div className="flex items-center gap-3 w-full md:w-auto"> <div className="flex items-center gap-3 w-full md:w-auto">
{/* View Toggle */} {/* View Toggle */}
<div className="flex items-center bg-gray-100 dark:bg-white/5 rounded-lg p-1 border border-gray-200 dark:border-white/10"> <div className="flex items-center bg-gray-100 dark:bg-white/5 rounded-lg p-1 border border-gray-200 dark:border-white/10">
<button <button
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-2 rounded-md transition-all ${viewMode === 'grid' ? 'bg-white dark:bg-white/10 shadow-sm text-blue-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`} className={`p-2 rounded-md transition-all ${viewMode === 'grid' ? 'bg-white dark:bg-white/10 shadow-sm text-blue-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
> >
<LayoutGrid className="w-4 h-4" /> <LayoutGrid className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
className={`p-2 rounded-md transition-all ${viewMode === 'list' ? 'bg-white dark:bg-white/10 shadow-sm text-blue-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`} className={`p-2 rounded-md transition-all ${viewMode === 'list' ? 'bg-white dark:bg-white/10 shadow-sm text-blue-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
> >
<List className="w-4 h-4" /> <List className="w-4 h-4" />
</button> </button>
</div> </div>
{/* Sort Dropdown (Simple Toggle for now) */} {/* Sort Dropdown (Simple Toggle for now) */}
<button <button
onClick={() => setSortMode(prev => prev === 'size' ? 'name' : 'size')} onClick={() => setSortMode(prev => prev === 'size' ? 'name' : 'size')}
className="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/10 transition-colors" className="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/10 transition-colors"
> >
<ArrowUpDown className="w-4 h-4" /> <ArrowUpDown className="w-4 h-4" />
<span>Sort by {sortMode === 'size' ? 'Size' : 'Name'}</span> <span>Sort by {sortMode === 'size' ? 'Size' : 'Name'}</span>
</button> </button>
{/* Search */} {/* Search */}
<div className="relative w-full md:w-64"> <div className="relative w-full md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input <input
type="text" type="text"
placeholder="Search apps..." placeholder="Search apps..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full bg-white/50 dark:bg-black/20 border border-gray-200 dark:border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/50 transition-all text-gray-900 dark:text-white placeholder-gray-400" className="w-full bg-white/50 dark:bg-black/20 border border-gray-200 dark:border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/50 transition-all text-gray-900 dark:text-white placeholder-gray-400"
/> />
</div> </div>
</div> </div>
</header> </header>
{loading ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-32 text-gray-400"> <div className="flex flex-col items-center justify-center py-32 text-gray-400">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-4" /> <div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-4" />
<p className="font-medium">Scanning applications...</p> <p className="font-medium">Scanning applications...</p>
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-y-auto pr-2 -mr-2"> <div className="flex-1 overflow-y-auto pr-2 -mr-2">
{viewMode === 'grid' ? ( {viewMode === 'grid' ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 pb-10"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 pb-10">
<AnimatePresence> <AnimatePresence>
{sortedApps.map((app) => ( {sortedApps.map((app) => (
<motion.div <motion.div
key={app.bundleID} key={app.bundleID}
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }} exit={{ opacity: 0, scale: 0.95 }}
layout layout
> >
<GlassCard <GlassCard
className="group hover:bg-white/80 dark:hover:bg-white/5 transition-all duration-300 cursor-pointer h-full border border-transparent hover:border-blue-500/20 hover:shadow-xl hover:shadow-blue-500/5 hover:-translate-y-1" className="group hover:bg-white/80 dark:hover:bg-white/5 transition-all duration-300 cursor-pointer h-full border border-transparent hover:border-blue-500/20 hover:shadow-xl hover:shadow-blue-500/5 hover:-translate-y-1"
onClick={() => setSelectedApp(app)} onClick={() => setSelectedApp(app)}
> >
<div className="flex flex-col items-center p-5 text-center h-full"> <div className="flex flex-col items-center p-5 text-center h-full">
<div className="w-16 h-16 mb-4 relative"> <div className="w-16 h-16 mb-4 relative">
{app.icon ? ( {app.icon ? (
<img src={app.icon} alt={app.name} className="w-full h-full object-contain drop-shadow-md" /> <img src={app.icon} alt={app.name} className="w-full h-full object-contain drop-shadow-md" />
) : ( ) : (
<div className="w-full h-full rounded-xl bg-gradient-to-br from-blue-500/10 to-purple-500/10 flex items-center justify-center text-blue-400"> <div className="w-full h-full rounded-xl bg-gradient-to-br from-blue-500/10 to-purple-500/10 flex items-center justify-center text-blue-400">
<Package className="w-8 h-8 opacity-50" /> <Package className="w-8 h-8 opacity-50" />
</div> </div>
)} )}
</div> </div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-1 w-full text-sm md:text-base" title={app.name}> <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-1 w-full text-sm md:text-base" title={app.name}>
{app.name} {app.name}
</h3> </h3>
<p className="text-[10px] md:text-xs text-gray-500 dark:text-gray-400 font-mono mb-3 bg-gray-100 dark:bg-white/5 px-2 py-0.5 rounded-full"> <p className="text-[10px] md:text-xs text-gray-500 dark:text-gray-400 font-mono mb-3 bg-gray-100 dark:bg-white/5 px-2 py-0.5 rounded-full">
{formatSize(app.size)} {formatSize(app.size)}
</p> </p>
</div> </div>
</GlassCard> </GlassCard>
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
</div> </div>
) : ( ) : (
// List View Logic // List View Logic
<div className="flex flex-col gap-2 pb-10"> <div className="flex flex-col gap-2 pb-10">
{sortedApps.map((app) => ( {sortedApps.map((app) => (
<motion.div <motion.div
key={app.bundleID} key={app.bundleID}
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="group flex items-center gap-4 p-3 rounded-xl hover:bg-white/80 dark:hover:bg-white/5 border border-transparent hover:border-gray-200 dark:hover:border-white/10 transition-all cursor-pointer" className="group flex items-center gap-4 p-3 rounded-xl hover:bg-white/80 dark:hover:bg-white/5 border border-transparent hover:border-gray-200 dark:hover:border-white/10 transition-all cursor-pointer"
onClick={() => setSelectedApp(app)} onClick={() => setSelectedApp(app)}
> >
<div className="w-10 h-10 shrink-0"> <div className="w-10 h-10 shrink-0">
{app.icon ? ( {app.icon ? (
<img src={app.icon} alt={app.name} className="w-full h-full object-contain" /> <img src={app.icon} alt={app.name} className="w-full h-full object-contain" />
) : ( ) : (
<div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center"> <div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center">
<Package className="w-5 h-5 text-blue-500" /> <Package className="w-5 h-5 text-blue-500" />
</div> </div>
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm md:text-base">{app.name}</h3> <h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm md:text-base">{app.name}</h3>
<p className="text-[11px] md:text-xs text-gray-500 dark:text-gray-400 truncate">{app.path}</p> <p className="text-[11px] md:text-xs text-gray-500 dark:text-gray-400 truncate">{app.path}</p>
</div> </div>
<div className="shrink-0 text-xs md:text-sm font-mono text-gray-600 dark:text-gray-400"> <div className="shrink-0 text-xs md:text-sm font-mono text-gray-600 dark:text-gray-400">
{formatSize(app.size)} {formatSize(app.size)}
</div> </div>
</motion.div> </motion.div>
))} ))}
</div> </div>
)} )}
{sortedApps.length === 0 && ( {sortedApps.length === 0 && (
<div className="text-center py-20 text-gray-500"> <div className="text-center py-20 text-gray-500">
No applications found matching "{search}" No applications found matching "{search}"
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
); );
} }

View file

@ -1,4 +1,4 @@
export { GlassCard } from './GlassCard'; export { GlassCard } from './GlassCard';
export { GlassButton } from './GlassButton'; export { GlassButton } from './GlassButton';
export { ToastProvider, useToast } from './Toast'; export { ToastProvider, useToast } from './Toast';
export { Tooltip } from './Tooltip'; export { Tooltip } from './Tooltip';

View file

@ -1,244 +1,244 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ============================================ /* ============================================
LIQUID GLASS DESIGN SYSTEM (macOS 26 Tahoe) LIQUID GLASS DESIGN SYSTEM (macOS 26 Tahoe)
============================================ */ ============================================ */
:root { :root {
/* Glass Effect Variables */ /* Glass Effect Variables */
--glass-bg: rgba(255, 255, 255, 0.72); --glass-bg: rgba(255, 255, 255, 0.72);
--glass-bg-light: rgba(255, 255, 255, 0.85); --glass-bg-light: rgba(255, 255, 255, 0.85);
--glass-bg-heavy: rgba(255, 255, 255, 0.92); --glass-bg-heavy: rgba(255, 255, 255, 0.92);
--glass-blur: 40px; --glass-blur: 40px;
--glass-blur-light: 20px; --glass-blur-light: 20px;
--glass-border: rgba(255, 255, 255, 0.18); --glass-border: rgba(255, 255, 255, 0.18);
--glass-border-strong: rgba(255, 255, 255, 0.35); --glass-border-strong: rgba(255, 255, 255, 0.35);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.08); --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
--glass-shadow-elevated: 0 16px 48px rgba(0, 0, 0, 0.12); --glass-shadow-elevated: 0 16px 48px rgba(0, 0, 0, 0.12);
--glass-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.5); --glass-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
/* Radius System */ /* Radius System */
--radius-xs: 8px; --radius-xs: 8px;
--radius-sm: 12px; --radius-sm: 12px;
--radius-md: 16px; --radius-md: 16px;
--radius-lg: 20px; --radius-lg: 20px;
--radius-xl: 24px; --radius-xl: 24px;
--radius-full: 9999px; --radius-full: 9999px;
/* Timing Functions */ /* Timing Functions */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1); --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55); --ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
/* Base Colors */ /* Base Colors */
--color-surface: #f5f5f7; --color-surface: #f5f5f7;
--color-text: #1d1d1f; --color-text: #1d1d1f;
--color-text-secondary: rgba(0, 0, 0, 0.55); --color-text-secondary: rgba(0, 0, 0, 0.55);
--color-accent: #007AFF; --color-accent: #007AFF;
--color-danger: #FF3B30; --color-danger: #FF3B30;
--color-success: #34C759; --color-success: #34C759;
background-color: var(--color-surface); background-color: var(--color-surface);
color: var(--color-text); color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--glass-bg: rgba(30, 30, 30, 0.72); --glass-bg: rgba(30, 30, 30, 0.72);
--glass-bg-light: rgba(40, 40, 40, 0.85); --glass-bg-light: rgba(40, 40, 40, 0.85);
--glass-bg-heavy: rgba(20, 20, 20, 0.92); --glass-bg-heavy: rgba(20, 20, 20, 0.92);
--glass-border: rgba(255, 255, 255, 0.1); --glass-border: rgba(255, 255, 255, 0.1);
--glass-border-strong: rgba(255, 255, 255, 0.15); --glass-border-strong: rgba(255, 255, 255, 0.15);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
--glass-shadow-elevated: 0 16px 48px rgba(0, 0, 0, 0.5); --glass-shadow-elevated: 0 16px 48px rgba(0, 0, 0, 0.5);
--glass-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.1); --glass-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
--color-surface: #000000; --color-surface: #000000;
--color-text: #f5f5f7; --color-text: #f5f5f7;
--color-text-secondary: rgba(255, 255, 255, 0.55); --color-text-secondary: rgba(255, 255, 255, 0.55);
} }
} }
.dark { .dark {
--glass-bg: rgba(20, 20, 20, 0.7); --glass-bg: rgba(20, 20, 20, 0.7);
--glass-bg-light: rgba(30, 30, 30, 0.8); --glass-bg-light: rgba(30, 30, 30, 0.8);
--glass-bg-heavy: rgba(10, 10, 10, 0.9); --glass-bg-heavy: rgba(10, 10, 10, 0.9);
--glass-border: rgba(255, 255, 255, 0.08); --glass-border: rgba(255, 255, 255, 0.08);
--glass-border-strong: rgba(255, 255, 255, 0.12); --glass-border-strong: rgba(255, 255, 255, 0.12);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--glass-shadow-elevated: 0 16px 48px rgba(0, 0, 0, 0.6); --glass-shadow-elevated: 0 16px 48px rgba(0, 0, 0, 0.6);
--glass-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.05); --glass-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
--color-surface: #000000; --color-surface: #000000;
--color-text: #f5f5f7; --color-text: #f5f5f7;
--color-text-secondary: rgba(255, 255, 255, 0.55); --color-text-secondary: rgba(255, 255, 255, 0.55);
} }
body { body {
margin: 0; margin: 0;
overflow-x: hidden; overflow-x: hidden;
user-select: none; user-select: none;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
button { button {
cursor: default; cursor: default;
} }
/* ============================================ /* ============================================
GLASS UTILITY CLASSES GLASS UTILITY CLASSES
============================================ */ ============================================ */
.liquid-glass { .liquid-glass {
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)); backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow), var(--glass-inner-glow); box-shadow: var(--glass-shadow), var(--glass-inner-glow);
} }
.liquid-glass-light { .liquid-glass-light {
background: var(--glass-bg-light); background: var(--glass-bg-light);
backdrop-filter: blur(var(--glass-blur-light)); backdrop-filter: blur(var(--glass-blur-light));
-webkit-backdrop-filter: blur(var(--glass-blur-light)); -webkit-backdrop-filter: blur(var(--glass-blur-light));
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow); box-shadow: var(--glass-shadow);
} }
.liquid-glass-heavy { .liquid-glass-heavy {
background: var(--glass-bg-heavy); background: var(--glass-bg-heavy);
backdrop-filter: blur(var(--glass-blur)); backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border-strong); border: 1px solid var(--glass-border-strong);
box-shadow: var(--glass-shadow-elevated), var(--glass-inner-glow); box-shadow: var(--glass-shadow-elevated), var(--glass-inner-glow);
} }
.glass-hover { .glass-hover {
transition: transform 0.2s var(--ease-spring), box-shadow 0.2s var(--ease-smooth); transition: transform 0.2s var(--ease-spring), box-shadow 0.2s var(--ease-smooth);
} }
.glass-hover:hover { .glass-hover:hover {
transform: translateY(-2px) scale(1.01); transform: translateY(-2px) scale(1.01);
box-shadow: var(--glass-shadow-elevated), var(--glass-inner-glow); box-shadow: var(--glass-shadow-elevated), var(--glass-inner-glow);
} }
.glass-press { .glass-press {
transition: transform 0.1s var(--ease-smooth); transition: transform 0.1s var(--ease-smooth);
} }
.glass-press:active { .glass-press:active {
transform: scale(0.97); transform: scale(0.97);
} }
/* ============================================ /* ============================================
ANIMATION KEYFRAMES ANIMATION KEYFRAMES
============================================ */ ============================================ */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(8px) scale(0.98); transform: translateY(8px) scale(0.98);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
} }
@keyframes slideUp { @keyframes slideUp {
from { from {
opacity: 0; opacity: 0;
transform: translateY(24px); transform: translateY(24px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes scaleIn { @keyframes scaleIn {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95);
} }
to { to {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
} }
} }
@keyframes shimmer { @keyframes shimmer {
0% { 0% {
background-position: -200% 0; background-position: -200% 0;
} }
100% { 100% {
background-position: 200% 0; background-position: 200% 0;
} }
} }
@keyframes pulse-glow { @keyframes pulse-glow {
0%, 0%,
100% { 100% {
box-shadow: var(--glass-shadow); box-shadow: var(--glass-shadow);
} }
50% { 50% {
box-shadow: var(--glass-shadow-elevated), 0 0 20px rgba(0, 122, 255, 0.15); box-shadow: var(--glass-shadow-elevated), 0 0 20px rgba(0, 122, 255, 0.15);
} }
} }
/* Animation Utility Classes */ /* Animation Utility Classes */
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.4s var(--ease-spring) forwards; animation: fadeIn 0.4s var(--ease-spring) forwards;
} }
.animate-slide-up { .animate-slide-up {
animation: slideUp 0.5s var(--ease-spring) forwards; animation: slideUp 0.5s var(--ease-spring) forwards;
} }
.animate-scale-in { .animate-scale-in {
animation: scaleIn 0.3s var(--ease-spring) forwards; animation: scaleIn 0.3s var(--ease-spring) forwards;
} }
/* Stagger children animations */ /* Stagger children animations */
.stagger-children>* { .stagger-children>* {
animation: fadeIn 0.4s var(--ease-spring) forwards; animation: fadeIn 0.4s var(--ease-spring) forwards;
opacity: 0; opacity: 0;
} }
.stagger-children>*:nth-child(1) { .stagger-children>*:nth-child(1) {
animation-delay: 0s; animation-delay: 0s;
} }
.stagger-children>*:nth-child(2) { .stagger-children>*:nth-child(2) {
animation-delay: 0.05s; animation-delay: 0.05s;
} }
.stagger-children>*:nth-child(3) { .stagger-children>*:nth-child(3) {
animation-delay: 0.1s; animation-delay: 0.1s;
} }
.stagger-children>*:nth-child(4) { .stagger-children>*:nth-child(4) {
animation-delay: 0.15s; animation-delay: 0.15s;
} }
.stagger-children>*:nth-child(5) { .stagger-children>*:nth-child(5) {
animation-delay: 0.2s; animation-delay: 0.2s;
} }
.stagger-children>*:nth-child(6) { .stagger-children>*:nth-child(6) {
animation-delay: 0.25s; animation-delay: 0.25s;
} }

View file

@ -1,55 +1,55 @@
# Start-Dev.ps1 - Windows equivalent of start-go.sh # Start-Dev.ps1 - Windows equivalent of start-go.sh
Write-Host "Starting Antigravity (Windows Mode)..." -ForegroundColor Green Write-Host "Starting Antigravity (Windows Mode)..." -ForegroundColor Green
# 1. Kill existing backend on port 36969 if running # 1. Kill existing backend on port 36969 if running
$port = 36969 $port = 36969
$process = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique $process = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique
if ($process) { if ($process) {
Write-Host "Killing existing backend process (PID: $process)..." -ForegroundColor Yellow Write-Host "Killing existing backend process (PID: $process)..." -ForegroundColor Yellow
Stop-Process -Id $process -Force -ErrorAction SilentlyContinue Stop-Process -Id $process -Force -ErrorAction SilentlyContinue
} }
# 2. Check for Go # 2. Check for Go
if (-not (Get-Command "go" -ErrorAction SilentlyContinue)) { if (-not (Get-Command "go" -ErrorAction SilentlyContinue)) {
Write-Host "Go is not installed or not in PATH." -ForegroundColor Red Write-Host "Go is not installed or not in PATH." -ForegroundColor Red
exit 1 exit 1
} }
# 3. Check for pnpm or fallback # 3. Check for pnpm or fallback
$pkgManager = "pnpm" $pkgManager = "pnpm"
if (-not (Get-Command "pnpm" -ErrorAction SilentlyContinue)) { if (-not (Get-Command "pnpm" -ErrorAction SilentlyContinue)) {
if (Get-Command "npm" -ErrorAction SilentlyContinue) { if (Get-Command "npm" -ErrorAction SilentlyContinue) {
$pkgManager = "npm" $pkgManager = "npm"
} }
else { else {
Write-Host "pnpm/npm not found." -ForegroundColor Red Write-Host "pnpm/npm not found." -ForegroundColor Red
exit 1 exit 1
} }
} }
# 4. Start Backend in background # 4. Start Backend in background
Write-Host "Starting Go Backend..." -ForegroundColor Cyan Write-Host "Starting Go Backend..." -ForegroundColor Cyan
$env:APP_ENV = "development" $env:APP_ENV = "development"
$backendJob = Start-Process -FilePath "go" -ArgumentList "run backend/main.go" -NoNewWindow -PassThru $backendJob = Start-Process -FilePath "go" -ArgumentList "run backend/main.go" -NoNewWindow -PassThru
Write-Host "Backend started (Simple PID: $($backendJob.Id))" -ForegroundColor Gray Write-Host "Backend started (Simple PID: $($backendJob.Id))" -ForegroundColor Gray
# 5. Start Frontend # 5. Start Frontend
Write-Host "Checking dependencies..." -ForegroundColor Cyan Write-Host "Checking dependencies..." -ForegroundColor Cyan
if (-not (Test-Path "node_modules\.bin\vite.ps1") -and -not (Test-Path "node_modules\.bin\vite.cmd")) { if (-not (Test-Path "node_modules\.bin\vite.ps1") -and -not (Test-Path "node_modules\.bin\vite.cmd")) {
Write-Host "Dependencies missing. Running install..." -ForegroundColor Yellow Write-Host "Dependencies missing. Running install..." -ForegroundColor Yellow
if ($pkgManager -eq "pnpm") { if ($pkgManager -eq "pnpm") {
pnpm install pnpm install
} }
else { else {
npm install npm install
} }
} }
Write-Host "Starting Frontend ($pkgManager run dev)..." -ForegroundColor Cyan Write-Host "Starting Frontend ($pkgManager run dev)..." -ForegroundColor Cyan
if ($pkgManager -eq "pnpm") { if ($pkgManager -eq "pnpm") {
pnpm run dev pnpm run dev
} }
else { else {
npm run dev npm run dev
} }

View file

@ -26,7 +26,7 @@ GO_PID=$!
echo "✨ Starting Frontend..." echo "✨ Starting Frontend..."
# Check for pnpm or fallback # Check for pnpm or fallback
if command -v pnpm &> /dev/null; then if command -v pnpm &> /dev/null; then
pnpm run dev pnpm run dev:electron
else else
# Fallback if pnpm is also missing from PATH but bun is there # Fallback if pnpm is also missing from PATH but bun is there
bun run dev bun run dev