Compare commits

...

No commits in common. "v1.0.0" and "main" have entirely different histories.
v1.0.0 ... main

61 changed files with 9394 additions and 7803 deletions

7
.gitignore vendored Executable file → Normal file
View file

@ -22,6 +22,9 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Release artifacts
# Release Artifacts
Release/
release/
*.zip
*.exe
backend/dist/

0
.npmrc Executable file → Normal file
View file

33
README.md Executable file → Normal file
View file

@ -5,22 +5,24 @@ A modern, high-performance system optimizer for macOS, built with **Electron**,
![App Screenshot](https://via.placeholder.com/800x500?text=Antigravity+Dashboard)
## Features
- **Flash Clean**: Instantly remove system caches, logs, and trash.
- **Flash Clean**: Instantly remove system caches, logs, Xcode cache, Homebrew cache, and manage Trash with a detailed inspection view.
- **App Uninstaller**: View installed applications, their sizes, and thoroughly remove them along with their associated preference files and caches.
- **Deep Clean**: Scan for large files and heavy folders.
- **Real-time Monitoring**: Track disk usage and category sizes.
- **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.
- **Native Menubar Integration**: Includes a responsive, monochrome template icon that adapts to macOS light/dark modes perfectly.
- **Cross-Platform**: Runs natively with compiled Go backends on Apple Silicon (M1/M2/M3), Intel Macs, and Windows.
## Prerequisites
- **Node.js** (v18+)
- **Go** (v1.20+)
- **pnpm** (preferred) or npm
- **C Compiler** (gcc/clang, via Xcode Command Line Tools on macOS)
## Development
### 1. Install Dependencies
```bash
npm install
pnpm install
```
### 2. Run in Development Mode
@ -28,28 +30,25 @@ This starts the Go backend (port 36969) and the Vite/Electron frontend concurren
```bash
./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 `pnpm run dev` directly if you want the backend to work. Use the script.*
## Building for Production
To create a distributable `.dmg` file for macOS:
To create distributable release binaries (Universal `.dmg` for macOS, Portable `.exe` for Windows):
### 1. Build the App
```bash
npm run build:mac
# macOS Universal DMG
pnpm run build && pnpm run electron:build && npx electron-builder --mac --universal
# Windows Portable EXE
pnpm run build && pnpm run electron:build && npx electron-builder --win portable --x64
```
This command will:
1. Compile the Go backend for both `amd64` and `arm64`.
2. Create a universal binary using `lipo`.
3. Build the React frontend.
4. Package the Electron app and bundle the backend.
5. Generate a universal `.dmg`.
### 2. Locate the Installer
The output file will be at:
```
release/KV Clearnup-1.0.0-universal.dmg
```
The output files will be automatically placed in the `release/` directory:
- `release/KV Clearnup-1.0.0-universal.dmg` (macOS)
- `release/KV Clearnup 1.0.0.exe` (Windows)
## Running the App
1. **Mount the DMG**: Double-click the `.dmg` file in the `release` folder.

View file

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

View file

@ -1,3 +1,5 @@
//go:build darwin
package apps
import (
@ -9,25 +11,7 @@ import (
"sync"
)
type AppInfo struct {
Name string `json:"name"`
Path string `json:"path"`
BundleID string `json:"bundleID"`
Size int64 `json:"size"`
Icon string `json:"icon,omitempty"` // Base64 or path? For now just path to .app (frontend can get icon)
}
type AssociatedFile struct {
Path string `json:"path"`
Type string `json:"type"` // "cache", "config", "log", "data"
Size int64 `json:"size"`
}
type AppDetails struct {
AppInfo
Associated []AssociatedFile `json:"associated"`
TotalSize int64 `json:"totalSize"`
}
// Structs moved to apps_common.go
// ScanApps returns a list of installed applications
func ScanApps() ([]AppInfo, error) {
@ -81,7 +65,7 @@ func ScanApps() ([]AppInfo, error) {
}
// GetAppDetails finds all associated files for a given app path
func GetAppDetails(appPath string) (*AppDetails, error) {
func GetAppDetails(appPath, _ string) (*AppDetails, error) {
bid := getBundleID(appPath)
if bid == "" {
return nil, fmt.Errorf("could not determine bundle ID")
@ -215,3 +199,8 @@ func getType(locName string) string {
return "data"
}
}
// RunUninstaller executes the uninstall command (Not implemented on Mac yet)
func RunUninstaller(cmdString string) error {
return fmt.Errorf("uninstall not supported on macOS yet")
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
//go:build darwin
package scanner
import (
@ -10,33 +12,10 @@ import (
"strings"
)
type ScanResult struct {
Path string `json:"path"`
Size int64 `json:"size"`
IsDirectory bool `json:"isDirectory"`
}
type DiskUsage struct {
TotalGB string `json:"totalGB"`
UsedGB string `json:"usedGB"`
FreeGB string `json:"freeGB"`
}
type CategorySizes struct {
Documents int64 `json:"documents"` // Personal Docs only
Downloads int64 `json:"downloads"`
Desktop int64 `json:"desktop"`
Music int64 `json:"music"`
Movies int64 `json:"movies"`
System int64 `json:"system"`
Trash int64 `json:"trash"`
Apps int64 `json:"apps"`
Photos int64 `json:"photos"`
ICloud int64 `json:"icloud"`
}
// Structs moved to scanner_common.go
// GetDiskUsage uses diskutil for accurate APFS disk usage
func GetDiskUsage() (*DiskUsage, error) {
func GetDiskUsage() ([]*DiskUsage, error) {
cmd := exec.Command("diskutil", "info", "/")
out, err := cmd.Output()
if err != nil {
@ -80,59 +59,15 @@ func GetDiskUsage() (*DiskUsage, error) {
return fmt.Sprintf("%.2f", gb)
}
return &DiskUsage{
return []*DiskUsage{{
Name: "Macintosh HD",
TotalGB: toGB(containerTotal),
UsedGB: toGB(containerUsed),
FreeGB: toGB(containerFree),
}, nil
}}, nil
}
// FindLargeFiles walks a directory and returns files > threshold
func FindLargeFiles(root string, threshold int64) ([]ScanResult, error) {
var results []ScanResult
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // Skip errors
}
// Skip hidden files/dirs (except .Trash maybe, but let's skip all . for now)
if strings.HasPrefix(d.Name(), ".") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
// Skip node_modules explicitly
if d.IsDir() && d.Name() == "node_modules" {
return filepath.SkipDir
}
if !d.IsDir() {
info, err := d.Info()
if err == nil && info.Size() > threshold {
results = append(results, ScanResult{
Path: path,
Size: info.Size(),
IsDirectory: false,
})
}
}
return nil
})
// Sort by size desc
sort.Slice(results, func(i, j int) bool {
return results[i].Size > results[j].Size
})
// Return top 50
if len(results) > 50 {
return results[:50], err
}
return results, err
}
// FindLargeFiles moved to scanner_common.go
// FindHeavyFolders uses `du` to find large directories
func FindHeavyFolders(root string) ([]ScanResult, error) {
@ -384,10 +319,7 @@ func GetCategorySizes() (*CategorySizes, error) {
return sizes, nil
}
type CleaningEstimates struct {
FlashEst int64 `json:"flash_est"`
DeepEst int64 `json:"deep_est"`
}
// CleaningEstimates struct moved to scanner_common.go
func GetCleaningEstimates() (*CleaningEstimates, error) {
home, err := os.UserHomeDir()

View file

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

View file

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

View file

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

View file

@ -8,9 +8,11 @@ import (
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/kv/clearnup/backend/internal/apps"
"github.com/kv/clearnup/backend/internal/cleaner"
"github.com/kv/clearnup/backend/internal/platform"
"github.com/kv/clearnup/backend/internal/scanner"
)
@ -34,6 +36,8 @@ func main() {
http.HandleFunc("/api/empty-trash", handleEmptyTrash)
http.HandleFunc("/api/clear-cache", handleClearCache)
http.HandleFunc("/api/clean-docker", handleCleanDocker)
http.HandleFunc("/api/clean-xcode", handleCleanXcode)
http.HandleFunc("/api/clean-homebrew", handleCleanHomebrew)
http.HandleFunc("/api/system-info", handleSystemInfo)
http.HandleFunc("/api/estimates", handleCleaningEstimates)
@ -41,8 +45,18 @@ func main() {
http.HandleFunc("/api/apps", handleScanApps)
http.HandleFunc("/api/apps/details", handleAppDetails)
http.HandleFunc("/api/apps/action", handleAppAction)
http.HandleFunc("/api/apps/uninstall", handleAppUninstall)
// Static File Serving is handled directly by Electron.
// Backend only needs to provide API routes.
fmt.Printf("🚀 Antigravity Backend running on http://localhost%s\n", Port)
// Open Browser if not in development mode
if os.Getenv("APP_ENV") != "development" {
go platform.OpenBrowser("http://localhost" + Port)
}
if err := http.ListenAndServe(Port, nil); err != nil {
fmt.Printf("Server failed: %s\n", err)
}
@ -64,38 +78,17 @@ func handleScanCategory(w http.ResponseWriter, r *http.Request) {
return
}
home, _ := os.UserHomeDir()
var targets []string
switch req.Category {
case "apps":
targets = []string{"/Applications", filepath.Join(home, "Applications")}
case "photos":
targets = []string{filepath.Join(home, "Pictures")}
case "icloud":
targets = []string{filepath.Join(home, "Library", "Mobile Documents")}
case "docs":
targets = []string{filepath.Join(home, "Documents")}
case "downloads":
targets = []string{filepath.Join(home, "Downloads")}
case "desktop":
targets = []string{filepath.Join(home, "Desktop")}
case "music":
targets = []string{filepath.Join(home, "Music")}
case "movies":
targets = []string{filepath.Join(home, "Movies")}
case "system":
targets = []string{filepath.Join(home, "Library", "Caches"), filepath.Join(home, "Library", "Logs"), filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")}
default:
targets := scanner.GetScanTargets(req.Category)
if len(targets) == 0 {
json.NewEncoder(w).Encode([]scanner.ScanResult{})
return
}
// Reuse ScanPath logic inline or call a helper
// We'll just do a quick loop here since ScanPath in scanner.go was defined but I need to link it
// Actually I put ScanPath in scanner.go as FindLargeFiles wrapper.
var allResults []scanner.ScanResult
for _, t := range targets {
if t == "" {
continue
}
res, _ := scanner.FindLargeFiles(t, 10*1024*1024) // 10MB
allResults = append(allResults, res...)
}
@ -117,9 +110,9 @@ func handleOpenSettings(w http.ResponseWriter, r *http.Request) {
return
}
// Open Storage Settings
// macOS Ventura+: open x-apple.systempreferences:com.apple.settings.Storage
exec.Command("open", "x-apple.systempreferences:com.apple.settings.Storage").Run()
if err := platform.OpenSettings(); err != nil {
fmt.Printf("Failed to open settings: %v\n", err)
}
w.WriteHeader(http.StatusOK)
}
@ -134,6 +127,7 @@ func handleDiskUsage(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(usage)
}
@ -230,26 +224,11 @@ func handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
return
}
home, err := os.UserHomeDir()
if err != nil {
http.Error(w, "Cannot get home directory", http.StatusInternalServerError)
if err := platform.EmptyTrash(); err != nil {
http.Error(w, fmt.Sprintf("Cannot empty trash: %v", err), http.StatusInternalServerError)
return
}
trashPath := filepath.Join(home, ".Trash")
// Get all items in trash and delete them
entries, err := os.ReadDir(trashPath)
if err != nil {
http.Error(w, "Cannot read trash", http.StatusInternalServerError)
return
}
for _, entry := range entries {
itemPath := filepath.Join(trashPath, entry.Name())
os.RemoveAll(itemPath)
}
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
@ -259,13 +238,16 @@ func handleClearCache(w http.ResponseWriter, r *http.Request) {
return
}
home, _ := os.UserHomeDir()
cachePath := filepath.Join(home, "Library", "Caches")
cachePath, err := platform.GetCachePath()
if err != nil {
http.Error(w, "Cannot get cache path", http.StatusInternalServerError)
return
}
// Get size before clearing
sizeBefore := scanner.GetDirectorySize(cachePath)
// Clear cache directories (keep the Caches folder itself)
// Clear cache directories (keep the Caches folder itself if possible, or jus remove content)
entries, err := os.ReadDir(cachePath)
if err != nil {
http.Error(w, "Cannot read cache directory", http.StatusInternalServerError)
@ -286,39 +268,32 @@ func handleCleanDocker(w http.ResponseWriter, r *http.Request) {
return
}
// Try to find docker executable
dockerPath, err := exec.LookPath("docker")
dockerPath, err := platform.GetDockerPath()
if err != nil {
// Try common locations
commonPaths := []string{
"/usr/local/bin/docker",
"/opt/homebrew/bin/docker",
"/Applications/Docker.app/Contents/Resources/bin/docker",
}
for _, p := range commonPaths {
if _, e := os.Stat(p); e == nil {
dockerPath = p
break
}
}
}
if dockerPath == "" {
json.NewEncoder(w).Encode(map[string]interface{}{
"cleared": 0,
"message": "Docker not found in PATH or common locations",
"message": "Docker not found",
})
return
}
// Run docker system prune -af
cmd := exec.Command(dockerPath, "system", "prune", "-af")
// Run docker system prune -af --volumes to clean images, containers, and volumes
cmd := exec.Command(dockerPath, "system", "prune", "-af", "--volumes")
output, err := cmd.CombinedOutput()
if err != nil {
message := string(output)
if message == "" || len(message) > 500 { // fallback if output is empty mapping or huge
message = err.Error()
}
// If the daemon isn't running, provide a helpful message
if strings.Contains(message, "connect: no such file or directory") || strings.Contains(message, "Is the docker daemon running") {
message = "Docker daemon is not running. Please start Docker to clean it."
}
json.NewEncoder(w).Encode(map[string]interface{}{
"cleared": 0,
"message": fmt.Sprintf("Docker cleanup failed: %s", err),
"message": message,
})
return
}
@ -329,58 +304,74 @@ func handleCleanDocker(w http.ResponseWriter, r *http.Request) {
})
}
func handleCleanXcode(w http.ResponseWriter, r *http.Request) {
enableCors(&w)
if r.Method == "OPTIONS" {
return
}
home, err := os.UserHomeDir()
if err != nil {
json.NewEncoder(w).Encode(map[string]interface{}{"cleared": 0, "message": "Could not find home directory"})
return
}
paths := []string{
filepath.Join(home, "Library/Developer/Xcode/DerivedData"),
filepath.Join(home, "Library/Developer/Xcode/iOS DeviceSupport"),
filepath.Join(home, "Library/Developer/Xcode/Archives"),
filepath.Join(home, "Library/Caches/com.apple.dt.Xcode"),
}
totalCleared := int64(0)
for _, p := range paths {
if stat, err := os.Stat(p); err == nil && stat.IsDir() {
size := scanner.GetDirectorySize(p)
if err := os.RemoveAll(p); err == nil {
totalCleared += size
}
}
}
json.NewEncoder(w).Encode(map[string]interface{}{"cleared": totalCleared, "message": "Xcode Caches Cleared"})
}
func handleCleanHomebrew(w http.ResponseWriter, r *http.Request) {
enableCors(&w)
if r.Method == "OPTIONS" {
return
}
cmd := exec.Command("brew", "cleanup", "--prune=all")
output, err := cmd.CombinedOutput()
if err != nil {
json.NewEncoder(w).Encode(map[string]interface{}{
"cleared": 0,
"message": fmt.Sprintf("Brew cleanup failed: %s", string(output)),
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"cleared": 1,
"message": "Homebrew Cache Cleared",
})
}
func handleSystemInfo(w http.ResponseWriter, r *http.Request) {
enableCors(&w)
if r.Method == "OPTIONS" {
return
}
// Structs for parsing system_profiler JSON
type HardwareItem struct {
MachineName string `json:"machine_name"`
ChipType string `json:"chip_type"`
PhysicalMemory string `json:"physical_memory"`
}
type SoftwareItem struct {
OSVersion string `json:"os_version"`
}
type SystemProfile struct {
Hardware []HardwareItem `json:"SPHardwareDataType"`
Software []SoftwareItem `json:"SPSoftwareDataType"`
}
cmd := exec.Command("system_profiler", "SPHardwareDataType", "SPSoftwareDataType", "-json")
output, err := cmd.Output()
info, err := platform.GetSystemInfo()
if err != nil {
http.Error(w, "Failed to get system info", http.StatusInternalServerError)
return
}
var profile SystemProfile
if err := json.Unmarshal(output, &profile); err != nil {
http.Error(w, "Failed to parse system info", http.StatusInternalServerError)
return
}
response := map[string]string{
"model": "Unknown",
"chip": "Unknown",
"memory": "Unknown",
"os": "Unknown",
}
if len(profile.Hardware) > 0 {
response["model"] = profile.Hardware[0].MachineName
response["chip"] = profile.Hardware[0].ChipType
response["memory"] = profile.Hardware[0].PhysicalMemory
}
if len(profile.Software) > 0 {
response["os"] = profile.Software[0].OSVersion
}
json.NewEncoder(w).Encode(response)
json.NewEncoder(w).Encode(info)
}
func handleCleaningEstimates(w http.ResponseWriter, r *http.Request) {
@ -413,23 +404,23 @@ func handleScanApps(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(appsList)
}
type AppDetailsRequest struct {
Path string `json:"path"`
}
func handleAppDetails(w http.ResponseWriter, r *http.Request) {
enableCors(&w)
if r.Method == "OPTIONS" {
return
}
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)
details, err := apps.GetAppDetails(req.Path, req.BundleID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -437,17 +428,15 @@ func handleAppDetails(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(details)
}
type AppActionRequest struct {
Files []string `json:"files"`
}
func handleAppAction(w http.ResponseWriter, r *http.Request) {
enableCors(&w)
if r.Method == "OPTIONS" {
return
}
var req AppActionRequest
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
@ -461,3 +450,26 @@ func handleAppAction(w http.ResponseWriter, r *http.Request) {
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})
}

BIN
backend/verify_output.txt Normal file

Binary file not shown.

View file

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

62
build-release.ps1 Normal file
View file

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

View file

@ -24,7 +24,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
// electron/main.ts
var import_electron = require("electron");
var import_fs = __toESM(require("fs"), 1);
var import_path3 = __toESM(require("path"), 1);
var import_child_process3 = require("child_process");
@ -293,7 +292,8 @@ var startBackend = () => {
console.log("Development mode: Backend should be running via start-go.sh");
return;
}
const backendPath = import_path3.default.join(process.resourcesPath, "backend");
const backendExec = process.platform === "win32" ? "backend.exe" : "backend";
const backendPath = import_path3.default.join(process.resourcesPath, backendExec);
console.log("Starting backend from:", backendPath);
try {
backendProcess = (0, import_child_process3.spawn)(backendPath, [], {
@ -312,11 +312,12 @@ var startBackend = () => {
function createTray() {
const iconPath = import_path3.default.join(__dirname, "../dist/tray/tray-iconTemplate.png");
let finalIconPath = iconPath;
if (!import_fs.default.existsSync(iconPath)) {
if (process.env.NODE_ENV === "development") {
finalIconPath = import_path3.default.join(__dirname, "../public/tray/tray-iconTemplate.png");
}
const image = import_electron.nativeImage.createFromPath(finalIconPath);
tray = new import_electron.Tray(image.resize({ width: 16, height: 16 }));
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...");
}

0
electron/features/cleaner.ts Executable file → Normal file
View file

0
electron/features/enforcer.ts Executable file → Normal file
View file

0
electron/features/scanner.ts Executable file → Normal file
View file

0
electron/features/updater.ts Executable file → Normal file
View file

11
electron/main.ts Executable file → Normal file
View file

@ -22,7 +22,8 @@ const startBackend = () => {
return;
}
const backendPath = path.join(process.resourcesPath, 'backend');
const backendExec = process.platform === 'win32' ? 'backend.exe' : 'backend';
const backendPath = path.join(process.resourcesPath, backendExec);
console.log('Starting backend from:', backendPath);
try {
@ -49,12 +50,14 @@ function createTray() {
// Check if dist/tray exists, if not try public/tray (dev mode)
let finalIconPath = iconPath;
if (!fs.existsSync(iconPath)) {
if (process.env.NODE_ENV === 'development') {
finalIconPath = path.join(__dirname, '../public/tray/tray-iconTemplate.png');
}
const image = nativeImage.createFromPath(finalIconPath);
tray = new Tray(image.resize({ width: 16, height: 16 }));
let image = nativeImage.createFromPath(finalIconPath);
image.setTemplateImage(true);
tray = new Tray(image.resize({ width: 18, height: 18 }));
tray.setToolTip('Antigravity Cleaner');
updateTrayMenu('Initializing...');

0
electron/preload.ts Executable file → Normal file
View file

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

0
electron/tsconfig.json Executable file → Normal file
View file

0
eslint.config.js Executable file → Normal file
View file

2
go.mod
View file

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

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

0
index.html Executable file → Normal file
View file

30
package.json Executable file → Normal file
View file

@ -1,5 +1,5 @@
{
"name": "Lumina",
"name": "lumina",
"private": true,
"version": "1.0.0",
"type": "module",
@ -10,7 +10,9 @@
"electron:build": "node scripts/build-electron.mjs",
"build": "tsc -b && vite build",
"build:go:mac": "sh scripts/build-go.sh",
"build:go:win": "GOOS=windows GOARCH=amd64 go build -ldflags=\"-s -w\" -o backend/dist/windows/backend.exe backend/main.go",
"build:mac": "pnpm run build:go:mac && pnpm run build && pnpm run electron:build && electron-builder --mac --universal",
"build:win": "pnpm run build:go:win && pnpm run build && pnpm run electron:build && electron-builder --win portable --x64",
"lint": "eslint .",
"preview": "vite preview",
"preinstall": "node scripts/check-pnpm.js"
@ -64,18 +66,30 @@
],
"icon": "build/icon.png",
"category": "public.app-category.utilities",
"hardenedRuntime": false
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"package.json"
],
"hardenedRuntime": false,
"extraResources": [
{
"from": "backend/dist/universal/backend",
"to": "backend"
}
]
},
"win": {
"target": [
"portable"
],
"icon": "build/icon.png",
"extraResources": [
{
"from": "backend/dist/windows/backend.exe",
"to": "backend.exe"
}
]
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"package.json"
]
}
}

0
postcss.config.js Executable file → Normal file
View file

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

0
public/vite.svg Executable file → Normal file
View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

0
scripts/check-pnpm.js Executable file → Normal file
View file

0
src/App.css Executable file → Normal file
View file

0
src/App.tsx Executable file → Normal file
View file

View file

@ -7,6 +7,7 @@ export interface ScanResult {
}
export interface DiskUsage {
name: string;
totalGB: string;
usedGB: string;
freeGB: string;
@ -23,6 +24,12 @@ export interface CategorySizes {
apps: number;
photos: number;
icloud: number;
archives?: number;
virtual_machines?: number;
games?: number;
ai?: number;
docker?: number;
cache?: number;
}
export interface SystemInfo {
@ -42,13 +49,14 @@ export interface AppInfo {
name: string;
path: string;
bundleID: string;
uninstallString?: string;
size: number;
icon?: string;
}
export interface AssociatedFile {
path: string;
type: 'cache' | 'config' | 'log' | 'data';
type: 'cache' | 'config' | 'log' | 'data' | 'registry';
size: number;
}
@ -58,7 +66,7 @@ export interface AppDetails extends AppInfo {
}
export const API = {
getDiskUsage: async (): Promise<DiskUsage | null> => {
getDiskUsage: async (): Promise<DiskUsage[] | null> => {
try {
const res = await fetch(`${API_BASE}/disk-usage`);
if (!res.ok) throw new Error("Failed to fetch disk usage");
@ -83,7 +91,7 @@ export const API = {
getCategorySizes: async (): Promise<CategorySizes> => {
try {
const res = await fetch(`${API_BASE}/scan/sizes`);
const res = await fetch(`${API_BASE}/scan/sizes?t=${Date.now()}`);
return await res.json();
} catch {
return { documents: 0, downloads: 0, desktop: 0, music: 0, movies: 0, system: 0, trash: 0, apps: 0, photos: 0, icloud: 0 };
@ -139,6 +147,26 @@ export const API = {
}
},
cleanXcode: async (): Promise<{ cleared: number; message: string }> => {
try {
const res = await fetch(`${API_BASE}/clean-xcode`, { method: "POST" });
return await res.json();
} catch (e) {
console.error(e);
return { cleared: 0, message: "Xcode cleanup failed" };
}
},
cleanHomebrew: async (): Promise<{ cleared: number; message: string }> => {
try {
const res = await fetch(`${API_BASE}/clean-homebrew`, { method: "POST" });
return await res.json();
} catch (e) {
console.error(e);
return { cleared: 0, message: "Homebrew cleanup failed" };
}
},
getSystemInfo: async (): Promise<SystemInfo | null> => {
try {
const res = await fetch(`${API_BASE}/system-info`);
@ -165,10 +193,10 @@ export const API = {
return res.json();
},
getAppDetails: async (path: string): Promise<AppDetails> => {
getAppDetails: async (path: string, bundleID?: string): Promise<AppDetails> => {
const res = await fetch(`${API_BASE}/apps/details`, {
method: 'POST',
body: JSON.stringify({ path }),
body: JSON.stringify({ path, bundleID }),
});
return res.json();
},
@ -181,6 +209,14 @@ export const API = {
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) {

0
src/assets/react.svg Executable file → Normal file
View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

440
src/components/Dashboard.tsx Executable file → Normal file
View file

@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { API } from '../api/client';
import { useToast } from './ui';
@ -6,7 +7,7 @@ import type { DiskUsage, ScanResult, SystemInfo } from '../api/client';
export function Dashboard() {
const [scanResults, setScanResults] = useState<ScanResult[]>([]);
const [scanTitle, setScanTitle] = useState("");
const [storageData, setStorageData] = useState<DiskUsage>({ usedGB: '0', totalGB: '0', freeGB: '0' });
const [diskUsage, setDiskUsage] = useState<DiskUsage[]>([]);
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [activeScanType, setActiveScanType] = useState<string>('');
@ -25,7 +26,13 @@ export function Dashboard() {
apps: -1,
photos: -1,
icloud: -1,
trash: -1
trash: -1,
archives: -1,
virtual_machines: -1,
games: -1,
ai: -1,
docker: -1,
cache: -1
});
const isCategoriesScanned = categorySizes.documents !== -1;
@ -36,20 +43,20 @@ export function Dashboard() {
const refreshDiskUsage = async () => {
const usage = await API.getDiskUsage();
if (usage) {
setStorageData(usage);
// Update Tray Title
setDiskUsage(usage);
// Update Tray Title with
if (usage && usage.length > 0) {
const mainDisk = usage[0];
if (window.electronAPI && window.electronAPI.updateTrayTitle) {
window.electronAPI.updateTrayTitle(`${usage.freeGB} GB Free`)
.catch(e => console.error("Tray error", e));
window.electronAPI.updateTrayTitle(`${mainDisk.freeGB} GB`)
.catch(e => console.error("Tray error:", e));
}
}
// Update Tray Icon with Graph - REMOVED
}
};
// Dynamic Tray Icon removed per user request
const scanCategories = async () => {
setIsScanning(true);
try {
@ -65,7 +72,13 @@ export function Dashboard() {
trash: sizes.trash,
apps: sizes.apps,
photos: sizes.photos,
icloud: sizes.icloud
icloud: sizes.icloud,
archives: sizes.archives || 0,
virtual_machines: sizes.virtual_machines || 0,
games: sizes.games || 0,
ai: sizes.ai || 0,
docker: sizes.docker || 0,
cache: sizes.cache || 0
});
}
} catch (e) {
@ -79,22 +92,33 @@ export function Dashboard() {
useEffect(() => {
refreshDiskUsage();
API.getSystemInfo().then(setSystemInfo);
API.getCategorySizes().then(setCategorySizes);
API.getCategorySizes().then((sizes) => {
if (sizes) {
setCategorySizes({
documents: sizes.documents,
downloads: sizes.downloads,
desktop: sizes.desktop,
music: sizes.music,
movies: sizes.movies,
system: sizes.system,
trash: sizes.trash,
apps: sizes.apps,
photos: sizes.photos,
icloud: sizes.icloud,
archives: sizes.archives || 0,
virtual_machines: sizes.virtual_machines || 0,
games: sizes.games || 0,
ai: sizes.ai || 0,
docker: sizes.docker || 0,
cache: sizes.cache || 0
});
}
});
// Auto-refresh disk usage every 30 seconds
const interval = setInterval(refreshDiskUsage, 30000);
return () => clearInterval(interval);
}, []);
// Ensure Tray is updated whenever storageData changes
useEffect(() => {
if (storageData.totalGB !== '0') {
if (window.electronAPI?.updateTrayTitle) {
window.electronAPI.updateTrayTitle(`${storageData.freeGB} GB Free`);
}
// Icon update removed
}
}, [storageData]);
// Helper to run a scan and show results
const runScan = async (type: string, title: string) => {
setIsScanning(true);
@ -170,11 +194,6 @@ export function Dashboard() {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// Calculate bar width percentages
const total = parseFloat(storageData.totalGB) || 1;
const used = parseFloat(storageData.usedGB) || 0;
const usedPercent = (used / total) * 100;
return (
<div className="h-full flex flex-col p-4 md:p-8 max-w-7xl mx-auto w-full animate-fade-in overflow-hidden">
<div className="flex flex-col lg:flex-row gap-6 h-full min-h-0">
@ -185,30 +204,64 @@ export function Dashboard() {
<div className="flex items-center gap-4">
<h1 className="text-xl md:text-[28px] font-semibold text-black tracking-tight">Storage</h1>
{systemInfo && (
<div className="hidden md:flex items-center gap-3 px-3 py-1.5 liquid-glass rounded-[var(--radius-lg)] animate-fade-in border border-white/20 shadow-sm">
<div className="hidden md:flex items-center gap-2 px-2.5 py-1 liquid-glass rounded-[var(--radius-lg)] animate-fade-in border border-white/20 shadow-sm">
<span className="text-[11px] font-medium text-gray-600">{systemInfo.model}</span>
<span className="w-px h-3 bg-black/10"></span>
<div className="flex items-center gap-1.5">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-500">
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18" />
<line x1="7" y1="2" x2="7" y2="22" />
<line x1="17" y1="2" x2="17" y2="22" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="2" y1="7" x2="7" y2="7" />
<line x1="2" y1="17" x2="7" y2="17" />
<line x1="17" y1="17" x2="22" y2="17" />
<line x1="17" y1="7" x2="22" y2="7" />
{/* Responsive Info Items */}
{/* Chip - Hide on mobile, show on md */}
{systemInfo.chip && systemInfo.chip !== 'Unknown' && (
<>
<span className="w-px h-3 bg-black/10 hidden md:block"></span>
<div className="hidden md:flex items-center gap-1.5 min-w-0" title={systemInfo.chip}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-500 shrink-0">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2" />
<rect x="9" y="9" width="6" height="6" />
<line x1="9" y1="1" x2="9" y2="4" />
<line x1="15" y1="1" x2="15" y2="4" />
<line x1="9" y1="20" x2="9" y2="23" />
<line x1="15" y1="20" x2="15" y2="23" />
<line x1="20" y1="9" x2="23" y2="9" />
<line x1="20" y1="14" x2="23" y2="14" />
<line x1="1" y1="9" x2="4" y2="9" />
<line x1="1" y1="14" x2="4" y2="14" />
</svg>
<span className="text-[11px] font-medium text-gray-600">{systemInfo.chip}</span>
<span className="text-[11px] font-medium text-gray-600 truncate max-w-[120px]">
{systemInfo.chip
.replace('12th Gen ', '')
.replace('13th Gen ', '')
.replace('14th Gen ', '')
.replace('Intel(R) Core(TM) ', '')
.replace('Intel(R) ', '')
.replace('Core(TM) ', '')
.replace('Processor', '')
.trim()}
</span>
</div>
</>
)}
{/* Memory - Hide on mobile */}
{systemInfo.memory && systemInfo.memory !== 'Unknown' && (
<>
<span className="w-px h-3 bg-black/10 hidden sm:block"></span>
<span className="text-[11px] font-medium text-gray-600 hidden sm:block whitespace-nowrap">{systemInfo.memory}</span>
</>
)}
{/* OS - Always Show */}
<span className="w-px h-3 bg-black/10"></span>
<span className="text-[11px] font-medium text-gray-600">{systemInfo.memory}</span>
<span className="w-px h-3 bg-black/10"></span>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 shrink-0">
{systemInfo.os.includes('Windows') ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="text-gray-900">
<path d="M0 3.449L9.75 2.1v9.451H0v-8.102zm10.949-1.551L24 0v11.023h-12.296v-9.125h-.755zm-10.949 11.024H9.75v8.625L0 20.551v-7.628h.755zm11.704 0H24v9.578l-12.296-1.747v-7.831z" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="text-gray-900">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.3-3.14-2.53C4.25 17 2.91 12.45 4.7 9.35c.89-1.54 2.49-2.51 4.2-2.54 1.35-.03 2.61.91 3.42.91.81 0 2.38-1.12 4-1.09 1.4.03 2.64.92 3.65 1.5-3.05 1.6-2.52 6.01.6 7.37zM15.56 5.36c.72-.87 1.21-2.08 1.07-3.32-1.04.04-2.3.69-3.04 1.56-.66.79-1.24 2.05-1.08 3.25 1.16.09 2.34-.64 3.05-1.49z" />
</svg>
<span className="text-[11px] font-medium text-gray-900">{systemInfo.os.replace('macOS ', '')}</span>
)}
<span className="text-[11px] font-medium text-gray-900 whitespace-nowrap">
{systemInfo.os.replace('macOS ', '').replace('Microsoft Windows', 'Win').replace('Windows', 'Win')}
</span>
</div>
</div>
)}
@ -216,13 +269,13 @@ export function Dashboard() {
</div>
{/* Flash & Deep Clean Hero - Side by Side */}
<div className="grid grid-cols-2 gap-3 shrink-0">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 shrink-0">
{/* Flash Clean Card */}
<button
onClick={async () => {
const confirmed = await toast.confirm(
'Start Flash Clean?',
'This will safely remove system caches, logs, trash, and Docker data.'
'This will safely remove system caches, logs, trash, Xcode caches, Homebrew cache, and Docker data.'
);
if (confirmed) {
setIsScanning(true);
@ -234,23 +287,35 @@ export function Dashboard() {
const cacheRes = await API.clearCache();
await API.emptyTrash();
const dockerRes = await API.cleanDocker();
const xcodeRes = await API.cleanXcode();
const brewRes = await API.cleanHomebrew();
refreshDiskUsage();
scanCategories();
// Calculate total and formatted details
const totalFreed = cacheRes.cleared + trashSize + (dockerRes.cleared || 0);
const totalFreed = cacheRes.cleared + trashSize + (dockerRes.cleared || 0) + (xcodeRes.cleared || 0) + (brewRes.cleared || 0);
const details = [];
if (cacheRes.cleared > 0) details.push('Cache');
if (trashSize > 0) details.push('Trash');
if (dockerRes.cleared > 0) details.push('Docker');
if (xcodeRes.cleared > 0) details.push('Xcode');
if (brewRes.cleared > 0) details.push('Homebrew');
const detailStr = details.length > 0 ? ` (${details.join(', ')})` : '';
// Include the docker failure message if there was one
let finalMessage = `Freed ${formatBytes(totalFreed)}${detailStr}`;
if (dockerRes.message && dockerRes.cleared === 0 && details.length === 0 && totalFreed === 0) {
finalMessage = dockerRes.message;
} else if (dockerRes.message && dockerRes.cleared === 0) {
finalMessage += `\nNote: ${dockerRes.message}`;
}
toast.addToast({
type: 'success',
type: dockerRes.message && totalFreed === 0 ? 'error' : 'success',
title: 'Flash Clean Complete',
message: `Freed ${formatBytes(totalFreed)}${detailStr}`
message: finalMessage
});
} catch (e) {
console.error(e);
@ -301,47 +366,87 @@ export function Dashboard() {
</div>
{/* Storage Bar Section - Glass Container */}
<div className="liquid-glass rounded-[var(--radius-lg)] p-5 md:p-6 shrink-0">
{/* Storage Bars Section - Render for each drive */}
<div className="flex flex-col gap-4 min-w-0">
{diskUsage.map((drive, index) => {
const total = parseFloat(drive.totalGB);
const used = parseFloat(drive.usedGB);
const usedPercent = total > 0 ? (used / total) * 100 : 0;
const isSystemDrive = drive.name.includes("(C:)") || drive.name === "Macintosh HD";
// Calculate segments if system drive and categories are scanned (not -1)
const isCategoriesReady = categorySizes.documents !== -1;
let segments: { label: string; size: number; color: string; legendColor: string }[] = [];
if (isSystemDrive && isCategoriesReady) {
const toGB = (bytes: number) => bytes / (1024 * 1024 * 1024);
const appsGB = toGB(categorySizes.apps);
const docsGB = toGB(categorySizes.documents + categorySizes.downloads + categorySizes.desktop);
const mediaGB = toGB(categorySizes.music + categorySizes.movies + categorySizes.photos);
const trashGB = toGB(categorySizes.trash);
// System/Other is the remainder of Used - Known Categories
const knownUsed = appsGB + docsGB + mediaGB + trashGB;
const otherGB = Math.max(0, used - knownUsed);
segments = [
{ label: 'Apps', size: appsGB, color: 'from-[#FF5147] to-[#FF3B30]', legendColor: 'bg-[#FF3B30]' },
{ label: 'Documents', size: docsGB, color: 'from-[#FFD60A] to-[#FFCC00]', legendColor: 'bg-[#FFCC00]' },
{ label: 'Media', size: mediaGB, color: 'from-[#BF5AF2] to-[#AF52DE]', legendColor: 'bg-[#AF52DE]' },
{ label: 'Trash', size: trashGB, color: 'from-[#8E8E93] to-[#636366]', legendColor: 'bg-[#8E8E93]' },
{ label: 'System', size: otherGB, color: 'from-[#C7C7CC] to-[#AEAEB2]', legendColor: 'bg-[#C7C7CC]' },
];
}
return (
<div key={index} className="liquid-glass rounded-[var(--radius-lg)] p-5 md:p-6 shrink-0">
<div className="flex justify-between items-end mb-3">
<h2 className="text-[14px] font-semibold text-gray-900">Macintosh HD</h2>
<h2 className="text-[14px] font-semibold text-gray-900">
{drive.name}
</h2>
<span className="text-[12px] md:text-[13px] text-gray-500 font-medium tabular-nums">
{storageData.usedGB} GB of {storageData.totalGB} GB used
{drive.usedGB} GB of {drive.totalGB} GB used
</span>
</div>
{/* The Bar - Enhanced with inner glow */}
<div className="h-[28px] w-full bg-gradient-to-b from-black/5 to-black/10 rounded-[var(--radius-sm)] overflow-hidden flex shadow-inner">
{/* Applications Segment */}
{/* The Bar */}
<div className="h-[28px] w-full bg-gradient-to-b from-black/5 to-black/10 rounded-[var(--radius-sm)] overflow-hidden flex shadow-inner relative">
{isSystemDrive && segments.length > 0 ? (
segments.map((seg, i) => {
const width = total > 0 ? (seg.size / total) * 100 : 0;
if (width < 0.5) return null; // Hide tiny segments
return (
<div
className="h-full bg-gradient-to-b from-[#FF5147] to-[#FF3B30] first:rounded-l-[var(--radius-sm)]"
style={{ width: `${Math.max(5, usedPercent * 0.4)}%` }}
key={i}
className={`h-full bg-gradient-to-b ${seg.color} border-r border-white/20 last:border-0`}
style={{ width: `${width}%` }}
title={`${seg.label}: ${seg.size.toFixed(2)} GB`}
/>
{/* Photos Segment */}
);
})
) : (
// Generic Bar for non-system drives
<div
className="h-full bg-gradient-to-b from-[#FFB340] to-[#FF9500]"
style={{ width: `${Math.max(5, usedPercent * 0.2)}%` }}
/>
{/* System Data Segment */}
<div
className="h-full bg-gradient-to-b from-[#A0A0A5] to-[#8E8E93]"
style={{ width: `${Math.max(5, usedPercent * 0.3)}%` }}
/>
{/* macOS Segment */}
<div
className="h-full bg-gradient-to-b from-[#D4D4D9] to-[#C7C7CC] last:rounded-r-[var(--radius-sm)]"
style={{ width: `${Math.max(5, usedPercent * 0.1)}%` }}
className="h-full bg-gradient-to-b from-[#007AFF] to-[#0051A8]"
style={{ width: `${Math.max(0, usedPercent)}%` }}
/>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-5 gap-y-2 mt-4 text-[11px] font-medium text-gray-600">
<LegendItem color="bg-[#FF3B30]" label="Applications" />
<LegendItem color="bg-[#FFCC00]" label="Documents" />
<LegendItem color="bg-[#FF9500]" label="Photos" />
<LegendItem color="bg-[#8E8E93]" label="System Data" />
<LegendItem color="bg-[#C7C7CC]" label="macOS" />
{/* Legend for System Drive */}
{isSystemDrive && segments.length > 0 && (
<div className="flex flex-wrap gap-x-4 gap-y-2 mt-3">
{segments.map((seg, i) => seg.size > 0.1 && (
<div key={i} className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-full ${seg.legendColor} shadow-sm`} />
<span className="text-[11px] font-medium text-gray-600">{seg.label}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
@ -392,6 +497,14 @@ export function Dashboard() {
actionIcon
description="Personal documents folder"
/>
<CategoryRow
icon={<Icons.Archives />}
label="Archives"
size={categorySizes.archives !== undefined && categorySizes.archives >= 0 ? formatBytes(categorySizes.archives) : "Not Scanned"}
onClick={() => runScan('archives', 'Archives')}
actionIcon
description="Compressed files (.zip, .rar, .7z)"
/>
<CategoryRow
icon={<Icons.Downloads />}
label="Downloads"
@ -424,6 +537,46 @@ export function Dashboard() {
actionIcon
description="Movies folder & video files"
/>
<CategoryRow
icon={<Icons.VirtualMachines />}
label="Disk Images"
size={categorySizes.virtual_machines !== undefined && categorySizes.virtual_machines >= 0 ? formatBytes(categorySizes.virtual_machines) : "Not Scanned"}
onClick={() => runScan('vms', 'Disk Images')}
actionIcon
description="ISOs, VM disks, & installers"
/>
<CategoryRow
icon={<Icons.Games />}
label="Games"
size={categorySizes.games !== undefined && categorySizes.games >= 0 ? formatBytes(categorySizes.games) : "Not Scanned"}
onClick={() => runScan('games', 'Games Libraries')}
actionIcon
description="Steam, Epic, EA, Ubisoft"
/>
<CategoryRow
icon={<Icons.AI />}
label="AI Models"
size={categorySizes.ai !== undefined && categorySizes.ai >= 0 ? formatBytes(categorySizes.ai) : "Not Scanned"}
onClick={() => runScan('ai', 'AI Tools')}
actionIcon
description="ComfyUI, WebUI, Checkpoints"
/>
<CategoryRow
icon={<Icons.Docker />}
label="Docker"
size={categorySizes.docker !== undefined && categorySizes.docker >= 0 ? formatBytes(categorySizes.docker) : "Not Scanned"}
onClick={() => runScan('docker', 'Docker Data')}
actionIcon
description="Docker Desktop disk usage"
/>
<CategoryRow
icon={<Icons.Cache />}
label="System Cache"
size={categorySizes.cache !== undefined && categorySizes.cache >= 0 ? formatBytes(categorySizes.cache) : "Not Scanned"}
onClick={() => runScan('cache', 'Cache & Temp')}
actionIcon
description="Browser & System temporary files"
/>
<CategoryRow
icon={<Icons.CloudDrive />}
label="iCloud Drive"
@ -444,20 +597,8 @@ export function Dashboard() {
icon={<Icons.Trash />}
label="Trash"
size={categorySizes.trash >= 0 ? formatBytes(categorySizes.trash) : "Not Scanned"}
onClick={async () => {
if (categorySizes.trash > 0) {
const confirmed = await toast.confirm('Empty Trash?', 'This cannot be undone.');
if (confirmed) {
const success = await API.emptyTrash();
if (success) {
setCategorySizes(prev => ({ ...prev, trash: 0 }));
refreshDiskUsage();
toast.addToast({ type: 'success', title: 'Trash Emptied' });
}
}
}
}}
actionIcon={false}
onClick={() => runScan('trash', 'Trash Content')}
actionIcon
highlight={categorySizes.trash > 0}
description="Deleted files in Trash"
/>
@ -748,21 +889,12 @@ export function Dashboard() {
);
}
function LegendItem({ color, label }: { color: string, label: string }) {
return (
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${color} shadow-sm`} />
<span className="text-[11px]">{label}</span>
</div>
);
}
function CategoryRow({ icon, label, size, onClick, actionIcon, highlight, description }: any) {
return (
<div
onClick={onClick}
className={`flex items-center justify-between px-3 py-2 md:px-4 md:py-2.5 border border-black/5 dark:border-white/5 rounded-[var(--radius-md)] bg-white/40 dark:bg-white/5 hover:bg-white/60 dark:hover:bg-white/10 active:bg-white/80 dark:active:bg-white/15 group transition-all duration-200 cursor-pointer shadow-sm`}
className={`flex items-center justify-between px-3 py-2 md:px-4 md:py-2.5 rounded-[var(--radius-md)] bg-white/40 dark:bg-white/5 hover:bg-white/60 dark:hover:bg-white/10 active:bg-white/80 dark:active:bg-white/15 group transition-all duration-200 cursor-pointer shadow-sm`}
>
<div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
{/* Icon container - fixed size for alignment */}
@ -954,6 +1086,112 @@ const Icons = {
</svg>
),
Archives: () => (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<defs>
<linearGradient id="archives-bg" x1="0" y1="0" x2="44" y2="44">
<stop offset="0%" stopColor="#A29BFE" />
<stop offset="100%" stopColor="#6C5CE7" />
</linearGradient>
</defs>
<rect width="44" height="44" rx="12" fill="url(#archives-bg)" />
<g transform="translate(12, 12)">
<path d="M4 4h12v12H4z" stroke="white" strokeWidth="2" strokeLinecap="round" />
<path d="M4 8h12" stroke="white" strokeWidth="2" />
<path d="M10 12v2" stroke="white" strokeWidth="2" strokeLinecap="round" />
</g>
</svg>
),
VirtualMachines: () => (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<defs>
<linearGradient id="vm-bg" x1="0" y1="0" x2="44" y2="44">
<stop offset="0%" stopColor="#00CEC9" />
<stop offset="100%" stopColor="#00B894" />
</linearGradient>
</defs>
<rect width="44" height="44" rx="12" fill="url(#vm-bg)" />
<g transform="translate(10, 10)">
<circle cx="12" cy="12" r="9" stroke="white" strokeWidth="2" />
<circle cx="12" cy="12" r="3" fill="white" />
</g>
</svg>
),
Games: () => (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<defs>
<linearGradient id="games-bg" x1="0" y1="0" x2="44" y2="44">
<stop offset="0%" stopColor="#FF7675" />
<stop offset="100%" stopColor="#D63031" />
</linearGradient>
</defs>
<rect width="44" height="44" rx="12" fill="url(#games-bg)" />
<g transform="translate(10, 10)">
<path d="M12 2a10 10 0 0 0-10 10c0 5.5 4.5 10 10 10h.01" stroke="white" strokeWidth="2" strokeLinecap="round" strokeOpacity="0.1" />
<path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" fill="white" fillOpacity="0.2" />
<path d="M2 12c0-5.5 4.5-10 10-10s10 4.5 10 10-4.5 10-10 10S2 17.5 2 12z" stroke="white" strokeWidth="2" />
<path d="M6 12h4M8 10v4" stroke="white" strokeWidth="2" strokeLinecap="round" />
<circle cx="15" cy="10" r="1.5" fill="white" />
<circle cx="17" cy="13" r="1.5" fill="white" />
</g>
</svg>
),
AI: () => (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<defs>
<linearGradient id="ai-bg" x1="0" y1="0" x2="44" y2="44">
<stop offset="0%" stopColor="#74B9FF" />
<stop offset="100%" stopColor="#0984E3" />
</linearGradient>
</defs>
<rect width="44" height="44" rx="12" fill="url(#ai-bg)" />
<g transform="translate(10, 10)">
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeOpacity="0.5" />
<path d="M6 6l2 2M16 16l2 2M6 18l2-2M16 6l-2 2" stroke="white" strokeWidth="2" strokeLinecap="round" strokeOpacity="0.5" />
<circle cx="12" cy="12" r="6" stroke="white" strokeWidth="2" />
<circle cx="12" cy="12" r="2" fill="white" />
</g>
</svg>
),
Docker: () => (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<defs>
<linearGradient id="docker-bg" x1="0" y1="0" x2="44" y2="44">
<stop offset="0%" stopColor="#00BFA5" />
<stop offset="100%" stopColor="#00695C" />
</linearGradient>
</defs>
<rect width="44" height="44" rx="12" fill="url(#docker-bg)" />
<g transform="translate(10, 10)">
<path d="M22 10h-6v3h6v-3zM22 6h-6v3h6V6zM15 10H9v3h6v-3zM15 6H9v3h6V6z" fill="white" fillOpacity="0.8" />
<path d="M2 14c0 0 2-2 7-2s8 3 13 3 2-2 2-2v6H2v-5z" fill="white" />
<circle cx="5" cy="8" r="1" fill="white" />
</g>
</svg>
),
Cache: () => (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<defs>
<linearGradient id="cache-bg" x1="0" y1="0" x2="44" y2="44">
<stop offset="0%" stopColor="#FDCCB4" />
<stop offset="100%" stopColor="#E17055" />
</linearGradient>
</defs>
<rect width="44" height="44" rx="12" fill="url(#cache-bg)" />
<g transform="translate(11, 11)">
<path d="M16 2l4 4-10 10c-1 1-3 1-4 0l-4-4c-1-1-1-3 0-4L12 2z" fill="white" fillOpacity="0.2" />
<path d="M7 11l4 4" stroke="white" strokeWidth="2" strokeLinecap="round" />
<path d="M2 20s2-3 5-3 3 5 3 5" stroke="white" strokeWidth="2" strokeLinecap="round" />
<path d="M2 20l5-5" stroke="white" strokeWidth="2" strokeLinecap="round" />
</g>
</svg>
),
Movies: () => (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<defs>

0
src/components/Layout.tsx Executable file → Normal file
View file

0
src/components/Settings.tsx Executable file → Normal file
View file

View file

@ -27,7 +27,7 @@ export function AppDetailsView({ app, onBack, onUninstall }: Props) {
const loadDetails = async () => {
try {
setLoading(true);
const data = await API.getAppDetails(app.path);
const data = await API.getAppDetails(app.path, app.bundleID);
setDetails(data);
// Select all by default
@ -69,6 +69,7 @@ export function AppDetailsView({ app, onBack, onUninstall }: Props) {
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 'log': return <FileText className="w-4 h-4 text-blue-400" />;
case 'registry': return <Settings className="w-4 h-4 text-purple-400" />;
default: return <Folder className="w-4 h-4 text-gray-400" />;
}
};
@ -76,6 +77,30 @@ export function AppDetailsView({ app, onBack, onUninstall }: Props) {
const handleAction = async (action: 'uninstall' | 'reset' | 'cache') => {
if (!details) return;
// Special handling for Uninstall with official uninstaller
if (action === 'uninstall' && details.uninstallString) {
const confirmed = await toast.confirm(
`Run Uninstaller?`,
`This will launch the official uninstaller for ${details.name}.`
);
if (!confirmed) return;
try {
setProcessing(true);
await API.uninstallApp(details.uninstallString);
toast.addToast({ type: 'success', title: 'Success', message: 'Uninstaller launched' });
// We don't automatically close the view or reload apps because the uninstaller is external
// But we can go back
onBack();
} catch (error) {
console.error(error);
toast.addToast({ type: 'error', title: 'Error', message: 'Failed to launch uninstaller' });
} finally {
setProcessing(false);
}
return;
}
let filesToDelete: string[] = [];
if (action === 'uninstall') {

View file

@ -134,7 +134,7 @@ export function AppsView() {
<AnimatePresence>
{sortedApps.map((app) => (
<motion.div
key={app.path}
key={app.bundleID}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
@ -172,7 +172,7 @@ export function AppsView() {
<div className="flex flex-col gap-2 pb-10">
{sortedApps.map((app) => (
<motion.div
key={app.path}
key={app.bundleID}
initial={{ opacity: 0, y: 10 }}
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"

0
src/index.css Executable file → Normal file
View file

0
src/main.tsx Executable file → Normal file
View file

0
src/vite-env.d.ts vendored Executable file → Normal file
View file

55
start-dev.ps1 Normal file
View file

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

0
start-dev.sh Executable file → Normal file
View file

2
start-go.sh Executable file → Normal file
View file

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

0
tailwind.config.js Executable file → Normal file
View file

BIN
tracked_files.txt Normal file

Binary file not shown.

0
tsconfig.app.json Executable file → Normal file
View file

0
tsconfig.json Executable file → Normal file
View file

0
tsconfig.node.json Executable file → Normal file
View file

0
vite.config.ts Executable file → Normal file
View file