feat: Initial commit version 1
This commit is contained in:
commit
301e638900
177 changed files with 19248 additions and 0 deletions
24
.gitignore
vendored
Executable file
24
.gitignore
vendored
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
2
.npmrc
Executable file
2
.npmrc
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
node-linker=hoisted
|
||||
package-import-method=clone-or-copy
|
||||
68
README.md
Executable file
68
README.md
Executable file
|
|
@ -0,0 +1,68 @@
|
|||
# KV Clearnup (Antigravity) 🚀
|
||||
|
||||
A modern, high-performance system optimizer for macOS, built with **Electron**, **React**, and **Go**.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
- **Flash Clean**: Instantly remove system caches, logs, and trash.
|
||||
- **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.
|
||||
|
||||
## Prerequisites
|
||||
- **Node.js** (v18+)
|
||||
- **Go** (v1.20+)
|
||||
- **pnpm** (preferred) or npm
|
||||
|
||||
## Development
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Run in Development Mode
|
||||
This starts the Go backend (port 36969) and the Vite/Electron frontend concurrently.
|
||||
```bash
|
||||
./start-go.sh
|
||||
```
|
||||
*Note: Do not run `npm run dev` directly if you want the backend to work. Use the script.*
|
||||
|
||||
## Building for Production
|
||||
|
||||
To create a distributable `.dmg` file for macOS:
|
||||
|
||||
### 1. Build the App
|
||||
```bash
|
||||
npm run build:mac
|
||||
```
|
||||
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-0.0.0-universal.dmg
|
||||
```
|
||||
|
||||
## Running the App
|
||||
1. **Mount the DMG**: Double-click the `.dmg` file in the `release` folder.
|
||||
2. **Install**: Drag the app to your `Applications` folder.
|
||||
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.
|
||||
|
||||
## Architecture
|
||||
- **Frontend**: React, TypeScript, TailwindCSS, Framer Motion.
|
||||
- **Main Process**: Electron (TypeScript).
|
||||
- **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).
|
||||
|
||||
## License
|
||||
MIT
|
||||
217
backend/internal/apps/apps.go
Normal file
217
backend/internal/apps/apps.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// ScanApps returns a list of installed applications
|
||||
func ScanApps() ([]AppInfo, error) {
|
||||
// Scan /Applications and ~/Applications
|
||||
home, _ := os.UserHomeDir()
|
||||
dirs := []string{
|
||||
"/Applications",
|
||||
filepath.Join(home, "Applications"),
|
||||
}
|
||||
|
||||
var apps []AppInfo
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".app") {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
wg.Add(1)
|
||||
go func(p, name string) {
|
||||
defer wg.Done()
|
||||
// Get Bundle ID
|
||||
bid := getBundleID(p)
|
||||
if bid == "" {
|
||||
return // Skip if no bundle ID (system util or broken)
|
||||
}
|
||||
|
||||
// Get Size (fast estimate)
|
||||
// using du -s -k
|
||||
size := getDirSize(p)
|
||||
|
||||
mu.Lock()
|
||||
apps = append(apps, AppInfo{
|
||||
Name: strings.TrimSuffix(name, ".app"),
|
||||
Path: p,
|
||||
BundleID: bid,
|
||||
Size: size,
|
||||
})
|
||||
mu.Unlock()
|
||||
}(path, entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// GetAppDetails finds all associated files for a given app path
|
||||
func GetAppDetails(appPath string) (*AppDetails, error) {
|
||||
bid := getBundleID(appPath)
|
||||
if bid == "" {
|
||||
return nil, fmt.Errorf("could not determine bundle ID")
|
||||
}
|
||||
|
||||
appSize := getDirSize(appPath)
|
||||
details := &AppDetails{
|
||||
AppInfo: AppInfo{
|
||||
Name: filepath.Base(appPath), // simplified
|
||||
Path: appPath,
|
||||
BundleID: bid,
|
||||
Size: appSize,
|
||||
},
|
||||
TotalSize: appSize,
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
library := filepath.Join(home, "Library")
|
||||
|
||||
// Common locations to search for Bundle ID
|
||||
// Name-based search fallback is risky, staying strict to Bundle ID for now
|
||||
locations := map[string]string{
|
||||
"Application Support": filepath.Join(library, "Application Support"),
|
||||
"Caches": filepath.Join(library, "Caches"),
|
||||
"Preferences": filepath.Join(library, "Preferences"),
|
||||
"Saved Application State": filepath.Join(library, "Saved Application State"),
|
||||
"Logs": filepath.Join(library, "Logs"),
|
||||
"Cookies": filepath.Join(library, "Cookies"),
|
||||
"Containers": filepath.Join(library, "Containers"), // Sandboxed data
|
||||
}
|
||||
|
||||
for locName, locPath := range locations {
|
||||
// 1. Direct match: path/BundleID
|
||||
target := filepath.Join(locPath, bid)
|
||||
if exists(target) {
|
||||
size := getDirSize(target)
|
||||
details.Associated = append(details.Associated, AssociatedFile{
|
||||
Path: target,
|
||||
Type: getType(locName),
|
||||
Size: size,
|
||||
})
|
||||
details.TotalSize += size
|
||||
}
|
||||
|
||||
// 2. Preferences often use plist extension: path/BundleID.plist
|
||||
if locName == "Preferences" {
|
||||
plistPath := filepath.Join(locPath, bid+".plist")
|
||||
if exists(plistPath) {
|
||||
size := getFileSize(plistPath)
|
||||
details.Associated = append(details.Associated, AssociatedFile{
|
||||
Path: plistPath,
|
||||
Type: "config",
|
||||
Size: size,
|
||||
})
|
||||
details.TotalSize += size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return details, nil
|
||||
}
|
||||
|
||||
// DeleteFiles removes the requested paths
|
||||
func DeleteFiles(paths []string) error {
|
||||
for _, p := range paths {
|
||||
// Basic safety check: don't delete root or critical system paths
|
||||
// Real implementation needs robust safeguards
|
||||
if p == "/" || p == "/Applications" || p == "/System" || p == "/Library" {
|
||||
continue // Skip dangerous paths
|
||||
}
|
||||
if err := os.RemoveAll(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
func getBundleID(path string) string {
|
||||
// mdls -name kMDItemCFBundleIdentifier -r /path/to/app
|
||||
cmd := exec.Command("mdls", "-name", "kMDItemCFBundleIdentifier", "-r", path)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
res := strings.TrimSpace(string(out))
|
||||
if res == "(null)" {
|
||||
return ""
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func getDirSize(path string) int64 {
|
||||
cmd := exec.Command("du", "-s", "-k", path)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
parts := strings.Fields(string(out))
|
||||
if len(parts) > 0 {
|
||||
var s int64
|
||||
fmt.Sscanf(parts[0], "%d", &s)
|
||||
return s * 1024
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getFileSize(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func getType(locName string) string {
|
||||
switch locName {
|
||||
case "Caches":
|
||||
return "cache"
|
||||
case "Preferences", "Cookies":
|
||||
return "config"
|
||||
case "Logs":
|
||||
return "log"
|
||||
default:
|
||||
return "data"
|
||||
}
|
||||
}
|
||||
27
backend/internal/cleaner/cleaner.go
Normal file
27
backend/internal/cleaner/cleaner.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package cleaner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// PurgePath deletes a file or directory permanently
|
||||
func PurgePath(path string) error {
|
||||
// Safety check: Don't delete root or critical paths
|
||||
if path == "/" || path == "" {
|
||||
return fmt.Errorf("cannot delete root")
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return fmt.Errorf("path does not exist")
|
||||
}
|
||||
|
||||
// Perform deletion
|
||||
err := os.RemoveAll(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
437
backend/internal/scanner/scanner.go
Normal file
437
backend/internal/scanner/scanner.go
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
package scanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// GetDiskUsage uses diskutil for accurate APFS disk usage
|
||||
func GetDiskUsage() (*DiskUsage, error) {
|
||||
cmd := exec.Command("diskutil", "info", "/")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(out), "\n")
|
||||
|
||||
var containerTotal, containerFree int64
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Parse "Container Total Space:" line
|
||||
if strings.HasPrefix(line, "Container Total Space:") {
|
||||
// Format: "Container Total Space: 245.1 GB (245107195904 Bytes)"
|
||||
if start := strings.Index(line, "("); start != -1 {
|
||||
if end := strings.Index(line[start:], " Bytes)"); end != -1 {
|
||||
bytesStr := line[start+1 : start+end]
|
||||
containerTotal, _ = strconv.ParseInt(bytesStr, 10, 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse "Container Free Space:" line
|
||||
if strings.HasPrefix(line, "Container Free Space:") {
|
||||
if start := strings.Index(line, "("); start != -1 {
|
||||
if end := strings.Index(line[start:], " Bytes)"); end != -1 {
|
||||
bytesStr := line[start+1 : start+end]
|
||||
containerFree, _ = strconv.ParseInt(bytesStr, 10, 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate used space
|
||||
containerUsed := containerTotal - containerFree
|
||||
|
||||
toGB := func(bytes int64) string {
|
||||
gb := float64(bytes) / 1024 / 1024 / 1024
|
||||
return fmt.Sprintf("%.2f", gb)
|
||||
}
|
||||
|
||||
return &DiskUsage{
|
||||
TotalGB: toGB(containerTotal),
|
||||
UsedGB: toGB(containerUsed),
|
||||
FreeGB: toGB(containerFree),
|
||||
}, 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
|
||||
}
|
||||
|
||||
// FindHeavyFolders uses `du` to find large directories
|
||||
func FindHeavyFolders(root string) ([]ScanResult, error) {
|
||||
// du -k -d 2 <root> | sort -nr | head -n 50
|
||||
cmd := exec.Command("bash", "-c", fmt.Sprintf("du -k -d 2 \"%s\" | sort -nr | head -n 50", root))
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []ScanResult
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Attempt to parse first part as size
|
||||
firstSpace := strings.IndexAny(line, " \t")
|
||||
if firstSpace == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
sizeStr := line[:firstSpace]
|
||||
pathStr := strings.TrimSpace(line[firstSpace:])
|
||||
|
||||
if pathStr == root {
|
||||
continue
|
||||
}
|
||||
|
||||
sizeK, err := strconv.ParseInt(sizeStr, 10, 64)
|
||||
if err == nil {
|
||||
results = append(results, ScanResult{
|
||||
Path: pathStr,
|
||||
Size: sizeK * 1024,
|
||||
IsDirectory: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// 10MB threshold
|
||||
res, _ := FindLargeFiles(t, 10*1024*1024)
|
||||
allResults = append(allResults, res...)
|
||||
}
|
||||
|
||||
// Sort combined
|
||||
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
|
||||
}
|
||||
|
||||
// System targets: Caches, Logs, Application Support (selective)
|
||||
targets := []string{
|
||||
filepath.Join(home, "Library", "Caches"),
|
||||
filepath.Join(home, "Library", "Logs"),
|
||||
filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData"),
|
||||
}
|
||||
|
||||
var allResults []ScanResult
|
||||
for _, t := range targets {
|
||||
// 10MB threshold
|
||||
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 GetDirectorySize(path string) int64 {
|
||||
// du -s -k <path>
|
||||
cmd := exec.Command("du", "-s", "-k", path)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Output is "size path"
|
||||
parts := strings.Fields(string(out))
|
||||
if len(parts) > 0 {
|
||||
sizeK, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
return sizeK * 1024 // Bytes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func GetCategorySizes() (*CategorySizes, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Paths to check
|
||||
docPath := filepath.Join(home, "Documents")
|
||||
downPath := filepath.Join(home, "Downloads")
|
||||
deskPath := filepath.Join(home, "Desktop")
|
||||
musicPath := filepath.Join(home, "Music")
|
||||
moviesPath := filepath.Join(home, "Movies")
|
||||
|
||||
caches := filepath.Join(home, "Library", "Caches")
|
||||
logs := filepath.Join(home, "Library", "Logs")
|
||||
xcode := filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")
|
||||
|
||||
trash := filepath.Join(home, ".Trash")
|
||||
|
||||
apps := "/Applications"
|
||||
photos := filepath.Join(home, "Pictures")
|
||||
icloud := filepath.Join(home, "Library", "Mobile Documents")
|
||||
|
||||
// Run in parallel
|
||||
type result struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
// Increased buffer for new categories
|
||||
c := make(chan result)
|
||||
totalChecks := 12
|
||||
|
||||
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)
|
||||
|
||||
go check("caches", caches)
|
||||
go check("logs", logs)
|
||||
go check("xcode", xcode)
|
||||
|
||||
go check("trash", trash)
|
||||
go check("apps", apps)
|
||||
go check("photos", photos)
|
||||
go check("icloud", icloud)
|
||||
|
||||
sizes := &CategorySizes{}
|
||||
|
||||
// Get total disk usage to calculate System Data remainder using diskutil (APFS aware)
|
||||
cmd := exec.Command("diskutil", "info", "/")
|
||||
out, err := cmd.Output()
|
||||
var totalUsed int64
|
||||
|
||||
if err == nil {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
var containerTotal, containerFree int64
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Container Total Space:") {
|
||||
if start := strings.Index(line, "("); start != -1 {
|
||||
if end := strings.Index(line[start:], " Bytes)"); end != -1 {
|
||||
bytesStr := line[start+1 : start+end]
|
||||
containerTotal, _ = strconv.ParseInt(bytesStr, 10, 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "Container Free Space:") {
|
||||
if start := strings.Index(line, "("); start != -1 {
|
||||
if end := strings.Index(line[start:], " Bytes)"); end != -1 {
|
||||
bytesStr := line[start+1 : start+end]
|
||||
containerFree, _ = strconv.ParseInt(bytesStr, 10, 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
totalUsed = containerTotal - containerFree // In Bytes
|
||||
}
|
||||
|
||||
var systemSpecific int64
|
||||
|
||||
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 "caches", "logs", "xcode":
|
||||
systemSpecific += res.size
|
||||
case "trash":
|
||||
sizes.Trash += res.size
|
||||
case "apps":
|
||||
sizes.Apps = res.size
|
||||
case "photos":
|
||||
sizes.Photos = res.size
|
||||
case "icloud":
|
||||
sizes.ICloud = res.size
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate System Data
|
||||
// Ideally: System = Total - (Docs + Down + Desk + Music + Movies + Apps + Photos + iCloud + Trash)
|
||||
known := sizes.Documents + sizes.Downloads + sizes.Desktop + sizes.Music + sizes.Movies + sizes.Trash + sizes.Apps + sizes.Photos + sizes.ICloud
|
||||
|
||||
var remainder int64 = 0
|
||||
if totalUsed > known {
|
||||
remainder = totalUsed - known
|
||||
}
|
||||
|
||||
if remainder > systemSpecific {
|
||||
sizes.System = remainder
|
||||
} else {
|
||||
sizes.System = systemSpecific
|
||||
}
|
||||
|
||||
return sizes, nil
|
||||
}
|
||||
|
||||
type CleaningEstimates struct {
|
||||
FlashEst int64 `json:"flash_est"`
|
||||
DeepEst int64 `json:"deep_est"`
|
||||
}
|
||||
|
||||
func GetCleaningEstimates() (*CleaningEstimates, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Paths for Flash Clean: Caches, Logs, Trash, Xcode
|
||||
caches := filepath.Join(home, "Library", "Caches")
|
||||
logs := filepath.Join(home, "Library", "Logs")
|
||||
trash := filepath.Join(home, ".Trash")
|
||||
xcode := filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")
|
||||
|
||||
// Paths for Deep Clean (proxy): Downloads
|
||||
downloads := filepath.Join(home, "Downloads")
|
||||
|
||||
type result struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
c := make(chan result)
|
||||
totalChecks := 5
|
||||
|
||||
check := func(name, p string) {
|
||||
c <- result{name, GetDirectorySize(p)}
|
||||
}
|
||||
|
||||
go check("caches", caches)
|
||||
go check("logs", logs)
|
||||
go check("trash", trash)
|
||||
go check("xcode", xcode)
|
||||
go check("downloads", downloads)
|
||||
|
||||
estimates := &CleaningEstimates{}
|
||||
|
||||
for i := 0; i < totalChecks; i++ {
|
||||
res := <-c
|
||||
switch res.name {
|
||||
case "caches", "logs", "trash", "xcode":
|
||||
estimates.FlashEst += res.size
|
||||
case "downloads":
|
||||
estimates.DeepEst += res.size
|
||||
}
|
||||
}
|
||||
|
||||
return estimates, nil
|
||||
}
|
||||
463
backend/main.go
Normal file
463
backend/main.go
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/kv/clearnup/backend/internal/apps"
|
||||
"github.com/kv/clearnup/backend/internal/cleaner"
|
||||
"github.com/kv/clearnup/backend/internal/scanner"
|
||||
)
|
||||
|
||||
const Port = ":36969"
|
||||
|
||||
func enableCors(w *http.ResponseWriter) {
|
||||
(*w).Header().Set("Access-Control-Allow-Origin", "*")
|
||||
(*w).Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
|
||||
(*w).Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/api/disk-usage", handleDiskUsage)
|
||||
http.HandleFunc("/api/scan/user", handleScanUser)
|
||||
http.HandleFunc("/api/scan/system", handleScanSystem) // Detailed list
|
||||
http.HandleFunc("/api/scan/sizes", handleScanSizes) // Fast summary
|
||||
http.HandleFunc("/api/scan/deepest", handleDeepestScan)
|
||||
|
||||
http.HandleFunc("/api/scan/category", handleScanCategory)
|
||||
http.HandleFunc("/api/purge", handlePurge)
|
||||
http.HandleFunc("/api/empty-trash", handleEmptyTrash)
|
||||
http.HandleFunc("/api/clear-cache", handleClearCache)
|
||||
http.HandleFunc("/api/clean-docker", handleCleanDocker)
|
||||
http.HandleFunc("/api/system-info", handleSystemInfo)
|
||||
http.HandleFunc("/api/estimates", handleCleaningEstimates)
|
||||
|
||||
// App Uninstaller
|
||||
http.HandleFunc("/api/apps", handleScanApps)
|
||||
http.HandleFunc("/api/apps/details", handleAppDetails)
|
||||
http.HandleFunc("/api/apps/action", handleAppAction)
|
||||
|
||||
fmt.Printf("🚀 Antigravity Backend running on http://localhost%s\n", Port)
|
||||
if err := http.ListenAndServe(Port, nil); err != nil {
|
||||
fmt.Printf("Server failed: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
type ScanRequest struct {
|
||||
Category string `json:"category"` // "apps", "photos", "icloud", "docs", "system"
|
||||
}
|
||||
|
||||
func handleScanCategory(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
var req ScanRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
var targets []string
|
||||
|
||||
switch req.Category {
|
||||
case "apps":
|
||||
targets = []string{"/Applications", filepath.Join(home, "Applications")}
|
||||
case "photos":
|
||||
targets = []string{filepath.Join(home, "Pictures")}
|
||||
case "icloud":
|
||||
targets = []string{filepath.Join(home, "Library", "Mobile Documents")}
|
||||
case "docs":
|
||||
targets = []string{filepath.Join(home, "Documents")}
|
||||
case "downloads":
|
||||
targets = []string{filepath.Join(home, "Downloads")}
|
||||
case "desktop":
|
||||
targets = []string{filepath.Join(home, "Desktop")}
|
||||
case "music":
|
||||
targets = []string{filepath.Join(home, "Music")}
|
||||
case "movies":
|
||||
targets = []string{filepath.Join(home, "Movies")}
|
||||
case "system":
|
||||
targets = []string{filepath.Join(home, "Library", "Caches"), filepath.Join(home, "Library", "Logs"), filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")}
|
||||
default:
|
||||
json.NewEncoder(w).Encode([]scanner.ScanResult{})
|
||||
return
|
||||
}
|
||||
|
||||
// Reuse ScanPath logic inline or call a helper
|
||||
// We'll just do a quick loop here since ScanPath in scanner.go was defined but I need to link it
|
||||
// Actually I put ScanPath in scanner.go as FindLargeFiles wrapper.
|
||||
var allResults []scanner.ScanResult
|
||||
for _, t := range targets {
|
||||
res, _ := scanner.FindLargeFiles(t, 10*1024*1024) // 10MB
|
||||
allResults = append(allResults, res...)
|
||||
}
|
||||
|
||||
// Sort
|
||||
sort.Slice(allResults, func(i, j int) bool {
|
||||
return allResults[i].Size > allResults[j].Size
|
||||
})
|
||||
if len(allResults) > 50 {
|
||||
allResults = allResults[:50]
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(allResults)
|
||||
}
|
||||
|
||||
func handleOpenSettings(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
// Open Storage Settings
|
||||
// macOS Ventura+: open x-apple.systempreferences:com.apple.settings.Storage
|
||||
exec.Command("open", "x-apple.systempreferences:com.apple.settings.Storage").Run()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func handleDiskUsage(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
usage, err := scanner.GetDiskUsage()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(usage)
|
||||
}
|
||||
|
||||
func handleScanUser(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := scanner.ScanUserDocuments()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(files)
|
||||
}
|
||||
|
||||
func handleScanSizes(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
sizes, err := scanner.GetCategorySizes()
|
||||
if err != nil {
|
||||
// Log but return empty
|
||||
fmt.Println("Size scan error:", err)
|
||||
json.NewEncoder(w).Encode(map[string]int64{})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(sizes)
|
||||
}
|
||||
|
||||
func handleScanSystem(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := scanner.ScanSystemData()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(files)
|
||||
}
|
||||
|
||||
func handleDeepestScan(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
// Default to Documents for now, or parse body for path
|
||||
home, _ := os.UserHomeDir()
|
||||
target := filepath.Join(home, "Documents")
|
||||
|
||||
folders, err := scanner.FindHeavyFolders(target)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(folders)
|
||||
}
|
||||
|
||||
type PurgeRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func handlePurge(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
var req PurgeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := cleaner.PurgePath(req.Path); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to purge: %s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
http.Error(w, "Cannot get home directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
trashPath := filepath.Join(home, ".Trash")
|
||||
|
||||
// Get all items in trash and delete them
|
||||
entries, err := os.ReadDir(trashPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Cannot read trash", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
itemPath := filepath.Join(trashPath, entry.Name())
|
||||
os.RemoveAll(itemPath)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func handleClearCache(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
cachePath := filepath.Join(home, "Library", "Caches")
|
||||
|
||||
// Get size before clearing
|
||||
sizeBefore := scanner.GetDirectorySize(cachePath)
|
||||
|
||||
// Clear cache directories (keep the Caches folder itself)
|
||||
entries, err := os.ReadDir(cachePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Cannot read cache directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
itemPath := filepath.Join(cachePath, entry.Name())
|
||||
os.RemoveAll(itemPath)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]int64{"cleared": sizeBefore})
|
||||
}
|
||||
|
||||
func handleCleanDocker(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find docker executable
|
||||
dockerPath, err := exec.LookPath("docker")
|
||||
if err != nil {
|
||||
// Try common locations
|
||||
commonPaths := []string{
|
||||
"/usr/local/bin/docker",
|
||||
"/opt/homebrew/bin/docker",
|
||||
"/Applications/Docker.app/Contents/Resources/bin/docker",
|
||||
}
|
||||
for _, p := range commonPaths {
|
||||
if _, e := os.Stat(p); e == nil {
|
||||
dockerPath = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dockerPath == "" {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"cleared": 0,
|
||||
"message": "Docker not found in PATH or common locations",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Run docker system prune -af
|
||||
cmd := exec.Command(dockerPath, "system", "prune", "-af")
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"cleared": 0,
|
||||
"message": fmt.Sprintf("Docker cleanup failed: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"cleared": 1,
|
||||
"message": string(output),
|
||||
})
|
||||
}
|
||||
|
||||
func handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
// Structs for parsing system_profiler JSON
|
||||
type HardwareItem struct {
|
||||
MachineName string `json:"machine_name"`
|
||||
ChipType string `json:"chip_type"`
|
||||
PhysicalMemory string `json:"physical_memory"`
|
||||
}
|
||||
|
||||
type SoftwareItem struct {
|
||||
OSVersion string `json:"os_version"`
|
||||
}
|
||||
|
||||
type SystemProfile struct {
|
||||
Hardware []HardwareItem `json:"SPHardwareDataType"`
|
||||
Software []SoftwareItem `json:"SPSoftwareDataType"`
|
||||
}
|
||||
|
||||
cmd := exec.Command("system_profiler", "SPHardwareDataType", "SPSoftwareDataType", "-json")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get system info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var profile SystemProfile
|
||||
if err := json.Unmarshal(output, &profile); err != nil {
|
||||
http.Error(w, "Failed to parse system info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]string{
|
||||
"model": "Unknown",
|
||||
"chip": "Unknown",
|
||||
"memory": "Unknown",
|
||||
"os": "Unknown",
|
||||
}
|
||||
|
||||
if len(profile.Hardware) > 0 {
|
||||
response["model"] = profile.Hardware[0].MachineName
|
||||
response["chip"] = profile.Hardware[0].ChipType
|
||||
response["memory"] = profile.Hardware[0].PhysicalMemory
|
||||
}
|
||||
if len(profile.Software) > 0 {
|
||||
response["os"] = profile.Software[0].OSVersion
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func handleCleaningEstimates(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
estimates, err := scanner.GetCleaningEstimates()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(estimates)
|
||||
}
|
||||
|
||||
// App Uninstaller Handlers
|
||||
|
||||
func handleScanApps(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
appsList, err := apps.ScanApps()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(appsList)
|
||||
}
|
||||
|
||||
type AppDetailsRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func handleAppDetails(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
var req AppDetailsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
details, err := apps.GetAppDetails(req.Path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(details)
|
||||
}
|
||||
|
||||
type AppActionRequest struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
func handleAppAction(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
var req AppActionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := apps.DeleteFiles(req.Files); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to delete files: %s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
}
|
||||
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 416 KiB |
467
dist-electron/main.cjs
Normal file
467
dist-electron/main.cjs
Normal 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_fs = __toESM(require("fs"), 1);
|
||||
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 (!import_fs.default.existsSync(iconPath)) {
|
||||
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 }));
|
||||
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
20
dist-electron/preload.cjs
Normal 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)
|
||||
});
|
||||
141
electron/features/cleaner.ts
Executable file
141
electron/features/cleaner.ts
Executable file
|
|
@ -0,0 +1,141 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Trash Compactor: System Sanitation
|
||||
|
||||
export async function clearCaches() {
|
||||
const cacheDir = path.join(os.homedir(), 'Library/Caches');
|
||||
// Be careful! Cleaning everything here might break apps.
|
||||
// We should target specific known large caches or stale ones.
|
||||
// For PRD: "Empty User Caches"
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(cacheDir);
|
||||
let freedSpace = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(cacheDir, entry);
|
||||
// We might want to just delete the contents, not the folder itself, or assume app recreates it.
|
||||
// Safer: delete recursively.
|
||||
await fs.rm(fullPath, { recursive: true, force: true });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error clearing caches', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function purgePath(targetPath: string) {
|
||||
try {
|
||||
await fs.rm(targetPath, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`Failed to purge ${targetPath}`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function emptyTrash() {
|
||||
// Uses apple script to force empty trash to avoid "in use" errors if possible,
|
||||
// or `rm -rf ~/.Trash/*` (dangerous!).
|
||||
// Safe way: osascript
|
||||
|
||||
try {
|
||||
const { exec } = await import('child_process');
|
||||
exec(`osascript -e 'tell application "Finder" to empty trash'`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupDocker() {
|
||||
try {
|
||||
const { exec } = await import('child_process');
|
||||
const util = await import('util');
|
||||
const execAsync = util.promisify(exec);
|
||||
|
||||
// Prune everything: stopped containers, all images (dangling + unused), volumes, networks
|
||||
await execAsync('docker system prune -a --volumes -f');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to cleanup docker:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupTmp() {
|
||||
const tmpDir = os.tmpdir();
|
||||
let success = true;
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(tmpDir);
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
// Be careful not to delete hidden system files if possible, but user asked for "complete"
|
||||
await fs.rm(path.join(tmpDir, entry), { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
// Ignore individual file errors (locked files)
|
||||
console.warn(`Skipped ${entry}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to access tmp dir:', e);
|
||||
success = false;
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
export async function cleanupXcode() {
|
||||
try {
|
||||
const home = os.homedir();
|
||||
// Remove DerivedData and iOS DeviceSupport (Aggressive!)
|
||||
const paths = [
|
||||
path.join(home, 'Library/Developer/Xcode/DerivedData'),
|
||||
path.join(home, 'Library/Developer/Xcode/iOS DeviceSupport'),
|
||||
path.join(home, 'Library/Developer/Xcode/Archives'),
|
||||
path.join(home, 'Library/Caches/com.apple.dt.Xcode')
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
await fs.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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupTurnkey() {
|
||||
try {
|
||||
const home = os.homedir();
|
||||
// Clean package manager caches
|
||||
const paths = [
|
||||
path.join(home, '.npm/_cacache'),
|
||||
path.join(home, '.yarn/cache'),
|
||||
path.join(home, 'Library/pnpm/store'), // Mac default for pnpm store if not configured otherwise
|
||||
path.join(home, '.cache/yarn'),
|
||||
path.join(home, '.gradle/caches')
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
await fs.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;
|
||||
}
|
||||
}
|
||||
45
electron/features/enforcer.ts
Executable file
45
electron/features/enforcer.ts
Executable file
|
|
@ -0,0 +1,45 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { dialog, app } from 'electron';
|
||||
|
||||
// The pnpm Force
|
||||
// Watches for package-lock.json or yarn.lock and warns the user.
|
||||
|
||||
export function startEnforcement(projectPath: string) {
|
||||
// We can use fs.watch, but strictly we might want to watch the specific directory
|
||||
// where the user is working.
|
||||
// For this app, maybe we watch the projects user adds?
|
||||
// Or do we implement a global watcher? The PRD implies "actively monitor".
|
||||
// Monitoring the entire filesystem is expensive.
|
||||
// We'll assume we monitor specific "Active Projects" added to the app.
|
||||
|
||||
// Implementation note: This function would be called for each watched project.
|
||||
|
||||
const watcher = fs.watch(projectPath, (eventType, filename) => {
|
||||
if (filename === 'package-lock.json' || filename === 'yarn.lock') {
|
||||
const lockFile = path.join(projectPath, filename);
|
||||
if (fs.existsSync(lockFile)) {
|
||||
handleViolation(lockFile);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return watcher;
|
||||
}
|
||||
|
||||
function handleViolation(lockFile: string) {
|
||||
dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'The pnpm Force',
|
||||
message: `Illegal lockfile detected: ${path.basename(lockFile)}`,
|
||||
detail: 'You must use pnpm! Do you want to convert now?',
|
||||
buttons: ['Convert to pnpm', 'Delete Lockfile', 'Ignore'],
|
||||
defaultId: 0
|
||||
}).then(result => {
|
||||
if (result.response === 0) {
|
||||
// Run conversion
|
||||
} else if (result.response === 1) {
|
||||
fs.unlinkSync(lockFile);
|
||||
}
|
||||
});
|
||||
}
|
||||
191
electron/features/scanner.ts
Executable file
191
electron/features/scanner.ts
Executable file
|
|
@ -0,0 +1,191 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
interface ScanResult {
|
||||
path: string;
|
||||
size: number;
|
||||
lastAccessed: Date;
|
||||
type: 'node_modules' | 'vendor' | 'venv';
|
||||
}
|
||||
|
||||
export async function scanDirectory(rootDir: string, maxDepth: number = 5): Promise<ScanResult[]> {
|
||||
const results: ScanResult[] = [];
|
||||
|
||||
async function traverse(currentPath: string, depth: number) {
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'vendor' || entry.name === '.venv') {
|
||||
// Found a target
|
||||
try {
|
||||
const stats = await fs.stat(fullPath);
|
||||
results.push({
|
||||
path: fullPath,
|
||||
size: 0, // Calculating size is expensive, might do lazily or separate task
|
||||
lastAccessed: stats.atime,
|
||||
type: entry.name as any
|
||||
});
|
||||
// Don't traverse inside node_modules
|
||||
continue;
|
||||
} catch (e) {
|
||||
console.error(`Error stat-ing ${fullPath}`, e);
|
||||
}
|
||||
} else if (!entry.name.startsWith('.')) {
|
||||
// Recurse normal directories
|
||||
await traverse(fullPath, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error scanning ${currentPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await traverse(rootDir, 0);
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getFolderSize(folderPath: string): Promise<number> {
|
||||
let total = 0;
|
||||
try {
|
||||
const stats = await fs.stat(folderPath);
|
||||
if (stats.isFile()) return stats.size;
|
||||
|
||||
const files = await fs.readdir(folderPath, { withFileTypes: true });
|
||||
for (const file of files) {
|
||||
total += await getFolderSize(path.join(folderPath, file.name));
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export interface DeepScanResult {
|
||||
path: string;
|
||||
size: number;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
export async function findLargeFiles(rootDir: string, threshold: number = 100 * 1024 * 1024): Promise<DeepScanResult[]> {
|
||||
const results: DeepScanResult[] = [];
|
||||
|
||||
async function traverse(currentPath: string) {
|
||||
try {
|
||||
const stats = await fs.stat(currentPath);
|
||||
if (stats.size > threshold && !stats.isDirectory()) {
|
||||
results.push({ path: currentPath, size: stats.size, isDirectory: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// SKIP node_modules to prevent self-deletion of the running app!
|
||||
if (path.basename(currentPath) === 'node_modules') return;
|
||||
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') && entry.name !== '.Trash') continue;
|
||||
await traverse(path.join(currentPath, entry.name));
|
||||
}
|
||||
}
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
|
||||
await traverse(rootDir);
|
||||
return results.sort((a, b) => b.size - a.size);
|
||||
}
|
||||
|
||||
export async function getDeepDiveSummary() {
|
||||
const home = os.homedir();
|
||||
const targets = [
|
||||
path.join(home, 'Downloads'),
|
||||
path.join(home, 'Documents'),
|
||||
path.join(home, 'Desktop'),
|
||||
path.join(home, 'Library/Application Support'),
|
||||
];
|
||||
|
||||
const results: DeepScanResult[] = [];
|
||||
for (const t of targets) {
|
||||
console.log(`Scanning ${t}...`);
|
||||
const large = await findLargeFiles(t, 50 * 1024 * 1024); // 50MB+
|
||||
console.log(`Found ${large.length} large files in ${t}`);
|
||||
results.push(...large);
|
||||
}
|
||||
return results.slice(0, 20); // Top 20
|
||||
}
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import util from 'util';
|
||||
const execPromise = util.promisify(exec);
|
||||
|
||||
export async function getDiskUsage() {
|
||||
try {
|
||||
// macOS/Linux: df -k / gives 1K-blocks
|
||||
const { stdout } = await execPromise('df -k /');
|
||||
const lines = stdout.trim().split('\n');
|
||||
// Filesystem 1024-blocks Used Available Capacity iused ifree %iused Mounted on
|
||||
// /dev/disk3s1s1 488245288 15266888 308805360 5% 350280 1957260560 0% /
|
||||
if (lines.length < 2) return null;
|
||||
|
||||
const parts = lines[1].split(/\s+/);
|
||||
// parts[1] is total in 1K blocks
|
||||
// parts[2] is used
|
||||
// parts[3] is available
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findHeavyFolders(rootDir: string): Promise<DeepScanResult[]> {
|
||||
try {
|
||||
console.log(`Deepest scan on: ${rootDir}`);
|
||||
// du -k -d 2: report size in KB, max depth 2
|
||||
// sort -nr: numeric reverse sort
|
||||
// head -n 50: top 50
|
||||
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 => {
|
||||
// Trim leading whitespace
|
||||
const trimmed = line.trim();
|
||||
// Split by first whitespace only
|
||||
const firstSpace = trimmed.indexOf('\t'); // du output is usually tab separated or space
|
||||
// Actually du output on mac is "size<tab>path"
|
||||
|
||||
// Robust splitting for size and path
|
||||
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) as DeepScanResult[];
|
||||
|
||||
return results;
|
||||
} catch (e) {
|
||||
console.error("Deepest scan failed:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
43
electron/features/updater.ts
Executable file
43
electron/features/updater.ts
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
import { exec } from 'child_process';
|
||||
import util from 'util';
|
||||
|
||||
const execAsync = util.promisify(exec);
|
||||
|
||||
// Stasis Field: macOS Update Control
|
||||
// Requires sudo for most operations.
|
||||
|
||||
export async function disableAutoUpdates(password?: string) {
|
||||
// Command to disable auto-update
|
||||
// defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool false
|
||||
// softwareupdate --schedule off
|
||||
|
||||
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'
|
||||
];
|
||||
|
||||
// Note: Handling password securely is tricky via IPC.
|
||||
// Usually we prompt via a sudo-capable executor (like osascript with administrator privileges).
|
||||
|
||||
try {
|
||||
await execWithSudo('softwareupdate --schedule off');
|
||||
// More commands...
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to disable updates', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function execWithSudo(command: string) {
|
||||
const script = `do shell script "${command}" with administrator privileges`;
|
||||
return execAsync(`osascript -e '${script}'`);
|
||||
}
|
||||
|
||||
export async function ignoreUpdate(label: string) {
|
||||
// softwareupdate --ignore <label>
|
||||
// Note: --ignore flag is deprecated/removed in recent macOS for major updates, but might work for minor.
|
||||
return execWithSudo(`softwareupdate --ignore "${label}"`);
|
||||
}
|
||||
244
electron/main.ts
Executable file
244
electron/main.ts
Executable file
|
|
@ -0,0 +1,244 @@
|
|||
import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { scanDirectory, getDeepDiveSummary, getDiskUsage, findHeavyFolders } from './features/scanner.js';
|
||||
import { startEnforcement } from './features/enforcer.js';
|
||||
import { disableAutoUpdates, ignoreUpdate } from './features/updater.js';
|
||||
import { clearCaches, emptyTrash, cleanupDocker, cleanupTmp, purgePath, cleanupXcode, cleanupTurnkey } from './features/cleaner.js';
|
||||
|
||||
// In CJS, __dirname is globally defined. We rely on esbuild providing it.
|
||||
// To stop TypeScript from complaining about missing type in 'module' mode:
|
||||
declare const __dirname: string;
|
||||
declare const __filename: string;
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let backendProcess: ChildProcess | null = null;
|
||||
let tray: Tray | null = null;
|
||||
|
||||
const startBackend = () => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Development mode: Backend should be running via start-go.sh');
|
||||
return;
|
||||
}
|
||||
|
||||
const backendPath = path.join(process.resourcesPath, 'backend');
|
||||
console.log('Starting backend from:', backendPath);
|
||||
|
||||
try {
|
||||
backendProcess = 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 = path.join(__dirname, '../dist/tray/tray-iconTemplate.png');
|
||||
// Use Template image for automatic Light/Dark mode adjustment.
|
||||
// Fallback to color if needed, but Template is best for menu bar.
|
||||
|
||||
// Check if dist/tray exists, if not try public/tray (dev mode)
|
||||
let finalIconPath = iconPath;
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
finalIconPath = path.join(__dirname, '../public/tray/tray-iconTemplate.png');
|
||||
}
|
||||
|
||||
const image = nativeImage.createFromPath(finalIconPath);
|
||||
tray = new Tray(image.resize({ width: 16, height: 16 }));
|
||||
|
||||
tray.setToolTip('Antigravity Cleaner');
|
||||
updateTrayMenu('Initializing...');
|
||||
}
|
||||
|
||||
let isDockVisible = true;
|
||||
|
||||
function updateTrayMenu(statusText: string) {
|
||||
if (!tray) return;
|
||||
|
||||
const contextMenu = 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();
|
||||
// Optionally send IPC to trigger scan?
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Show Dock Icon',
|
||||
type: 'checkbox',
|
||||
checked: isDockVisible,
|
||||
click: (menuItem) => {
|
||||
isDockVisible = menuItem.checked;
|
||||
if (isDockVisible) {
|
||||
app.dock.show();
|
||||
} else {
|
||||
app.dock.hide();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', click: () => app.quit() }
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
// Do NOT reset Title if we are just updating the menu structure,
|
||||
// but statusText drives the menu so we keep it.
|
||||
tray.setTitle(statusText);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
backgroundColor: '#FFFFFF', // Helps prevent white flash
|
||||
webPreferences: {
|
||||
preload: path.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}`);
|
||||
// mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// IPC Handlers
|
||||
ipcMain.handle('scan-directory', async (event, path) => {
|
||||
return scanDirectory(path);
|
||||
});
|
||||
|
||||
ipcMain.handle('deep-dive-scan', async () => {
|
||||
return getDeepDiveSummary();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-disk-usage', async () => {
|
||||
return getDiskUsage();
|
||||
});
|
||||
|
||||
ipcMain.handle('deepest-scan', async (event, targetPath) => {
|
||||
// Default to Documents if no path provided, or allow passing path
|
||||
const target = targetPath || path.join(app.getPath('home'), 'Documents');
|
||||
return findHeavyFolders(target);
|
||||
});
|
||||
|
||||
ipcMain.handle('disable-updates', async () => {
|
||||
return disableAutoUpdates();
|
||||
});
|
||||
|
||||
ipcMain.handle('clean-system', async () => {
|
||||
return clearCaches(); // and others
|
||||
});
|
||||
|
||||
ipcMain.handle('cleanup-docker', async () => {
|
||||
return cleanupDocker();
|
||||
});
|
||||
|
||||
ipcMain.handle('cleanup-tmp', async () => {
|
||||
return cleanupTmp();
|
||||
});
|
||||
|
||||
ipcMain.handle('cleanup-xcode', async () => {
|
||||
return cleanupXcode();
|
||||
});
|
||||
|
||||
ipcMain.handle('cleanup-turnkey', async () => {
|
||||
return cleanupTurnkey();
|
||||
});
|
||||
|
||||
ipcMain.handle('purge-path', async (event, targetPath) => {
|
||||
// Security check: ensure path is within user home or projects?
|
||||
// For now, allow it but in production we need safeguards.
|
||||
// Also import purgePath from cleaner.ts (need to update imports)
|
||||
// const { purgePath } = await import('./features/cleaner.js'); // Already imported above
|
||||
return purgePath(targetPath);
|
||||
});
|
||||
|
||||
// Start background enforcer (example: monitoring home projects)
|
||||
// startEnforcement('/Users/khoa.vo/Projects'); // TODO: Make configurable
|
||||
|
||||
// IPC for Tray
|
||||
ipcMain.handle('update-tray-title', (event, title) => {
|
||||
if (tray) {
|
||||
tray.setTitle(title);
|
||||
updateTrayMenu(title); // Update menu with new status too if needed
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-app-icon', async (event, appPath) => {
|
||||
try {
|
||||
const icon = await app.getFileIcon(appPath, { size: 'normal' });
|
||||
return icon.toDataURL();
|
||||
} catch (e) {
|
||||
console.error('Failed to get icon for:', appPath, e);
|
||||
return '';
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('update-tray-icon', (event, dataUrl) => {
|
||||
if (tray && dataUrl) {
|
||||
const image = nativeImage.createFromDataURL(dataUrl);
|
||||
// Resize to tray standard (16x16 or 22x22 usually, let's keep it crisp)
|
||||
tray.setImage(image.resize({ width: 22, height: 22 }));
|
||||
}
|
||||
});
|
||||
|
||||
createWindow();
|
||||
createTray(); // Initialize Tray
|
||||
startBackend();
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
if (backendProcess) {
|
||||
console.log('Killing backend process...');
|
||||
backendProcess.kill();
|
||||
backendProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
18
electron/preload.ts
Executable file
18
electron/preload.ts
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
scanDirectory: (path: string) => ipcRenderer.invoke('scan-directory', path),
|
||||
disableUpdates: () => ipcRenderer.invoke('disable-updates'),
|
||||
cleanSystem: () => ipcRenderer.invoke('clean-system'),
|
||||
purgePath: (path: string) => ipcRenderer.invoke('purge-path', path),
|
||||
cleanupDocker: () => ipcRenderer.invoke('cleanup-docker'),
|
||||
cleanupTmp: () => ipcRenderer.invoke('cleanup-tmp'),
|
||||
cleanupXcode: () => ipcRenderer.invoke('cleanup-xcode'),
|
||||
cleanupTurnkey: () => ipcRenderer.invoke('cleanup-turnkey'),
|
||||
deepDiveScan: () => ipcRenderer.invoke('deep-dive-scan'),
|
||||
getDiskUsage: () => ipcRenderer.invoke('get-disk-usage'),
|
||||
deepestScan: (path?: string) => ipcRenderer.invoke('deepest-scan', path),
|
||||
updateTrayTitle: (title: string) => ipcRenderer.invoke('update-tray-title', title),
|
||||
getAppIcon: (path: string) => ipcRenderer.invoke('get-app-icon', path),
|
||||
updateTrayIcon: (dataUrl: string) => ipcRenderer.invoke('update-tray-icon', dataUrl),
|
||||
});
|
||||
14
electron/tsconfig.json
Executable file
14
electron/tsconfig.json
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"outDir": "../dist-electron",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["."]
|
||||
}
|
||||
23
eslint.config.js
Executable file
23
eslint.config.js
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/kv/clearnup
|
||||
|
||||
go 1.25.4
|
||||
13
index.html
Executable file
13
index.html
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>kv-clearnup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
7447
package-lock.json
generated
Normal file
7447
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
81
package.json
Executable file
81
package.json
Executable file
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"name": "Lumina",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "dist-electron/main.cjs",
|
||||
"scripts": {
|
||||
"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\"",
|
||||
"electron:build": "node scripts/build-electron.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:go:mac": "sh scripts/build-go.sh",
|
||||
"build:mac": "npm run build:go:mac && npm run build && npm run electron:build && electron-builder --mac --universal",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"preinstall": "node scripts/check-pnpm.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.29.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^26.4.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4",
|
||||
"wait-on": "^8.0.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.kv.clearnup",
|
||||
"productName": "KV Clearnup",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"compression": "maximum",
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"category": "public.app-category.utilities",
|
||||
"hardenedRuntime": false
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "backend/dist/universal/backend",
|
||||
"to": "backend"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3451
pnpm-lock.yaml
Executable file
3451
pnpm-lock.yaml
Executable file
File diff suppressed because it is too large
Load diff
7
postcss.config.js
Executable file
7
postcss.config.js
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
// Force reload
|
||||
BIN
public/tray/tray-icon.png
Normal file
BIN
public/tray/tray-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 513 KiB |
BIN
public/tray/tray-iconTemplate.png
Normal file
BIN
public/tray/tray-iconTemplate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 431 KiB |
1
public/vite.svg
Executable file
1
public/vite.svg
Executable file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
release/.icon-icns/icon.icns
Normal file
BIN
release/.icon-icns/icon.icns
Normal file
Binary file not shown.
BIN
release/KV Clearnup-0.0.0-universal.dmg
Normal file
BIN
release/KV Clearnup-0.0.0-universal.dmg
Normal file
Binary file not shown.
BIN
release/KV Clearnup-0.0.0-universal.dmg.blockmap
Normal file
BIN
release/KV Clearnup-0.0.0-universal.dmg.blockmap
Normal file
Binary file not shown.
40
release/builder-debug.yml
Normal file
40
release/builder-debug.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
x64:
|
||||
firstOrDefaultFilePatterns:
|
||||
- '!**/node_modules/**'
|
||||
- '!build{,/**/*}'
|
||||
- '!release{,/**/*}'
|
||||
- dist/**/*
|
||||
- dist-electron/**/*
|
||||
- package.json
|
||||
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
|
||||
- '!**/._*'
|
||||
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
|
||||
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,bun.lock,bun.lockb,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
|
||||
- '!.yarn{,/**/*}'
|
||||
- '!.editorconfig'
|
||||
- '!.yarnrc.yml'
|
||||
nodeModuleFilePatterns:
|
||||
- '**/*'
|
||||
- dist/**/*
|
||||
- dist-electron/**/*
|
||||
- package.json
|
||||
arm64:
|
||||
firstOrDefaultFilePatterns:
|
||||
- '!**/node_modules/**'
|
||||
- '!build{,/**/*}'
|
||||
- '!release{,/**/*}'
|
||||
- dist/**/*
|
||||
- dist-electron/**/*
|
||||
- package.json
|
||||
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
|
||||
- '!**/._*'
|
||||
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
|
||||
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,bun.lock,bun.lockb,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
|
||||
- '!.yarn{,/**/*}'
|
||||
- '!.editorconfig'
|
||||
- '!.yarnrc.yml'
|
||||
nodeModuleFilePatterns:
|
||||
- '**/*'
|
||||
- dist/**/*
|
||||
- dist-electron/**/*
|
||||
- package.json
|
||||
21
release/builder-effective-config.yaml
Normal file
21
release/builder-effective-config.yaml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
appId: com.kv.clearnup
|
||||
productName: KV Clearnup
|
||||
compression: maximum
|
||||
mac:
|
||||
target:
|
||||
- dmg
|
||||
icon: build/icon.png
|
||||
category: public.app-category.utilities
|
||||
hardenedRuntime: false
|
||||
files:
|
||||
- filter:
|
||||
- dist/**/*
|
||||
- dist-electron/**/*
|
||||
- package.json
|
||||
extraResources:
|
||||
- from: backend/dist/universal/backend
|
||||
to: backend
|
||||
electronVersion: 33.4.11
|
||||
|
|
@ -0,0 +1 @@
|
|||
Versions/Current/Electron Framework
|
||||
|
|
@ -0,0 +1 @@
|
|||
Versions/Current/Helpers
|
||||
|
|
@ -0,0 +1 @@
|
|||
Versions/Current/Libraries
|
||||
|
|
@ -0,0 +1 @@
|
|||
Versions/Current/Resources
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
{"file_format_version": "1.0.0", "ICD": {"library_path": "./libvk_swiftshader.dylib", "api_version": "1.0.5"}}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Electron Framework</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.github.Electron.framework</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Electron Framework</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>33.4.11</string>
|
||||
<key>DTCompiler</key>
|
||||
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||
<key>DTSDKBuild</key>
|
||||
<string>23F73</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx14.5</string>
|
||||
<key>DTXcode</key>
|
||||
<string>1540</string>
|
||||
<key>DTXcodeBuild</key>
|
||||
<string>15F31d</string>
|
||||
<key>LSEnvironment</key>
|
||||
<dict>
|
||||
<key>MallocNanoZone</key>
|
||||
<string>0</string>
|
||||
</dict>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
<key>ElectronAsarIntegrity</key>
|
||||
<dict>
|
||||
<key>Resources/app.asar</key>
|
||||
<dict>
|
||||
<key>algorithm</key>
|
||||
<string>SHA256</string>
|
||||
<key>hash</key>
|
||||
<string>7f0ca3c6fae4ccfe2d088e243546c0f695b844fbf714bd59e8c6111fb873f334</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue