release: v1.1 portable (clean history)
This commit is contained in:
commit
8e30ac6825
69 changed files with 19021 additions and 0 deletions
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# 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?
|
||||||
|
# Release Artifacts
|
||||||
|
Release/
|
||||||
|
release/
|
||||||
|
*.zip
|
||||||
|
*.exe
|
||||||
|
backend/dist/
|
||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node-linker=hoisted
|
||||||
|
package-import-method=clone-or-copy
|
||||||
68
README.md
Normal file
68
README.md
Normal 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-1.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
|
||||||
BIN
Release/Antigravity-Mac.zip
Normal file
BIN
Release/Antigravity-Mac.zip
Normal file
Binary file not shown.
BIN
Release/Antigravity-Windows.zip
Normal file
BIN
Release/Antigravity-Windows.zip
Normal file
Binary file not shown.
22
backend/internal/apps/apps_common.go
Normal file
22
backend/internal/apps/apps_common.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package apps
|
||||||
|
|
||||||
|
type AppInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
BundleID string `json:"bundleID"` // On Windows this can be ProductCode or Registry Key Name
|
||||||
|
UninstallString string `json:"uninstallString"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssociatedFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"` // "cache", "config", "log", "data"
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppDetails struct {
|
||||||
|
AppInfo
|
||||||
|
Associated []AssociatedFile `json:"associated"`
|
||||||
|
TotalSize int64 `json:"totalSize"`
|
||||||
|
}
|
||||||
206
backend/internal/apps/apps_darwin.go
Normal file
206
backend/internal/apps/apps_darwin.go
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package apps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Structs moved to apps_common.go
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunUninstaller executes the uninstall command (Not implemented on Mac yet)
|
||||||
|
func RunUninstaller(cmdString string) error {
|
||||||
|
return fmt.Errorf("uninstall not supported on macOS yet")
|
||||||
|
}
|
||||||
248
backend/internal/apps/apps_windows.go
Normal file
248
backend/internal/apps/apps_windows.go
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package apps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScanApps returns a list of installed applications via Registry
|
||||||
|
func ScanApps() ([]AppInfo, error) {
|
||||||
|
var apps []AppInfo
|
||||||
|
|
||||||
|
// Keys to search
|
||||||
|
// HKLM Software\Microsoft\Windows\CurrentVersion\Uninstall
|
||||||
|
// HKLM Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall
|
||||||
|
// HKCU Software\Microsoft\Windows\CurrentVersion\Uninstall
|
||||||
|
|
||||||
|
keys := []struct {
|
||||||
|
hive registry.Key
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{registry.LOCAL_MACHINE, `Software\Microsoft\Windows\CurrentVersion\Uninstall`},
|
||||||
|
{registry.LOCAL_MACHINE, `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`},
|
||||||
|
{registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Uninstall`},
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
baseKey, err := registry.OpenKey(k.hive, k.path, registry.READ)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
subkeys, err := baseKey.ReadSubKeyNames(-1)
|
||||||
|
baseKey.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subkeyName := range subkeys {
|
||||||
|
appKey, err := registry.OpenKey(k.hive, k.path+`\`+subkeyName, registry.READ)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName, _, err := appKey.GetStringValue("DisplayName")
|
||||||
|
if err != nil || displayName == "" {
|
||||||
|
appKey.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define installLocation explicitly
|
||||||
|
installLocation, _, _ := appKey.GetStringValue("InstallLocation")
|
||||||
|
uninstallString, _, _ := appKey.GetStringValue("UninstallString")
|
||||||
|
quietUninstallString, _, _ := appKey.GetStringValue("QuietUninstallString")
|
||||||
|
|
||||||
|
if uninstallString == "" && quietUninstallString != "" {
|
||||||
|
uninstallString = quietUninstallString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug Log
|
||||||
|
if strings.Contains(displayName, "Foxit") {
|
||||||
|
fmt.Printf("found Foxit: %s | UninstallString: %s\n", displayName, uninstallString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplication: If we've seen this Name + Location combination, skip it.
|
||||||
|
// This handles the common case of 32-bit apps appearing in both HKLM and WOW6432Node.
|
||||||
|
dedupKey := displayName + "|" + strings.ToLower(installLocation)
|
||||||
|
if seen[dedupKey] {
|
||||||
|
appKey.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[dedupKey] = true
|
||||||
|
|
||||||
|
// Try to get size from registry (EstimatedSize is in KB)
|
||||||
|
sizeVal, _, errSize := appKey.GetIntegerValue("EstimatedSize")
|
||||||
|
var sizeBytes int64
|
||||||
|
if errSize == nil {
|
||||||
|
sizeBytes = int64(sizeVal) * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct Full Registry Key Path as BundleID for later use
|
||||||
|
hiveName := "HKLM"
|
||||||
|
if k.hive == registry.CURRENT_USER {
|
||||||
|
hiveName = "HKCU"
|
||||||
|
}
|
||||||
|
fullRegPath := hiveName + `\` + k.path + `\` + subkeyName
|
||||||
|
|
||||||
|
apps = append(apps, AppInfo{
|
||||||
|
Name: displayName,
|
||||||
|
Path: installLocation,
|
||||||
|
BundleID: fullRegPath,
|
||||||
|
UninstallString: uninstallString,
|
||||||
|
Size: sizeBytes,
|
||||||
|
})
|
||||||
|
appKey.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAppDetails finds all associated files (simplified for Windows)
|
||||||
|
func GetAppDetails(appPath, bundleID string) (*AppDetails, error) {
|
||||||
|
// appPath might come from ScanApps which set it to InstallLocation.
|
||||||
|
// bundleID is used as the Registry Key Path.
|
||||||
|
|
||||||
|
// Re-construct basic info
|
||||||
|
info := AppInfo{
|
||||||
|
Name: filepath.Base(appPath),
|
||||||
|
Path: appPath,
|
||||||
|
BundleID: bundleID,
|
||||||
|
// UninstallString is hard to recover if not passed, but usually we call GetAppDetails after ScanApps which has it.
|
||||||
|
// For now leave empty, or we'd need to re-query registry if bundleID is a registry path.
|
||||||
|
Size: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if appPath == "" && bundleID != "" {
|
||||||
|
// Fallback name if path is empty
|
||||||
|
parts := strings.Split(bundleID, `\`)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
info.Name = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
details := &AppDetails{
|
||||||
|
AppInfo: info,
|
||||||
|
TotalSize: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Scan File System
|
||||||
|
if appPath != "" {
|
||||||
|
var size int64
|
||||||
|
filepath.WalkDir(appPath, func(_ string, d os.DirEntry, err error) error {
|
||||||
|
if err == nil && !d.IsDir() {
|
||||||
|
i, _ := d.Info()
|
||||||
|
size += i.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
details.AppInfo.Size = size
|
||||||
|
details.TotalSize = size
|
||||||
|
|
||||||
|
// Add the main folder as associated data
|
||||||
|
details.Associated = append(details.Associated, AssociatedFile{
|
||||||
|
Path: appPath,
|
||||||
|
Type: "data",
|
||||||
|
Size: size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add Registry Key (Uninstall Entry)
|
||||||
|
if bundleID != "" && (strings.HasPrefix(bundleID, "HKLM") || strings.HasPrefix(bundleID, "HKCU")) {
|
||||||
|
// We treat the registry key as a "file" with special type and 0 size
|
||||||
|
details.Associated = append(details.Associated, AssociatedFile{
|
||||||
|
Path: "REG:" + bundleID,
|
||||||
|
Type: "registry", // New type
|
||||||
|
Size: 0, // Registry entries are negligible in size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return details, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFiles removes the requested paths
|
||||||
|
func DeleteFiles(paths []string) error {
|
||||||
|
for _, p := range paths {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry Deletion
|
||||||
|
if strings.HasPrefix(p, "REG:") {
|
||||||
|
regPath := strings.TrimPrefix(p, "REG:")
|
||||||
|
deleteRegistryKey(regPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety checks
|
||||||
|
if p == "C:\\" || p == "c:\\" ||
|
||||||
|
p == "C:\\Windows" || strings.HasPrefix(strings.ToLower(p), "c:\\windows") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.RemoveAll(p)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but continue? Or return?
|
||||||
|
// return err
|
||||||
|
// On Windows file locking is common, best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRegistryKey(fullPath string) error {
|
||||||
|
var hive registry.Key
|
||||||
|
var subPath string
|
||||||
|
|
||||||
|
if strings.HasPrefix(fullPath, "HKLM\\") {
|
||||||
|
hive = registry.LOCAL_MACHINE
|
||||||
|
subPath = strings.TrimPrefix(fullPath, "HKLM\\")
|
||||||
|
} else if strings.HasPrefix(fullPath, "HKCU\\") {
|
||||||
|
hive = registry.CURRENT_USER
|
||||||
|
subPath = strings.TrimPrefix(fullPath, "HKCU\\")
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide parent key and subkey name to DeleteKey
|
||||||
|
// path: Software\...\Uninstall\AppGUID
|
||||||
|
lastSlash := strings.LastIndex(subPath, `\`)
|
||||||
|
if lastSlash == -1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parentPath := subPath[:lastSlash]
|
||||||
|
keyName := subPath[lastSlash+1:]
|
||||||
|
|
||||||
|
k, err := registry.OpenKey(hive, parentPath, registry.WRITE)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
|
||||||
|
return registry.DeleteKey(k, keyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunUninstaller executes the uninstall command
|
||||||
|
func RunUninstaller(cmdString string) error {
|
||||||
|
fmt.Printf("RunUninstaller Called with: %s\n", cmdString)
|
||||||
|
cmd := exec.Command("cmd", "/C", cmdString)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: false} // Show window so user can click next
|
||||||
|
err := cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("RunUninstaller Error: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("RunUninstaller Started Successfully\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
8
backend/internal/platform/platform_common.go
Normal file
8
backend/internal/platform/platform_common.go
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
package platform
|
||||||
|
|
||||||
|
type SystemInfo struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Chip string `json:"chip"`
|
||||||
|
Memory string `json:"memory"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
}
|
||||||
114
backend/internal/platform/platform_darwin.go
Normal file
114
backend/internal/platform/platform_darwin.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func OpenSettings() error {
|
||||||
|
return exec.Command("open", "x-apple.systempreferences:com.apple.settings.Storage").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSystemInfo() (*SystemInfo, error) {
|
||||||
|
// Structs for parsing system_profiler JSON
|
||||||
|
type HardwareItem struct {
|
||||||
|
MachineName string `json:"machine_name"`
|
||||||
|
ChipType string `json:"chip_type"`
|
||||||
|
PhysicalMemory string `json:"physical_memory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SoftwareItem struct {
|
||||||
|
OSVersion string `json:"os_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemProfile struct {
|
||||||
|
Hardware []HardwareItem `json:"SPHardwareDataType"`
|
||||||
|
Software []SoftwareItem `json:"SPSoftwareDataType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("system_profiler", "SPHardwareDataType", "SPSoftwareDataType", "-json")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile SystemProfile
|
||||||
|
if err := json.Unmarshal(output, &profile); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &SystemInfo{
|
||||||
|
Model: "Unknown",
|
||||||
|
Chip: "Unknown",
|
||||||
|
Memory: "Unknown",
|
||||||
|
OS: "Unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(profile.Hardware) > 0 {
|
||||||
|
info.Model = profile.Hardware[0].MachineName
|
||||||
|
info.Chip = profile.Hardware[0].ChipType
|
||||||
|
info.Memory = profile.Hardware[0].PhysicalMemory
|
||||||
|
}
|
||||||
|
if len(profile.Software) > 0 {
|
||||||
|
info.OS = profile.Software[0].OSVersion
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EmptyTrash() error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
trashPath := filepath.Join(home, ".Trash")
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(trashPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
itemPath := filepath.Join(trashPath, entry.Name())
|
||||||
|
os.RemoveAll(itemPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCachePath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, "Library", "Caches"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDockerPath() (string, error) {
|
||||||
|
dockerPath, err := exec.LookPath("docker")
|
||||||
|
if err != nil {
|
||||||
|
// Try common locations
|
||||||
|
commonPaths := []string{
|
||||||
|
"/usr/local/bin/docker",
|
||||||
|
"/opt/homebrew/bin/docker",
|
||||||
|
"/Applications/Docker.app/Contents/Resources/bin/docker",
|
||||||
|
}
|
||||||
|
for _, p := range commonPaths {
|
||||||
|
if _, e := os.Stat(p); e == nil {
|
||||||
|
dockerPath = p
|
||||||
|
return dockerPath, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dockerPath != "" {
|
||||||
|
return dockerPath, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("docker not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenBrowser(url string) error {
|
||||||
|
return exec.Command("open", url).Start()
|
||||||
|
}
|
||||||
106
backend/internal/platform/platform_windows.go
Normal file
106
backend/internal/platform/platform_windows.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func OpenSettings() error {
|
||||||
|
// Open Windows Settings -> Storage
|
||||||
|
// ms-settings:storagesense
|
||||||
|
return exec.Command("cmd", "/c", "start", "ms-settings:storagesense").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSystemInfo() (*SystemInfo, error) {
|
||||||
|
// Use systeminfo or wmic
|
||||||
|
// simpler: generic info
|
||||||
|
|
||||||
|
info := &SystemInfo{
|
||||||
|
Model: "PC",
|
||||||
|
Chip: "Unknown",
|
||||||
|
Memory: "Unknown",
|
||||||
|
OS: "Windows",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to run powershell and get string result
|
||||||
|
runPS := func(cmd string) string {
|
||||||
|
out, err := exec.Command("powershell", "-NoProfile", "-Command", cmd).Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Get OS Name (Simplified)
|
||||||
|
// Get-CimInstance Win32_OperatingSystem | Select-Object -ExpandProperty Caption
|
||||||
|
osName := runPS("(Get-CimInstance Win32_OperatingSystem).Caption")
|
||||||
|
if osName != "" {
|
||||||
|
info.OS = strings.TrimPrefix(osName, "Microsoft ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get Memory (in GB)
|
||||||
|
// [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB)
|
||||||
|
mem := runPS("[math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB)")
|
||||||
|
if mem != "" {
|
||||||
|
info.Memory = mem + " GB"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get CPU Name
|
||||||
|
// (Get-CimInstance Win32_Processor).Name
|
||||||
|
cpu := runPS("(Get-CimInstance Win32_Processor).Name")
|
||||||
|
if cpu != "" {
|
||||||
|
// Cleanup CPU string (remove extra spaces)
|
||||||
|
info.Chip = strings.Join(strings.Fields(cpu), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EmptyTrash() error {
|
||||||
|
// PowerShell to empty Recycle Bin
|
||||||
|
// Clear-RecycleBin -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
// PowerShell to empty Recycle Bin
|
||||||
|
// Clear-RecycleBin -Force -ErrorAction SilentlyContinue
|
||||||
|
// We use ExecutionPolicy Bypass to avoid permission issues.
|
||||||
|
// We also catch errors to prevent 500s on empty bins.
|
||||||
|
|
||||||
|
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "Clear-RecycleBin -Force -ErrorAction SilentlyContinue")
|
||||||
|
// If it returns an error, it might be due to permissions or being already empty.
|
||||||
|
// We can ignore the error for now to check if that fixes the User's 500.
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
// Log it but return nil effectively?
|
||||||
|
// For now, let's return nil because 'Empty Trash' is best-effort.
|
||||||
|
// If the user really has a permission issue, it acts as a no-op which is better than a crash.
|
||||||
|
fmt.Printf("EmptyTrash warning: %v\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCachePath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, "AppData", "Local", "Temp"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDockerPath() (string, error) {
|
||||||
|
path, err := exec.LookPath("docker")
|
||||||
|
if err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
// Common Windows path?
|
||||||
|
return "", fmt.Errorf("docker not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenBrowser(url string) error {
|
||||||
|
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||||
|
}
|
||||||
92
backend/internal/scanner/scanner_common.go
Normal file
92
backend/internal/scanner/scanner_common.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScanResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
IsDirectory bool `json:"isDirectory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiskUsage struct {
|
||||||
|
Name string `json:"name"` // e.g. "Local Disk (C:)"
|
||||||
|
TotalGB string `json:"totalGB"`
|
||||||
|
UsedGB string `json:"usedGB"`
|
||||||
|
FreeGB string `json:"freeGB"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategorySizes struct {
|
||||||
|
Documents int64 `json:"documents"` // Personal Docs only
|
||||||
|
Downloads int64 `json:"downloads"`
|
||||||
|
Desktop int64 `json:"desktop"`
|
||||||
|
Music int64 `json:"music"`
|
||||||
|
Movies int64 `json:"movies"`
|
||||||
|
System int64 `json:"system"`
|
||||||
|
Trash int64 `json:"trash"`
|
||||||
|
Apps int64 `json:"apps"`
|
||||||
|
Photos int64 `json:"photos"`
|
||||||
|
ICloud int64 `json:"icloud"` // Or OneDrive on Windows?
|
||||||
|
Archives int64 `json:"archives"`
|
||||||
|
VirtualMachines int64 `json:"virtual_machines"`
|
||||||
|
Games int64 `json:"games"`
|
||||||
|
AI int64 `json:"ai"`
|
||||||
|
Docker int64 `json:"docker"`
|
||||||
|
Cache int64 `json:"cache"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CleaningEstimates struct {
|
||||||
|
FlashEst int64 `json:"flash_est"`
|
||||||
|
DeepEst int64 `json:"deep_est"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLargeFiles walks a directory and returns files > threshold
|
||||||
|
func FindLargeFiles(root string, threshold int64) ([]ScanResult, error) {
|
||||||
|
var results []ScanResult
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // Skip errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hidden files/dirs (except .Trash maybe, but let's skip all . for now)
|
||||||
|
if strings.HasPrefix(d.Name(), ".") {
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip node_modules explicitly
|
||||||
|
if d.IsDir() && d.Name() == "node_modules" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.IsDir() {
|
||||||
|
info, err := d.Info()
|
||||||
|
if err == nil && info.Size() > threshold {
|
||||||
|
results = append(results, ScanResult{
|
||||||
|
Path: path,
|
||||||
|
Size: info.Size(),
|
||||||
|
IsDirectory: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by size desc
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].Size > results[j].Size
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return top 50
|
||||||
|
if len(results) > 50 {
|
||||||
|
return results[:50], err
|
||||||
|
}
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
369
backend/internal/scanner/scanner_darwin.go
Normal file
369
backend/internal/scanner/scanner_darwin.go
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Structs moved to scanner_common.go
|
||||||
|
|
||||||
|
// 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{{
|
||||||
|
Name: "Macintosh HD",
|
||||||
|
TotalGB: toGB(containerTotal),
|
||||||
|
UsedGB: toGB(containerUsed),
|
||||||
|
FreeGB: toGB(containerFree),
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLargeFiles moved to scanner_common.go
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleaningEstimates struct moved to scanner_common.go
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
435
backend/internal/scanner/scanner_windows.go
Normal file
435
backend/internal/scanner/scanner_windows.go
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
// Added missing import
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDiskUsage using GetDiskFreeSpaceExW
|
||||||
|
// GetDiskUsage returns usage for all fixed drives
|
||||||
|
func GetDiskUsage() ([]*DiskUsage, error) {
|
||||||
|
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
getDiskFreeSpaceEx := kernel32.NewProc("GetDiskFreeSpaceExW")
|
||||||
|
getLogicalDrives := kernel32.NewProc("GetLogicalDrives")
|
||||||
|
|
||||||
|
var usages []*DiskUsage
|
||||||
|
|
||||||
|
// Get logical drives bitmask
|
||||||
|
ret, _, _ := getLogicalDrives.Call()
|
||||||
|
if ret == 0 {
|
||||||
|
return nil, fmt.Errorf("GetLogicalDrives failed")
|
||||||
|
}
|
||||||
|
drivesBitmask := uint32(ret)
|
||||||
|
|
||||||
|
toGB := func(bytes int64) string {
|
||||||
|
gb := float64(bytes) / 1024 / 1024 / 1024
|
||||||
|
return fmt.Sprintf("%.2f", gb)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 26; i++ {
|
||||||
|
if drivesBitmask&(1<<uint(i)) != 0 {
|
||||||
|
driveLetter := string(rune('A' + i))
|
||||||
|
root := driveLetter + ":\\"
|
||||||
|
|
||||||
|
// Check drive type? strictly speaking GetDiskFreeSpaceEx works on network too.
|
||||||
|
// Ideally check GetDriveType to avoid floppy/cd, but usually no biggie if we just check free space.
|
||||||
|
|
||||||
|
var freeBytesAvailable, totalNumberOfBytes, totalNumberOfFreeBytes int64
|
||||||
|
pathPtr, _ := syscall.UTF16PtrFromString(root)
|
||||||
|
|
||||||
|
r, _, _ := getDiskFreeSpaceEx.Call(
|
||||||
|
uintptr(unsafe.Pointer(pathPtr)),
|
||||||
|
uintptr(unsafe.Pointer(&freeBytesAvailable)),
|
||||||
|
uintptr(unsafe.Pointer(&totalNumberOfBytes)),
|
||||||
|
uintptr(unsafe.Pointer(&totalNumberOfFreeBytes)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if r != 0 && totalNumberOfBytes > 0 {
|
||||||
|
usedBytes := totalNumberOfBytes - totalNumberOfFreeBytes
|
||||||
|
usages = append(usages, &DiskUsage{
|
||||||
|
Name: fmt.Sprintf("Local Disk (%s:)", driveLetter),
|
||||||
|
TotalGB: toGB(totalNumberOfBytes),
|
||||||
|
UsedGB: toGB(usedBytes),
|
||||||
|
FreeGB: toGB(totalNumberOfFreeBytes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return usages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDirectorySize walks the directory to calculate size (Windows doesn't have `du`)
|
||||||
|
func GetDirectorySize(path string) int64 {
|
||||||
|
var size int64
|
||||||
|
filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !d.IsDir() {
|
||||||
|
info, err := d.Info()
|
||||||
|
if err == nil {
|
||||||
|
size += info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindHeavyFolders finds large directories
|
||||||
|
func FindHeavyFolders(root string) ([]ScanResult, error) {
|
||||||
|
// Basic implementation: Walk max 2 levels deep and calculate sizes
|
||||||
|
var results []ScanResult
|
||||||
|
|
||||||
|
// depth 0 = root
|
||||||
|
// depth 1 = children of root
|
||||||
|
// depth 2 = children of children
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
path := filepath.Join(root, entry.Name())
|
||||||
|
wg.Add(1)
|
||||||
|
go func(p string) {
|
||||||
|
defer wg.Done()
|
||||||
|
s := GetDirectorySize(p)
|
||||||
|
mu.Lock()
|
||||||
|
results = append(results, ScanResult{
|
||||||
|
Path: p,
|
||||||
|
Size: s,
|
||||||
|
IsDirectory: true,
|
||||||
|
})
|
||||||
|
mu.Unlock()
|
||||||
|
}(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Sort by size desc
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].Size > results[j].Size
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(results) > 50 {
|
||||||
|
return results[:50], nil
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanUserDocuments() ([]ScanResult, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := []string{
|
||||||
|
filepath.Join(home, "Documents"),
|
||||||
|
filepath.Join(home, "Downloads"),
|
||||||
|
filepath.Join(home, "Desktop"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var allResults []ScanResult
|
||||||
|
for _, t := range targets {
|
||||||
|
res, _ := FindLargeFiles(t, 10*1024*1024) // 10MB
|
||||||
|
allResults = append(allResults, res...)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(allResults, func(i, j int) bool {
|
||||||
|
return allResults[i].Size > allResults[j].Size
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(allResults) > 50 {
|
||||||
|
return allResults[:50], nil
|
||||||
|
}
|
||||||
|
return allResults, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanSystemData() ([]ScanResult, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows System/Temp locations
|
||||||
|
// %Temp%, Prefetch (admin only, careful), AppData/Local/Temp
|
||||||
|
targets := []string{
|
||||||
|
filepath.Join(home, "AppData", "Local", "Temp"),
|
||||||
|
os.Getenv("TEMP"),
|
||||||
|
// "C:\\Windows\\Temp", // Requires Admin, maybe skip for now or handle error
|
||||||
|
}
|
||||||
|
|
||||||
|
var allResults []ScanResult
|
||||||
|
for _, t := range targets {
|
||||||
|
if t == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res, _ := FindLargeFiles(t, 10*1024*1024)
|
||||||
|
allResults = append(allResults, res...)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(allResults, func(i, j int) bool {
|
||||||
|
return allResults[i].Size > allResults[j].Size
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(allResults) > 50 {
|
||||||
|
return allResults[:50], nil
|
||||||
|
}
|
||||||
|
return allResults, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCategorySizes() (*CategorySizes, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
docPath := filepath.Join(home, "Documents")
|
||||||
|
downPath := filepath.Join(home, "Downloads")
|
||||||
|
deskPath := filepath.Join(home, "Desktop")
|
||||||
|
musicPath := filepath.Join(home, "Music")
|
||||||
|
moviesPath := filepath.Join(home, "Videos") // Windows uses Videos
|
||||||
|
photos := filepath.Join(home, "Pictures")
|
||||||
|
|
||||||
|
// AppData is roughly Library
|
||||||
|
localAppData := filepath.Join(home, "AppData", "Local")
|
||||||
|
temp := filepath.Join(localAppData, "Temp")
|
||||||
|
|
||||||
|
// Parallel fetch
|
||||||
|
type result struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
c := make(chan result)
|
||||||
|
// Checks: docs, down, desk, music, movies, temp, photos, archives, vms, games, ai, docker, cache
|
||||||
|
totalChecks := 13
|
||||||
|
|
||||||
|
check := func(name, p string) {
|
||||||
|
c <- result{name, GetDirectorySize(p)}
|
||||||
|
}
|
||||||
|
|
||||||
|
go check("docs", docPath)
|
||||||
|
go check("down", downPath)
|
||||||
|
go check("desk", deskPath)
|
||||||
|
go check("music", musicPath)
|
||||||
|
go check("movies", moviesPath)
|
||||||
|
// Temp is part of Cache now, but let's keep it separate or sum it up
|
||||||
|
// System/Temp logic from before:
|
||||||
|
go check("temp", temp)
|
||||||
|
go check("photos", photos)
|
||||||
|
|
||||||
|
// Scan specific common folders for Archives and VMs
|
||||||
|
go func() {
|
||||||
|
// Archives: Zip, Rar, 7z in Downloads and Documents
|
||||||
|
size := ScanExtensions(downPath, []string{".zip", ".rar", ".7z", ".tar", ".gz", ".xz"})
|
||||||
|
size += ScanExtensions(docPath, []string{".zip", ".rar", ".7z", ".tar", ".gz", ".xz"})
|
||||||
|
c <- result{"archives", size}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// VMs / Disk Images: ISO, VHDX, VMDK in Downloads and Documents
|
||||||
|
size := ScanExtensions(downPath, []string{".iso", ".vdi", ".vmdk", ".qcow2", ".vhdx", ".img", ".dsk"})
|
||||||
|
size += ScanExtensions(docPath, []string{".iso", ".vdi", ".vmdk", ".qcow2", ".vhdx", ".img", ".dsk"})
|
||||||
|
c <- result{"vms", size}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Games
|
||||||
|
go func() {
|
||||||
|
var size int64
|
||||||
|
// Common Game Paths
|
||||||
|
paths := []string{
|
||||||
|
`C:\Program Files (x86)\Steam\steamapps\common`,
|
||||||
|
`C:\Program Files\Epic Games`,
|
||||||
|
`C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\games`,
|
||||||
|
`C:\Program Files\EA Games`,
|
||||||
|
filepath.Join(home, "AppData", "Roaming", ".minecraft"),
|
||||||
|
}
|
||||||
|
for _, p := range paths {
|
||||||
|
size += GetDirectorySize(p)
|
||||||
|
}
|
||||||
|
c <- result{"games", size}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// AI
|
||||||
|
go func() {
|
||||||
|
var size int64
|
||||||
|
// 1. Common Installation Paths
|
||||||
|
paths := []string{
|
||||||
|
`C:\ComfyUI`,
|
||||||
|
`C:\ai\ComfyUI`,
|
||||||
|
filepath.Join(home, "ComfyUI"),
|
||||||
|
filepath.Join(home, "stable-diffusion-webui"),
|
||||||
|
filepath.Join(home, "webui"),
|
||||||
|
// Common Model Caches
|
||||||
|
filepath.Join(home, ".cache", "huggingface"),
|
||||||
|
filepath.Join(home, ".ollama", "models"),
|
||||||
|
filepath.Join(home, ".lmstudio", "models"),
|
||||||
|
}
|
||||||
|
for _, p := range paths {
|
||||||
|
size += GetDirectorySize(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Loose Model Files (Deep Scan)
|
||||||
|
// Look for .safetensors, .ckpt, .gguf, .pt, .pth, .bin, .onnx in Downloads and Documents
|
||||||
|
aiExtensions := []string{".safetensors", ".ckpt", ".gguf", ".pt", ".pth", ".bin", ".onnx"}
|
||||||
|
size += ScanExtensions(downPath, aiExtensions)
|
||||||
|
size += ScanExtensions(docPath, aiExtensions)
|
||||||
|
|
||||||
|
c <- result{"ai", size}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Docker
|
||||||
|
go func() {
|
||||||
|
var size int64
|
||||||
|
// Docker Desktop Default WSL Data
|
||||||
|
dockerPath := filepath.Join(localAppData, "Docker", "wsl", "data", "ext4.vhdx")
|
||||||
|
info, err := os.Stat(dockerPath)
|
||||||
|
if err == nil {
|
||||||
|
size = info.Size()
|
||||||
|
}
|
||||||
|
c <- result{"docker", size}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Cache (Browser + System Temp)
|
||||||
|
go func() {
|
||||||
|
var size int64
|
||||||
|
// System Temp
|
||||||
|
size += GetDirectorySize(os.Getenv("TEMP"))
|
||||||
|
|
||||||
|
// Chrome Cache
|
||||||
|
size += GetDirectorySize(filepath.Join(localAppData, "Google", "Chrome", "User Data", "Default", "Cache"))
|
||||||
|
// Edge Cache
|
||||||
|
size += GetDirectorySize(filepath.Join(localAppData, "Microsoft", "Edge", "User Data", "Default", "Cache"))
|
||||||
|
// Brave Cache
|
||||||
|
size += GetDirectorySize(filepath.Join(localAppData, "BraveSoftware", "Brave-Browser", "User Data", "Default", "Cache"))
|
||||||
|
// Opera Cache
|
||||||
|
size += GetDirectorySize(filepath.Join(localAppData, "Opera Software", "Opera Stable", "Cache"))
|
||||||
|
// Firefox Cache
|
||||||
|
size += GetDirectorySize(filepath.Join(localAppData, "Mozilla", "Firefox", "Profiles")) // Scan all profiles for cache? Usually in Local/Mozilla/Firefox/Profiles/<profile>/cache2
|
||||||
|
|
||||||
|
// Firefox requires walking profiles in LocalAppData
|
||||||
|
mozPath := filepath.Join(localAppData, "Mozilla", "Firefox", "Profiles")
|
||||||
|
entries, _ := os.ReadDir(mozPath)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
size += GetDirectorySize(filepath.Join(mozPath, e.Name(), "cache2"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c <- result{"cache", size}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sizes := &CategorySizes{}
|
||||||
|
|
||||||
|
for i := 0; i < totalChecks; i++ {
|
||||||
|
res := <-c
|
||||||
|
switch res.name {
|
||||||
|
case "docs":
|
||||||
|
sizes.Documents = res.size
|
||||||
|
case "down":
|
||||||
|
sizes.Downloads = res.size
|
||||||
|
case "desk":
|
||||||
|
sizes.Desktop = res.size
|
||||||
|
case "music":
|
||||||
|
sizes.Music = res.size
|
||||||
|
case "movies":
|
||||||
|
sizes.Movies = res.size
|
||||||
|
case "temp":
|
||||||
|
// Keeping legacy System field for now, maybe map to part of Cache or System logs?
|
||||||
|
sizes.System = res.size
|
||||||
|
case "photos":
|
||||||
|
sizes.Photos = res.size
|
||||||
|
case "archives":
|
||||||
|
sizes.Archives = res.size
|
||||||
|
case "vms":
|
||||||
|
sizes.VirtualMachines = res.size
|
||||||
|
case "games":
|
||||||
|
sizes.Games = res.size
|
||||||
|
case "ai":
|
||||||
|
sizes.AI = res.size
|
||||||
|
case "docker":
|
||||||
|
sizes.Docker = res.size
|
||||||
|
case "cache":
|
||||||
|
sizes.Cache = res.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sizes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanExtensions walks a directory and sums up size of files with matching extensions
|
||||||
|
func ScanExtensions(root string, exts []string) int64 {
|
||||||
|
var total int64
|
||||||
|
extMap := make(map[string]bool)
|
||||||
|
for _, e := range exts {
|
||||||
|
extMap[strings.ToLower(e)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !d.IsDir() {
|
||||||
|
ext := strings.ToLower(filepath.Ext(d.Name()))
|
||||||
|
if extMap[ext] {
|
||||||
|
info, err := d.Info()
|
||||||
|
if err == nil {
|
||||||
|
total += info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCleaningEstimates() (*CleaningEstimates, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash Clean: Temp files
|
||||||
|
temp := filepath.Join(home, "AppData", "Local", "Temp")
|
||||||
|
|
||||||
|
// Deep Clean: Downloads
|
||||||
|
downloads := filepath.Join(home, "Downloads")
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
c := make(chan result)
|
||||||
|
|
||||||
|
go func() { c <- result{"temp", GetDirectorySize(temp)} }()
|
||||||
|
go func() { c <- result{"downloads", GetDirectorySize(downloads)} }()
|
||||||
|
|
||||||
|
estimates := &CleaningEstimates{}
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
res := <-c
|
||||||
|
switch res.name {
|
||||||
|
case "temp":
|
||||||
|
estimates.FlashEst = res.size
|
||||||
|
case "downloads":
|
||||||
|
estimates.DeepEst = res.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return estimates, nil
|
||||||
|
}
|
||||||
38
backend/internal/scanner/targets_darwin.go
Normal file
38
backend/internal/scanner/targets_darwin.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetScanTargets(category string) []string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
switch category {
|
||||||
|
case "apps":
|
||||||
|
return []string{"/Applications", filepath.Join(home, "Applications")}
|
||||||
|
case "photos":
|
||||||
|
return []string{filepath.Join(home, "Pictures")}
|
||||||
|
case "icloud":
|
||||||
|
return []string{filepath.Join(home, "Library", "Mobile Documents")}
|
||||||
|
case "docs":
|
||||||
|
return []string{filepath.Join(home, "Documents")}
|
||||||
|
case "downloads":
|
||||||
|
return []string{filepath.Join(home, "Downloads")}
|
||||||
|
case "desktop":
|
||||||
|
return []string{filepath.Join(home, "Desktop")}
|
||||||
|
case "music":
|
||||||
|
return []string{filepath.Join(home, "Music")}
|
||||||
|
case "movies":
|
||||||
|
return []string{filepath.Join(home, "Movies")}
|
||||||
|
case "system":
|
||||||
|
return []string{
|
||||||
|
filepath.Join(home, "Library", "Caches"),
|
||||||
|
filepath.Join(home, "Library", "Logs"),
|
||||||
|
filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData"),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
backend/internal/scanner/targets_windows.go
Normal file
90
backend/internal/scanner/targets_windows.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetScanTargets(category string) []string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
switch category {
|
||||||
|
case "apps":
|
||||||
|
// Windows apps are dispersed (Program Files), usually read-only. We don't file-scan them usually.
|
||||||
|
return []string{
|
||||||
|
os.Getenv("ProgramFiles"),
|
||||||
|
os.Getenv("ProgramFiles(x86)"),
|
||||||
|
filepath.Join(os.Getenv("LocalAppData"), "Programs"),
|
||||||
|
}
|
||||||
|
case "photos":
|
||||||
|
return []string{filepath.Join(home, "Pictures")}
|
||||||
|
case "icloud":
|
||||||
|
// iCloudDrive?
|
||||||
|
return []string{filepath.Join(home, "iCloudDrive")}
|
||||||
|
case "docs":
|
||||||
|
return []string{filepath.Join(home, "Documents")}
|
||||||
|
case "downloads":
|
||||||
|
return []string{filepath.Join(home, "Downloads")}
|
||||||
|
case "desktop":
|
||||||
|
return []string{filepath.Join(home, "Desktop")}
|
||||||
|
case "music":
|
||||||
|
return []string{filepath.Join(home, "Music")}
|
||||||
|
case "movies":
|
||||||
|
return []string{filepath.Join(home, "Videos")}
|
||||||
|
case "system":
|
||||||
|
return []string{
|
||||||
|
filepath.Join(home, "AppData", "Local", "Temp"),
|
||||||
|
filepath.Join(home, "AppData", "Local", "Microsoft", "Windows", "INetCache"), // IE/Edge cache
|
||||||
|
filepath.Join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Cache"),
|
||||||
|
filepath.Join(home, "AppData", "Local", "Mozilla", "Firefox", "Profiles"),
|
||||||
|
filepath.Join(home, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default", "Cache"),
|
||||||
|
filepath.Join(home, "AppData", "Local", "Opera Software", "Opera Stable", "Cache"),
|
||||||
|
}
|
||||||
|
case "cache":
|
||||||
|
return []string{
|
||||||
|
os.Getenv("TEMP"),
|
||||||
|
filepath.Join(home, "AppData", "Local", "Temp"),
|
||||||
|
filepath.Join(home, "AppData", "Local", "Microsoft", "Windows", "INetCache"),
|
||||||
|
filepath.Join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Cache"),
|
||||||
|
filepath.Join(home, "AppData", "Local", "Mozilla", "Firefox", "Profiles"),
|
||||||
|
filepath.Join(home, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default", "Cache"),
|
||||||
|
filepath.Join(home, "AppData", "Local", "Opera Software", "Opera Stable", "Cache"),
|
||||||
|
}
|
||||||
|
case "games":
|
||||||
|
return []string{
|
||||||
|
`C:\Program Files (x86)\Steam\steamapps\common`,
|
||||||
|
`C:\Program Files\Epic Games`,
|
||||||
|
`C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\games`,
|
||||||
|
`C:\Program Files\EA Games`,
|
||||||
|
filepath.Join(home, "AppData", "Roaming", ".minecraft"),
|
||||||
|
}
|
||||||
|
case "ai":
|
||||||
|
return []string{
|
||||||
|
`C:\ComfyUI`,
|
||||||
|
`C:\ai\ComfyUI`,
|
||||||
|
filepath.Join(home, "ComfyUI"),
|
||||||
|
filepath.Join(home, "stable-diffusion-webui"),
|
||||||
|
filepath.Join(home, "webui"),
|
||||||
|
filepath.Join(home, ".cache", "huggingface"),
|
||||||
|
filepath.Join(home, ".ollama", "models"),
|
||||||
|
filepath.Join(home, ".lmstudio", "models"),
|
||||||
|
}
|
||||||
|
case "docker":
|
||||||
|
return []string{
|
||||||
|
filepath.Join(os.Getenv("LocalAppData"), "Docker", "wsl", "data"),
|
||||||
|
}
|
||||||
|
case "archives":
|
||||||
|
// Archives usually scattered, but main ones in Downloads
|
||||||
|
return []string{
|
||||||
|
filepath.Join(home, "Downloads"),
|
||||||
|
filepath.Join(home, "Documents"),
|
||||||
|
}
|
||||||
|
case "vms":
|
||||||
|
return []string{
|
||||||
|
filepath.Join(home, "Downloads"),
|
||||||
|
filepath.Join(home, "Documents"),
|
||||||
|
filepath.Join(home, "VirtualBox VMs"),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
448
backend/main.go
Normal file
448
backend/main.go
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"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/platform"
|
||||||
|
"github.com/kv/clearnup/backend/internal/scanner"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var distFS embed.FS
|
||||||
|
|
||||||
|
const Port = ":36969"
|
||||||
|
|
||||||
|
func enableCors(w *http.ResponseWriter) {
|
||||||
|
(*w).Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
(*w).Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
|
||||||
|
(*w).Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
http.HandleFunc("/api/disk-usage", handleDiskUsage)
|
||||||
|
http.HandleFunc("/api/scan/user", handleScanUser)
|
||||||
|
http.HandleFunc("/api/scan/system", handleScanSystem) // Detailed list
|
||||||
|
http.HandleFunc("/api/scan/sizes", handleScanSizes) // Fast summary
|
||||||
|
http.HandleFunc("/api/scan/deepest", handleDeepestScan)
|
||||||
|
|
||||||
|
http.HandleFunc("/api/scan/category", handleScanCategory)
|
||||||
|
http.HandleFunc("/api/purge", handlePurge)
|
||||||
|
http.HandleFunc("/api/empty-trash", handleEmptyTrash)
|
||||||
|
http.HandleFunc("/api/clear-cache", handleClearCache)
|
||||||
|
http.HandleFunc("/api/clean-docker", handleCleanDocker)
|
||||||
|
http.HandleFunc("/api/system-info", handleSystemInfo)
|
||||||
|
http.HandleFunc("/api/estimates", handleCleaningEstimates)
|
||||||
|
|
||||||
|
// App Uninstaller
|
||||||
|
http.HandleFunc("/api/apps", handleScanApps)
|
||||||
|
http.HandleFunc("/api/apps/details", handleAppDetails)
|
||||||
|
http.HandleFunc("/api/apps/action", handleAppAction)
|
||||||
|
http.HandleFunc("/api/apps/uninstall", handleAppUninstall)
|
||||||
|
|
||||||
|
// Static File Serving (SPA Support)
|
||||||
|
// Check if we are running with embedded files or local dist
|
||||||
|
// Priority: Embedded (Production) -> Local dist (Dev/Preview)
|
||||||
|
|
||||||
|
// Try to get a sub-fs for "dist" from the embedded FS
|
||||||
|
distRoot, err := fs.Sub(distFS, "dist")
|
||||||
|
if err == nil {
|
||||||
|
fmt.Println("📂 Serving embedded static files")
|
||||||
|
// Check if it's actually populated (sanity check)
|
||||||
|
if _, err := distRoot.Open("index.html"); err == nil {
|
||||||
|
fsrv := http.FileServer(http.FS(distRoot))
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if filepath.Ext(r.URL.Path) == "" {
|
||||||
|
// Read index.html from embedded
|
||||||
|
index, _ := distRoot.Open("index.html")
|
||||||
|
stat, _ := index.Stat()
|
||||||
|
http.ServeContent(w, r, "index.html", stat.ModTime(), index.(io.ReadSeeker))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fsrv.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Fallback to local ./dist if embedded is empty (e.g. dev mode without build)
|
||||||
|
if _, err := os.Stat("dist"); err == nil {
|
||||||
|
fmt.Println("📂 Serving static files from local ./dist")
|
||||||
|
fs := http.FileServer(http.Dir("dist"))
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if filepath.Ext(r.URL.Path) == "" {
|
||||||
|
http.ServeFile(w, r, "dist/index.html")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("🚀 Antigravity Backend running on http://localhost%s\n", Port)
|
||||||
|
|
||||||
|
// Open Browser if not in development mode
|
||||||
|
if os.Getenv("APP_ENV") != "development" {
|
||||||
|
go platform.OpenBrowser("http://localhost" + Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(Port, nil); err != nil {
|
||||||
|
fmt.Printf("Server failed: %s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanRequest struct {
|
||||||
|
Category string `json:"category"` // "apps", "photos", "icloud", "docs", "system"
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScanCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ScanRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := scanner.GetScanTargets(req.Category)
|
||||||
|
if len(targets) == 0 {
|
||||||
|
json.NewEncoder(w).Encode([]scanner.ScanResult{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var allResults []scanner.ScanResult
|
||||||
|
for _, t := range targets {
|
||||||
|
if t == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res, _ := scanner.FindLargeFiles(t, 10*1024*1024) // 10MB
|
||||||
|
allResults = append(allResults, res...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
sort.Slice(allResults, func(i, j int) bool {
|
||||||
|
return allResults[i].Size > allResults[j].Size
|
||||||
|
})
|
||||||
|
if len(allResults) > 50 {
|
||||||
|
allResults = allResults[:50]
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(allResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleOpenSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := platform.OpenSettings(); err != nil {
|
||||||
|
fmt.Printf("Failed to open settings: %v\n", err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDiskUsage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := scanner.GetDiskUsage()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScanUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := scanner.ScanUserDocuments()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScanSizes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sizes, err := scanner.GetCategorySizes()
|
||||||
|
if err != nil {
|
||||||
|
// Log but return empty
|
||||||
|
fmt.Println("Size scan error:", err)
|
||||||
|
json.NewEncoder(w).Encode(map[string]int64{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(sizes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScanSystem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := scanner.ScanSystemData()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeepestScan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to Documents for now, or parse body for path
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
target := filepath.Join(home, "Documents")
|
||||||
|
|
||||||
|
folders, err := scanner.FindHeavyFolders(target)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurgeRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePurge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req PurgeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cleaner.PurgePath(req.Path); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to purge: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := platform.EmptyTrash(); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Cannot empty trash: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleClearCache(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath, err := platform.GetCachePath()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Cannot get cache path", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get size before clearing
|
||||||
|
sizeBefore := scanner.GetDirectorySize(cachePath)
|
||||||
|
|
||||||
|
// Clear cache directories (keep the Caches folder itself if possible, or jus remove content)
|
||||||
|
entries, err := os.ReadDir(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Cannot read cache directory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
itemPath := filepath.Join(cachePath, entry.Name())
|
||||||
|
os.RemoveAll(itemPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]int64{"cleared": sizeBefore})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCleanDocker(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerPath, err := platform.GetDockerPath()
|
||||||
|
if err != nil {
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"cleared": 0,
|
||||||
|
"message": "Docker not found",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run docker system prune -af --volumes to clean images, containers, and volumes
|
||||||
|
cmd := exec.Command(dockerPath, "system", "prune", "-af", "--volumes")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := platform.GetSystemInfo()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to get system info", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCleaningEstimates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
estimates, err := scanner.GetCleaningEstimates()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(estimates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// App Uninstaller Handlers
|
||||||
|
|
||||||
|
func handleScanApps(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appsList, err := apps.ScanApps()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(appsList)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAppDetails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppDetailsRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
BundleID string `json:"bundleID"`
|
||||||
|
}
|
||||||
|
var req AppDetailsRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
details, err := apps.GetAppDetails(req.Path, req.BundleID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAppAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Files []string `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := apps.DeleteFiles(req.Files); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to delete files: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAppUninstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
enableCors(&w)
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Cmd string `json:"cmd"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := apps.RunUninstaller(req.Cmd); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to launch uninstaller: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||||
|
}
|
||||||
BIN
backend/verify_output.txt
Normal file
BIN
backend/verify_output.txt
Normal file
Binary file not shown.
25
backend/verify_windows.ps1
Normal file
25
backend/verify_windows.ps1
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
$p = Start-Process -FilePath ".\kv-cleanup.exe" -PassThru -NoNewWindow
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-Host "`n=== Disk Usage ==="
|
||||||
|
$disk = Invoke-RestMethod -Uri "http://localhost:36969/api/disk-usage"
|
||||||
|
Write-Host "Total: $($disk.totalGB) GB, Free: $($disk.freeGB) GB"
|
||||||
|
|
||||||
|
Write-Host "`n=== System Info ==="
|
||||||
|
$sys = Invoke-RestMethod -Uri "http://localhost:36969/api/system-info"
|
||||||
|
Write-Host "OS: $($sys.os)"
|
||||||
|
Write-Host "Memory: $($sys.memory)"
|
||||||
|
|
||||||
|
Write-Host "`n=== Apps (First 3) ==="
|
||||||
|
$apps = Invoke-RestMethod -Uri "http://localhost:36969/api/apps"
|
||||||
|
$apps | Select-Object -First 3 | Format-Table Name, Path
|
||||||
|
|
||||||
|
Write-Host "`n=== Scan Downloads ==="
|
||||||
|
$scan = Invoke-RestMethod -Uri "http://localhost:36969/api/scan/category" -Method Post -Body '{"category": "downloads"}' -ContentType "application/json"
|
||||||
|
$scan | Select-Object -First 3 | Format-Table Path, Size
|
||||||
|
} catch {
|
||||||
|
Write-Host "Error: $_"
|
||||||
|
} finally {
|
||||||
|
Stop-Process -Id $p.Id -Force
|
||||||
|
}
|
||||||
62
build-release.ps1
Normal file
62
build-release.ps1
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# build-release.ps1
|
||||||
|
# Builds a portable SINGLE-FILE release for Windows and Mac
|
||||||
|
|
||||||
|
Write-Host "Starting Portable Release Build..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 1. Clean previous build
|
||||||
|
if (Test-Path "Release") { Remove-Item "Release" -Recurse -Force }
|
||||||
|
if (Test-Path "backend\dist") { Remove-Item "backend\dist" -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Force -Path "Release" | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path "Release\Windows" | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path "Release\Mac" | Out-Null
|
||||||
|
|
||||||
|
# 2. Build Frontend
|
||||||
|
Write-Host "Building Frontend (Vite)..." -ForegroundColor Yellow
|
||||||
|
$pkgManager = "pnpm"
|
||||||
|
if (-not (Get-Command "pnpm" -ErrorAction SilentlyContinue)) { $pkgManager = "npm" }
|
||||||
|
|
||||||
|
Invoke-Expression "$pkgManager install"
|
||||||
|
Invoke-Expression "$pkgManager run build"
|
||||||
|
|
||||||
|
if (-not (Test-Path "dist")) {
|
||||||
|
Write-Host "Frontend build failed: 'dist' folder not found." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Move dist to backend/dist (for embedding)
|
||||||
|
Write-Host "Moving frontend to backend for embedding..." -ForegroundColor Cyan
|
||||||
|
Copy-Item -Path "dist" -Destination "backend\dist" -Recurse
|
||||||
|
|
||||||
|
# 4. Build Backend
|
||||||
|
Write-Host "Building Backend..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Windows Build
|
||||||
|
Write-Host " Windows (amd64)..." -ForegroundColor Cyan
|
||||||
|
$env:GOOS = "windows"; $env:GOARCH = "amd64"
|
||||||
|
go build -ldflags "-s -w -H=windowsgui" -o "Release\Windows\Antigravity.exe" backend/main.go
|
||||||
|
|
||||||
|
# Mac Build (Cross-compile)
|
||||||
|
Write-Host " macOS (amd64 & arm64)..." -ForegroundColor Cyan
|
||||||
|
$env:GOOS = "darwin"; $env:GOARCH = "amd64"
|
||||||
|
go build -ldflags "-s -w" -o "Release\Mac\Antigravity-Intel" backend/main.go
|
||||||
|
|
||||||
|
$env:GOARCH = "arm64"
|
||||||
|
go build -ldflags "-s -w" -o "Release\Mac\Antigravity-AppleSilicon" backend/main.go
|
||||||
|
|
||||||
|
# Cleanup backend/dist
|
||||||
|
Remove-Item "backend\dist" -Recurse -Force
|
||||||
|
|
||||||
|
# 5. Success Message & Zipping
|
||||||
|
Write-Host "Build Complete!" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Zip Windows
|
||||||
|
if (Test-Path "Release\Antigravity-Windows.zip") { Remove-Item "Release\Antigravity-Windows.zip" }
|
||||||
|
Compress-Archive -Path "Release\Windows\*" -DestinationPath "Release\Antigravity-Windows.zip" -Force
|
||||||
|
Write-Host "Created Windows Zip: Release\Antigravity-Windows.zip" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Zip Mac
|
||||||
|
if (Test-Path "Release\Antigravity-Mac.zip") { Remove-Item "Release\Antigravity-Mac.zip" }
|
||||||
|
Compress-Archive -Path "Release\Mac\*" -DestinationPath "Release\Antigravity-Mac.zip" -Force
|
||||||
|
Write-Host "Created Mac Zip: Release\Antigravity-Mac.zip" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host "Artifacts are in the 'Release' folder." -ForegroundColor White
|
||||||
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 416 KiB |
141
electron/features/cleaner.ts
Normal file
141
electron/features/cleaner.ts
Normal 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
Normal file
45
electron/features/enforcer.ts
Normal 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
Normal file
191
electron/features/scanner.ts
Normal 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
Normal file
43
electron/features/updater.ts
Normal 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
Normal file
244
electron/main.ts
Normal 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
Normal file
18
electron/preload.ts
Normal 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
Normal file
14
electron/tsconfig.json
Normal 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
Normal file
23
eslint.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
module github.com/kv/clearnup
|
||||||
|
|
||||||
|
go 1.25.4
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.40.0 // indirect
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
13
index.html
Normal file
13
index.html
Normal 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
Normal file
81
package.json
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
{
|
||||||
|
"name": "Lumina",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.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": "pnpm run build:go:mac && pnpm run build && pnpm 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.3.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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5108
pnpm-lock.yaml
Normal file
5108
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
7
postcss.config.js
Normal file
7
postcss.config.js
Normal 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
Normal file
1
public/vite.svg
Normal 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 |
16
scripts/build-electron.mjs
Normal file
16
scripts/build-electron.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import * as esbuild from 'esbuild';
|
||||||
|
|
||||||
|
console.log('Building Electron Main and Preload...');
|
||||||
|
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: ['electron/main.ts', 'electron/preload.ts'],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node18',
|
||||||
|
external: ['electron'], // Do not bundle electron dependency
|
||||||
|
outdir: 'dist-electron',
|
||||||
|
format: 'cjs',
|
||||||
|
outExtension: { '.js': '.cjs' }, // Output .cjs to avoid conflict with package.json "type": "module"
|
||||||
|
})
|
||||||
|
.then(() => console.log('Build complete.'))
|
||||||
|
.catch(() => process.exit(1));
|
||||||
22
scripts/build-go.sh
Normal file
22
scripts/build-go.sh
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/bin/bash
|
||||||
|
echo "📦 Building Backend for Universal Binaries..."
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
mkdir -p backend/dist/amd64
|
||||||
|
mkdir -p backend/dist/arm64
|
||||||
|
mkdir -p backend/dist/universal
|
||||||
|
|
||||||
|
# Build for AMD64
|
||||||
|
# Build for AMD64
|
||||||
|
echo "⚙️ Building for AMD64..."
|
||||||
|
GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o backend/dist/amd64/backend backend/main.go
|
||||||
|
|
||||||
|
# Build for ARM64
|
||||||
|
echo "⚙️ Building for ARM64..."
|
||||||
|
GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o backend/dist/arm64/backend backend/main.go
|
||||||
|
|
||||||
|
# Create Universal Binary
|
||||||
|
echo "🔄 Creating Universal Binary..."
|
||||||
|
lipo -create -output backend/dist/universal/backend backend/dist/amd64/backend backend/dist/arm64/backend
|
||||||
|
|
||||||
|
echo "✅ Backend build complete: backend/dist/universal/backend"
|
||||||
13
scripts/check-pnpm.js
Normal file
13
scripts/check-pnpm.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const agent = process.env.npm_config_user_agent;
|
||||||
|
const execPath = process.env.npm_execpath;
|
||||||
|
|
||||||
|
// Allow 'bun' based on our bootstrapping, but prefer pnpm.
|
||||||
|
// Since we are running pnpm via bun, the user agent likely contains pnpm.
|
||||||
|
if (!agent || !agent.includes('pnpm')) {
|
||||||
|
console.error('\u001b[31m\nError: You must use pnpm to install dependencies in this project.\n\u001b[0m');
|
||||||
|
console.error(`Current agent: ${agent || 'unknown'}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
42
src/App.css
Normal file
42
src/App.css
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
163
src/App.tsx
Normal file
163
src/App.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { LayoutGrid, Package, Zap, Moon, Sun, Menu, ChevronLeft } from 'lucide-react';
|
||||||
|
import { Dashboard } from './components/Dashboard';
|
||||||
|
import { AppsView } from './components/Uninstaller/AppsView';
|
||||||
|
import { ToastProvider } from './components/ui';
|
||||||
|
|
||||||
|
type View = 'dashboard' | 'apps';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [currentView, setCurrentView] = useState<View>('dashboard');
|
||||||
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check local storage or system preference
|
||||||
|
const storedTheme = localStorage.getItem('theme');
|
||||||
|
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
setIsDark(true);
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
setIsDark(false);
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newTheme = !isDark;
|
||||||
|
setIsDark(newTheme);
|
||||||
|
if (newTheme) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<div className="h-screen w-screen bg-gray-50 dark:bg-[#0f0f0f] overflow-hidden font-[-apple-system,BlinkMacSystemFont,'Inter','Segoe_UI',Roboto,Helvetica,Arial,sans-serif] flex text-gray-900 dark:text-white transition-colors duration-300">
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div
|
||||||
|
className={`shrink-0 bg-white dark:bg-white/5 border-r border-gray-200 dark:border-white/10 flex flex-col transition-all duration-300 ease-in-out backdrop-blur-xl z-20
|
||||||
|
${isSidebarOpen ? 'w-64' : 'w-20'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Header & Toggle */}
|
||||||
|
<div className={`flex items-center gap-3 px-4 mb-8 mt-4 ${isSidebarOpen ? 'justify-between' : 'justify-center'}`}>
|
||||||
|
{isSidebarOpen ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-blue-500 to-purple-500 flex items-center justify-center text-white shrink-0 shadow-lg">
|
||||||
|
<Zap className="w-5 h-5 fill-current" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-xl tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 whitespace-nowrap">
|
||||||
|
Antigravity
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-blue-500 to-purple-500 flex items-center justify-center text-white shrink-0 shadow-lg cursor-pointer" onClick={() => setIsSidebarOpen(true)}>
|
||||||
|
<Zap className="w-5 h-5 fill-current" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toggle Button */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="space-y-2 flex-1 px-3">
|
||||||
|
<NavItem
|
||||||
|
icon={<LayoutGrid className="w-5 h-5" />}
|
||||||
|
label="Dashboard"
|
||||||
|
isActive={currentView === 'dashboard'}
|
||||||
|
onClick={() => setCurrentView('dashboard')}
|
||||||
|
isCollapsed={!isSidebarOpen}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
icon={<Package className="w-5 h-5" />}
|
||||||
|
label="Uninstaller"
|
||||||
|
isActive={currentView === 'apps'}
|
||||||
|
onClick={() => setCurrentView('apps')}
|
||||||
|
isCollapsed={!isSidebarOpen}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="mt-auto space-y-4 px-3 mb-4">
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/10 rounded-xl transition-colors ${!isSidebarOpen && 'justify-center'}`}
|
||||||
|
title={isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<span className="font-medium text-sm whitespace-nowrap">
|
||||||
|
{isDark ? 'Light Mode' : 'Dark Mode'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hamburger / Menu Expand Button (If collapsed) */}
|
||||||
|
{!isSidebarOpen && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
className="w-full flex items-center justify-center p-2 text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div className="pt-4 border-t border-gray-200 dark:border-white/10 mx-2">
|
||||||
|
<p className="text-[10px] text-gray-500 font-medium">
|
||||||
|
v0.1.0 Beta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 min-w-0 h-full overflow-hidden bg-white/50 dark:bg-transparent relative transition-all">
|
||||||
|
{/* Mobile Sidebar Overlay Trigger? No, focusing on desktop sidebar slide first as per Electron app context */}
|
||||||
|
<div className="absolute inset-0 overflow-auto">
|
||||||
|
{currentView === 'dashboard' ? <Dashboard /> : <AppsView />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({ icon, label, isActive, onClick, isCollapsed }: { icon: React.ReactNode, label: string, isActive: boolean, onClick: () => void, isCollapsed: boolean }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200 group ${isActive
|
||||||
|
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/25'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/10 hover:text-gray-900 dark:hover:text-white'
|
||||||
|
} ${isCollapsed ? 'justify-center' : ''}`}
|
||||||
|
title={isCollapsed ? label : undefined}
|
||||||
|
>
|
||||||
|
<div className={`shrink-0 transition-transform duration-200 ${isActive ? 'scale-110' : 'group-hover:scale-110'}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="font-medium text-sm whitespace-nowrap animate-in fade-in slide-in-from-left-2 duration-200">{label}</span>
|
||||||
|
)}
|
||||||
|
{isActive && !isCollapsed && <div className="ml-auto w-1.5 h-1.5 rounded-full bg-white animate-pulse" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
207
src/api/client.ts
Normal file
207
src/api/client.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
const API_BASE = "http://localhost:36969/api";
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
isDirectory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiskUsage {
|
||||||
|
name: string;
|
||||||
|
totalGB: string;
|
||||||
|
usedGB: string;
|
||||||
|
freeGB: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategorySizes {
|
||||||
|
documents: number;
|
||||||
|
downloads: number;
|
||||||
|
desktop: number;
|
||||||
|
music: number;
|
||||||
|
movies: number;
|
||||||
|
system: number;
|
||||||
|
trash: number;
|
||||||
|
apps: number;
|
||||||
|
photos: number;
|
||||||
|
icloud: number;
|
||||||
|
archives?: number;
|
||||||
|
virtual_machines?: number;
|
||||||
|
games?: number;
|
||||||
|
ai?: number;
|
||||||
|
docker?: number;
|
||||||
|
cache?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemInfo {
|
||||||
|
model: string;
|
||||||
|
chip: string;
|
||||||
|
memory: string;
|
||||||
|
os: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningEstimates {
|
||||||
|
flash_est: number;
|
||||||
|
deep_est: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstaller Types
|
||||||
|
export interface AppInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
bundleID: string;
|
||||||
|
uninstallString?: string;
|
||||||
|
size: number;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssociatedFile {
|
||||||
|
path: string;
|
||||||
|
type: 'cache' | 'config' | 'log' | 'data' | 'registry';
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppDetails extends AppInfo {
|
||||||
|
associated: AssociatedFile[];
|
||||||
|
totalSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API = {
|
||||||
|
getDiskUsage: async (): Promise<DiskUsage[] | null> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/disk-usage`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch disk usage");
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scanCategory: async (category: string): Promise<ScanResult[]> => {
|
||||||
|
const res = await fetch(`${API_BASE}/scan/category`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ category })
|
||||||
|
});
|
||||||
|
return await res.json() || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
openSettings: async () => {
|
||||||
|
await fetch(`${API_BASE}/open-settings`, { method: "POST" });
|
||||||
|
},
|
||||||
|
|
||||||
|
getCategorySizes: async (): Promise<CategorySizes> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/scan/sizes?t=${Date.now()}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return { documents: 0, downloads: 0, desktop: 0, music: 0, movies: 0, system: 0, trash: 0, apps: 0, photos: 0, icloud: 0 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deepestScan: async (): Promise<ScanResult[]> => {
|
||||||
|
const res = await fetch(`${API_BASE}/scan/deepest`, { method: "POST" });
|
||||||
|
return await res.json() || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
purgePath: async (path: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/purge`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emptyTrash: async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/empty-trash`, { method: "POST" });
|
||||||
|
return res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCache: async (): Promise<{ cleared: number }> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/clear-cache`, { method: "POST" });
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return { cleared: 0 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanDocker: async (): Promise<{ cleared: number; message: string }> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/clean-docker`, { method: "POST" });
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return { cleared: 0, message: "Docker cleanup failed" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSystemInfo: async (): Promise<SystemInfo | null> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/system-info`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getEstimates: async (): Promise<CleaningEstimates | null> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/estimates`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Uninstaller APIs
|
||||||
|
getApps: async (): Promise<AppInfo[]> => {
|
||||||
|
const res = await fetch(`${API_BASE}/apps`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
getAppDetails: async (path: string, bundleID?: string): Promise<AppDetails> => {
|
||||||
|
const res = await fetch(`${API_BASE}/apps/details`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path, bundleID }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAppFiles: async (files: string[]): Promise<void> => {
|
||||||
|
const res = await fetch(`${API_BASE}/apps/action`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ files }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete files");
|
||||||
|
},
|
||||||
|
|
||||||
|
uninstallApp: async (cmd: string): Promise<void> => {
|
||||||
|
const res = await fetch(`${API_BASE}/apps/uninstall`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ cmd }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to launch uninstaller");
|
||||||
|
},
|
||||||
|
|
||||||
|
getAppIcon: async (path: string): Promise<string> => {
|
||||||
|
// Fallback or use Electron bridge directly
|
||||||
|
if (window.electronAPI?.getAppIcon) {
|
||||||
|
return window.electronAPI.getAppIcon(path);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4 KiB |
1230
src/components/Dashboard.tsx
Normal file
1230
src/components/Dashboard.tsx
Normal file
File diff suppressed because it is too large
Load diff
73
src/components/Layout.tsx
Normal file
73
src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type Page = 'dashboard' | 'settings';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
currentPage: Page;
|
||||||
|
onNavigate: (page: Page) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ children, currentPage, onNavigate }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-neutral-900 text-white overflow-hidden font-sans">
|
||||||
|
<aside className="w-64 bg-neutral-950 border-r border-neutral-800 flex flex-col pt-10">
|
||||||
|
<div className="px-6 mb-8">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-br from-indigo-400 to-purple-400 bg-clip-text text-transparent">
|
||||||
|
Antigravity
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">System Optimizer</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 px-4 space-y-1">
|
||||||
|
<NavItem
|
||||||
|
isActive={currentPage === 'dashboard'}
|
||||||
|
onClick={() => onNavigate('dashboard')}
|
||||||
|
icon="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||||
|
label="Dashboard"
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
isActive={currentPage === 'settings'}
|
||||||
|
onClick={() => onNavigate('settings')}
|
||||||
|
icon="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||||
|
label="Settings"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-neutral-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-indigo-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400 text-xs font-bold">
|
||||||
|
KV
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-neutral-200">Dev Mode</div>
|
||||||
|
<div className="text-xs text-neutral-500">v0.1.0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 bg-neutral-900 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({ isActive, onClick, icon, label }: any) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200
|
||||||
|
${isActive
|
||||||
|
? 'bg-white/10 text-white shadow-sm'
|
||||||
|
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 opacity-70" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={icon} />
|
||||||
|
</svg>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/Settings.tsx
Normal file
82
src/components/Settings.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
const handleClean = async () => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI.cleanSystem();
|
||||||
|
alert('System cleaned!');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisableUpdates = async () => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI.disableUpdates();
|
||||||
|
alert('Updates disabled!');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h2 className="text-2xl font-semibold text-white">Stasis Field & Force</h2>
|
||||||
|
<p className="text-neutral-400 mt-1">Configure system protections and enforcement policies.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-6 max-w-3xl">
|
||||||
|
<Section title="The pnpm Force" description="Enforce strict package manager usage to save disk space.">
|
||||||
|
<Toggle label="Monitor Active Projects" description="Scan for package-lock.json creation in real-time" defaultChecked />
|
||||||
|
<Toggle label="Auto-Delete Illegal Lockfiles" description="Immediately remove npm/yarn lockfiles without warning" color="peer-checked:bg-red-600" />
|
||||||
|
<Toggle label="Shell Alias Injection" description="Inject 'npm install -> pnpm install' alias into .zshrc" defaultChecked />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Stasis Field" description="Prevent macOS from updating automatically.">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Toggle label="Disable Auto-Update Check" description="Prevent system from checking for new macOS versions" defaultChecked />
|
||||||
|
<button onClick={handleDisableUpdates} className="px-3 py-1 bg-red-600/20 text-red-400 hover:bg-red-600/30 rounded text-xs font-medium border border-red-600/20">Apply Block</button>
|
||||||
|
</div>
|
||||||
|
<Toggle label="Suppress System Badge" description="Hide the red notification badge on System Settings" />
|
||||||
|
<Toggle label="Ignore Major Version Updates" description="Block macOS Sequoia/Sonoma upgrades" defaultChecked />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Trash Compactor" description="System sanitation settings.">
|
||||||
|
<Toggle label="Force Empty Trash" description="Bypass 'File in Use' errors when emptying trash" />
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm font-medium text-neutral-200">System Caches</span>
|
||||||
|
<button onClick={handleClean} className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 rounded text-sm text-white transition-colors">Clean Now</button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, description, children }: any) {
|
||||||
|
return (
|
||||||
|
<div className="bg-neutral-800/30 border border-neutral-700/50 rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-medium text-white mb-1">{title}</h3>
|
||||||
|
<p className="text-sm text-neutral-500 mb-6">{description}</p>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ label, description, defaultChecked, color = "peer-checked:bg-indigo-600" }: any) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-start cursor-pointer group">
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input type="checkbox" className="sr-only peer" defaultChecked={defaultChecked} />
|
||||||
|
<div className={`w-11 h-6 bg-neutral-700 peer-focus:outline-none rounded-full peer ${color} peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all border-gray-600`}></div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<span className="block text-sm font-medium text-neutral-200 group-hover:text-white transition-colors">{label}</span>
|
||||||
|
{description && <span className="block text-xs text-neutral-500">{description}</span>}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
328
src/components/Uninstaller/AppDetails.tsx
Normal file
328
src/components/Uninstaller/AppDetails.tsx
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ArrowLeft, Trash2, RefreshCw, Eraser, FileText, Settings, Database, Folder, AlertTriangle } from 'lucide-react';
|
||||||
|
import { API } from '../../api/client';
|
||||||
|
import type { AppInfo, AppDetails } from '../../api/client';
|
||||||
|
import { GlassCard } from '../ui/GlassCard';
|
||||||
|
import { GlassButton } from '../ui/GlassButton';
|
||||||
|
import { useToast } from '../ui/Toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
app: AppInfo;
|
||||||
|
onBack: () => void;
|
||||||
|
onUninstall: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppDetailsView({ app, onBack, onUninstall }: Props) {
|
||||||
|
const [details, setDetails] = useState<AppDetails | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDetails();
|
||||||
|
}, [app.path]);
|
||||||
|
|
||||||
|
const loadDetails = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await API.getAppDetails(app.path, app.bundleID);
|
||||||
|
setDetails(data);
|
||||||
|
|
||||||
|
// Select all by default
|
||||||
|
const allFiles = new Set<string>();
|
||||||
|
allFiles.add(data.path); // Main app bundle
|
||||||
|
data.associated.forEach(f => allFiles.add(f.path));
|
||||||
|
setSelectedFiles(allFiles);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.addToast({ type: 'error', title: 'Error', message: 'Failed to load app details' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFile = (path: string) => {
|
||||||
|
const newSet = new Set(selectedFiles);
|
||||||
|
if (newSet.has(path)) {
|
||||||
|
newSet.delete(path);
|
||||||
|
} else {
|
||||||
|
newSet.add(path);
|
||||||
|
}
|
||||||
|
setSelectedFiles(newSet);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconForType = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'config': return <Settings className="w-4 h-4 text-gray-400" />;
|
||||||
|
case 'cache': return <Database className="w-4 h-4 text-yellow-400" />;
|
||||||
|
case 'log': return <FileText className="w-4 h-4 text-blue-400" />;
|
||||||
|
case 'registry': return <Settings className="w-4 h-4 text-purple-400" />;
|
||||||
|
default: return <Folder className="w-4 h-4 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (action: 'uninstall' | 'reset' | 'cache') => {
|
||||||
|
if (!details) return;
|
||||||
|
|
||||||
|
// Special handling for Uninstall with official uninstaller
|
||||||
|
if (action === 'uninstall' && details.uninstallString) {
|
||||||
|
const confirmed = await toast.confirm(
|
||||||
|
`Run Uninstaller?`,
|
||||||
|
`This will launch the official uninstaller for ${details.name}.`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProcessing(true);
|
||||||
|
await API.uninstallApp(details.uninstallString);
|
||||||
|
toast.addToast({ type: 'success', title: 'Success', message: 'Uninstaller launched' });
|
||||||
|
// We don't automatically close the view or reload apps because the uninstaller is external
|
||||||
|
// But we can go back
|
||||||
|
onBack();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.addToast({ type: 'error', title: 'Error', message: 'Failed to launch uninstaller' });
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filesToDelete: string[] = [];
|
||||||
|
|
||||||
|
if (action === 'uninstall') {
|
||||||
|
filesToDelete = Array.from(selectedFiles);
|
||||||
|
} else if (action === 'reset') {
|
||||||
|
// Reset: Config + Data only, keep App
|
||||||
|
filesToDelete = details.associated
|
||||||
|
.filter(f => f.type === 'config' || f.type === 'data')
|
||||||
|
.map(f => f.path);
|
||||||
|
} else if (action === 'cache') {
|
||||||
|
// Cache: Caches + Logs
|
||||||
|
filesToDelete = details.associated
|
||||||
|
.filter(f => f.type === 'cache' || f.type === 'log')
|
||||||
|
.map(f => f.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesToDelete.length === 0) {
|
||||||
|
toast.addToast({ type: 'info', title: 'Info', message: 'No files selected for this action' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation (Simple browser confirm for now, better UI later)
|
||||||
|
const confirmed = await toast.confirm(
|
||||||
|
`Delete ${filesToDelete.length} items?`,
|
||||||
|
'This cannot be undone.'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setProcessing(true);
|
||||||
|
await API.deleteAppFiles(filesToDelete);
|
||||||
|
toast.addToast({ type: 'success', title: 'Success', message: 'Cleaned up successfully' });
|
||||||
|
if (action === 'uninstall') {
|
||||||
|
onUninstall();
|
||||||
|
} else {
|
||||||
|
loadDetails(); // Reload to show remaining files
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.addToast({ type: 'error', title: 'Error', message: 'Failed to delete files' });
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !details) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-4" />
|
||||||
|
<p>Analyzing application structure...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSelectedSize = Array.from(selectedFiles).reduce((acc, path) => {
|
||||||
|
if (path === details.path) return acc + details.size;
|
||||||
|
const assoc = details.associated.find(f => f.path === path);
|
||||||
|
return acc + (assoc ? assoc.size : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-6xl mx-auto p-6 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<header className="flex items-center gap-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="p-2.5 bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{details.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm font-mono mt-1">{details.bundleID}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* File List */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<GlassCard className="overflow-hidden">
|
||||||
|
<div className="p-5 border-b border-gray-100 dark:border-white/5 flex items-center justify-between bg-gray-50/50 dark:bg-white/5">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-200">Application Bundle & Data</span>
|
||||||
|
<span className="text-sm font-medium px-3 py-1 rounded-full bg-blue-100/50 dark:bg-blue-500/20 text-blue-600 dark:text-blue-300">
|
||||||
|
{formatSize(totalSelectedSize)} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-1">
|
||||||
|
{/* Main App */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer border ${selectedFiles.has(details.path)
|
||||||
|
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/30'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-white/5 border-transparent'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleFile(details.path)}
|
||||||
|
>
|
||||||
|
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${selectedFiles.has(details.path)
|
||||||
|
? 'bg-blue-500 border-blue-500 text-white'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}`}>
|
||||||
|
{selectedFiles.has(details.path) && <Folder className="w-3 h-3" />}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded-lg bg-gray-100 dark:bg-white/5 text-gray-500 dark:text-gray-400">
|
||||||
|
<PackageIcon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">Application Bundle</div>
|
||||||
|
<div className="text-xs text-gray-500 truncate mt-0.5">{details.path}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono font-medium text-gray-600 dark:text-gray-400">{formatSize(details.size)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Associated Files */}
|
||||||
|
{details.associated.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.path}
|
||||||
|
className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer border ${selectedFiles.has(file.path)
|
||||||
|
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/30'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-white/5 border-transparent'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleFile(file.path)}
|
||||||
|
>
|
||||||
|
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${selectedFiles.has(file.path)
|
||||||
|
? 'bg-blue-500 border-blue-500 text-white'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}`}>
|
||||||
|
{selectedFiles.has(file.path) && <Folder className="w-3 h-3" />}
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded-lg bg-gray-100 dark:bg-white/5">
|
||||||
|
{getIconForType(file.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 capitalize">{file.type}</div>
|
||||||
|
<div className="text-xs text-gray-500 truncate mt-0.5">{file.path}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono font-medium text-gray-600 dark:text-gray-400">{formatSize(file.size)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<GlassCard className="p-5 space-y-5">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-gray-200">Cleanup Actions</h3>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
variant="danger"
|
||||||
|
className="w-full justify-start gap-4 p-4 h-auto"
|
||||||
|
onClick={() => handleAction('uninstall')}
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
<div className="p-2 bg-white/20 rounded-lg">
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start text-left">
|
||||||
|
<span className="font-semibold text-base">Uninstall</span>
|
||||||
|
<span className="text-xs opacity-90 font-normal">Remove {selectedFiles.size} selected items</span>
|
||||||
|
</div>
|
||||||
|
</GlassButton>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100 dark:bg-white/10 my-2" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('reset')}
|
||||||
|
disabled={processing}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-white/10 group"
|
||||||
|
>
|
||||||
|
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 group-hover:bg-blue-100 dark:group-hover:bg-blue-500/20 transition-colors">
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start text-left">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-200 text-sm">Reset Application</span>
|
||||||
|
<span className="text-xs text-gray-500">Delete config & data only</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('cache')}
|
||||||
|
disabled={processing}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-white/5 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-white/10 group"
|
||||||
|
>
|
||||||
|
<div className="p-2 rounded-lg bg-orange-50 dark:bg-orange-500/10 text-orange-600 dark:text-orange-400 group-hover:bg-orange-100 dark:group-hover:bg-orange-500/20 transition-colors">
|
||||||
|
<Eraser className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start text-left">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-200 text-sm">Clear Cache</span>
|
||||||
|
<span className="text-xs text-gray-500">Remove temporary files</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 flex gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-yellow-800 dark:text-yellow-200/80 leading-relaxed font-medium">
|
||||||
|
Deleted files cannot be recovered. Ensure you have backups of important data before uninstalling applications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="m16.5 9.4-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
212
src/components/Uninstaller/AppsView.tsx
Normal file
212
src/components/Uninstaller/AppsView.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Search, Package, LayoutGrid, List, ArrowUpDown } from 'lucide-react';
|
||||||
|
import { API } from '../../api/client';
|
||||||
|
import type { AppInfo } from '../../api/client';
|
||||||
|
import { GlassCard } from '../ui/GlassCard';
|
||||||
|
import { AppDetailsView } from './AppDetails';
|
||||||
|
|
||||||
|
export function AppsView() {
|
||||||
|
const [apps, setApps] = useState<AppInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
|
const [sortMode, setSortMode] = useState<'size' | 'name'>('size');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadApps();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadApps = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await API.getApps();
|
||||||
|
// Initial load - sort logic handled in render/memo usually but let's just set raw data
|
||||||
|
// Populate icons
|
||||||
|
const appsWithIcons = await Promise.all(data.map(async (app) => {
|
||||||
|
const icon = await API.getAppIcon(app.path);
|
||||||
|
return { ...app, icon };
|
||||||
|
}));
|
||||||
|
|
||||||
|
setApps(appsWithIcons);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedApps = [...apps].filter(app =>
|
||||||
|
app.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
).sort((a, b) => {
|
||||||
|
if (sortMode === 'size') {
|
||||||
|
return b.size - a.size;
|
||||||
|
} else {
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedApp) {
|
||||||
|
return (
|
||||||
|
<AppDetailsView
|
||||||
|
app={selectedApp}
|
||||||
|
onBack={() => setSelectedApp(null)}
|
||||||
|
onUninstall={() => {
|
||||||
|
loadApps();
|
||||||
|
setSelectedApp(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-7xl mx-auto p-6 h-full flex flex-col">
|
||||||
|
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4 shrink-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-400 dark:from-blue-300 dark:to-purple-300">
|
||||||
|
App Uninstaller
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mt-1">Scan and remove applications completely</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex items-center bg-gray-100 dark:bg-white/5 rounded-lg p-1 border border-gray-200 dark:border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-2 rounded-md transition-all ${viewMode === 'grid' ? 'bg-white dark:bg-white/10 shadow-sm text-blue-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`p-2 rounded-md transition-all ${viewMode === 'list' ? 'bg-white dark:bg-white/10 shadow-sm text-blue-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Dropdown (Simple Toggle for now) */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSortMode(prev => prev === 'size' ? 'name' : 'size')}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="w-4 h-4" />
|
||||||
|
<span>Sort by {sortMode === 'size' ? 'Size' : 'Name'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative w-full md:w-64">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search apps..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-white/50 dark:bg-black/20 border border-gray-200 dark:border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500/50 transition-all text-gray-900 dark:text-white placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-32 text-gray-400">
|
||||||
|
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-4" />
|
||||||
|
<p className="font-medium">Scanning applications...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-y-auto pr-2 -mr-2">
|
||||||
|
{viewMode === 'grid' ? (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 pb-10">
|
||||||
|
<AnimatePresence>
|
||||||
|
{sortedApps.map((app) => (
|
||||||
|
<motion.div
|
||||||
|
key={app.bundleID}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
layout
|
||||||
|
>
|
||||||
|
<GlassCard
|
||||||
|
className="group hover:bg-white/80 dark:hover:bg-white/5 transition-all duration-300 cursor-pointer h-full border border-transparent hover:border-blue-500/20 hover:shadow-xl hover:shadow-blue-500/5 hover:-translate-y-1"
|
||||||
|
onClick={() => setSelectedApp(app)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center p-5 text-center h-full">
|
||||||
|
<div className="w-16 h-16 mb-4 relative">
|
||||||
|
{app.icon ? (
|
||||||
|
<img src={app.icon} alt={app.name} className="w-full h-full object-contain drop-shadow-md" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full rounded-xl bg-gradient-to-br from-blue-500/10 to-purple-500/10 flex items-center justify-center text-blue-400">
|
||||||
|
<Package className="w-8 h-8 opacity-50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-1 w-full text-sm md:text-base" title={app.name}>
|
||||||
|
{app.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] md:text-xs text-gray-500 dark:text-gray-400 font-mono mb-3 bg-gray-100 dark:bg-white/5 px-2 py-0.5 rounded-full">
|
||||||
|
{formatSize(app.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// List View Logic
|
||||||
|
<div className="flex flex-col gap-2 pb-10">
|
||||||
|
{sortedApps.map((app) => (
|
||||||
|
<motion.div
|
||||||
|
key={app.bundleID}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="group flex items-center gap-4 p-3 rounded-xl hover:bg-white/80 dark:hover:bg-white/5 border border-transparent hover:border-gray-200 dark:hover:border-white/10 transition-all cursor-pointer"
|
||||||
|
onClick={() => setSelectedApp(app)}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 shrink-0">
|
||||||
|
{app.icon ? (
|
||||||
|
<img src={app.icon} alt={app.name} className="w-full h-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||||
|
<Package className="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm md:text-base">{app.name}</h3>
|
||||||
|
<p className="text-[11px] md:text-xs text-gray-500 dark:text-gray-400 truncate">{app.path}</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-xs md:text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||||
|
{formatSize(app.size)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sortedApps.length === 0 && (
|
||||||
|
<div className="text-center py-20 text-gray-500">
|
||||||
|
No applications found matching "{search}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
51
src/components/ui/GlassButton.tsx
Normal file
51
src/components/ui/GlassButton.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface GlassButtonProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'default' | 'primary' | 'danger' | 'success';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassButton({
|
||||||
|
children,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'md',
|
||||||
|
className = '',
|
||||||
|
onClick,
|
||||||
|
disabled = false
|
||||||
|
}: GlassButtonProps) {
|
||||||
|
const variantClasses = {
|
||||||
|
default: 'liquid-glass hover:bg-white/80',
|
||||||
|
primary: 'bg-gradient-to-b from-[#1E90FF] to-[#007AFF] text-white shadow-lg',
|
||||||
|
danger: 'bg-gradient-to-b from-[#FF5147] to-[#FF3B30] text-white shadow-lg',
|
||||||
|
success: 'bg-gradient-to-b from-[#3DD860] to-[#34C759] text-white shadow-lg'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-3 py-1 text-[12px]',
|
||||||
|
md: 'px-4 py-1.5 text-[13px]',
|
||||||
|
lg: 'px-5 py-2 text-[14px]'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
${variantClasses[variant]}
|
||||||
|
${sizeClasses[size]}
|
||||||
|
rounded-[var(--radius-sm)]
|
||||||
|
font-medium
|
||||||
|
glass-press
|
||||||
|
transition-all
|
||||||
|
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/ui/GlassCard.tsx
Normal file
37
src/components/ui/GlassCard.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface GlassCardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'default' | 'light' | 'heavy';
|
||||||
|
className?: string;
|
||||||
|
hover?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassCard({
|
||||||
|
children,
|
||||||
|
variant = 'default',
|
||||||
|
className = '',
|
||||||
|
hover = false,
|
||||||
|
onClick
|
||||||
|
}: GlassCardProps) {
|
||||||
|
const variantClasses = {
|
||||||
|
default: 'liquid-glass',
|
||||||
|
light: 'liquid-glass-light',
|
||||||
|
heavy: 'liquid-glass-heavy'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`
|
||||||
|
${variantClasses[variant]}
|
||||||
|
rounded-[var(--radius-lg)]
|
||||||
|
${hover ? 'glass-hover glass-press cursor-pointer' : ''}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
src/components/ui/Toast.tsx
Normal file
192
src/components/ui/Toast.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: string;
|
||||||
|
type: 'success' | 'error' | 'warning' | 'info' | 'confirm';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
duration?: number;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
toasts: Toast[];
|
||||||
|
addToast: (toast: Omit<Toast, 'id'>) => string;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
confirm: (title: string, message?: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | null>(null);
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) throw new Error('useToast must be used within ToastProvider');
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||||
|
const id = Math.random().toString(36).substr(2, 9);
|
||||||
|
setToasts(prev => [...prev, { ...toast, id }]);
|
||||||
|
|
||||||
|
// Auto-remove non-confirm toasts
|
||||||
|
if (toast.type !== 'confirm' && toast.duration !== 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, toast.duration || 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirm = useCallback((title: string, message?: string): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const id = Math.random().toString(36).substr(2, 9);
|
||||||
|
setToasts(prev => [...prev, {
|
||||||
|
id,
|
||||||
|
type: 'confirm',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onConfirm: () => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, addToast, removeToast, confirm }}>
|
||||||
|
{children}
|
||||||
|
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastContainer({ toasts, removeToast }: { toasts: Toast[], removeToast: (id: string) => void }) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] pointer-events-none flex flex-col items-center justify-start p-4 pt-8 gap-3">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<ToastItem key={toast.id} toast={toast} onDismiss={() => removeToast(toast.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastItem({ toast, onDismiss }: { toast: Toast, onDismiss: () => void }) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Trigger animation
|
||||||
|
requestAnimationFrame(() => setIsVisible(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const typeStyles = {
|
||||||
|
success: 'border-l-4 border-l-green-500',
|
||||||
|
error: 'border-l-4 border-l-red-500',
|
||||||
|
warning: 'border-l-4 border-l-yellow-500',
|
||||||
|
info: 'border-l-4 border-l-blue-500',
|
||||||
|
confirm: 'border-l-4 border-l-blue-500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcons = {
|
||||||
|
success: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#34C759" />
|
||||||
|
<path d="m8 12 3 3 5-6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#FF3B30" />
|
||||||
|
<path d="M15 9l-6 6M9 9l6 6" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 2L2 22h20L12 2z" fill="#FF9500" />
|
||||||
|
<path d="M12 10v4M12 17h.01" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#007AFF" />
|
||||||
|
<path d="M12 8h.01M12 12v4" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
confirm: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" fill="#007AFF" />
|
||||||
|
<path d="M12 8h.01M12 12v4" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
pointer-events-auto
|
||||||
|
liquid-glass-heavy
|
||||||
|
rounded-[var(--radius-lg)]
|
||||||
|
shadow-xl
|
||||||
|
max-w-md w-full
|
||||||
|
transform transition-all duration-300 ease-out
|
||||||
|
${isVisible ? 'translate-y-0 opacity-100' : '-translate-y-4 opacity-0'}
|
||||||
|
${typeStyles[toast.type]}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="p-4 flex items-start gap-3">
|
||||||
|
<div className="shrink-0 mt-0.5">
|
||||||
|
{typeIcons[toast.type]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold text-[14px] text-gray-900">{toast.title}</div>
|
||||||
|
{toast.message && (
|
||||||
|
<div className="text-[13px] text-gray-600 mt-0.5">{toast.message}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toast.type === 'confirm' && (
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={toast.onCancel}
|
||||||
|
className="px-4 py-1.5 liquid-glass rounded-[var(--radius-sm)] text-[13px] font-medium glass-hover glass-press"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toast.onConfirm}
|
||||||
|
className="px-4 py-1.5 bg-gradient-to-b from-[#1E90FF] to-[#007AFF] text-white rounded-[var(--radius-sm)] text-[13px] font-medium shadow-lg glass-press"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast.type !== 'confirm' && (
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="shrink-0 text-gray-400 hover:text-gray-600 transition-colors p-1"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/ui/Tooltip.tsx
Normal file
51
src/components/ui/Tooltip.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
content: string;
|
||||||
|
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({ children, content, position = 'top' }: TooltipProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const positionClasses = {
|
||||||
|
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||||
|
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||||
|
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||||
|
right: 'left-full top-1/2 -translate-y-1/2 ml-2'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex items-center"
|
||||||
|
onMouseEnter={() => setIsVisible(true)}
|
||||||
|
onMouseLeave={() => setIsVisible(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{isVisible && (
|
||||||
|
<div className={`
|
||||||
|
absolute z-50 px-3 py-2
|
||||||
|
w-max max-w-xs
|
||||||
|
text-[12px] font-medium text-white
|
||||||
|
bg-black/60 backdrop-blur-md
|
||||||
|
rounded-[var(--radius-sm)]
|
||||||
|
shadow-xl
|
||||||
|
pointer-events-none
|
||||||
|
animate-fade-in
|
||||||
|
${positionClasses[position]}
|
||||||
|
`}>
|
||||||
|
{content}
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className={`
|
||||||
|
absolute w-2 h-2 bg-black/60 backdrop-blur-md rotate-45
|
||||||
|
${position === 'top' ? 'bottom-[-4px] left-1/2 -translate-x-1/2' : ''}
|
||||||
|
${position === 'bottom' ? 'top-[-4px] left-1/2 -translate-x-1/2' : ''}
|
||||||
|
${position === 'left' ? 'right-[-4px] top-1/2 -translate-y-1/2' : ''}
|
||||||
|
${position === 'right' ? 'left-[-4px] top-1/2 -translate-y-1/2' : ''}
|
||||||
|
`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/components/ui/index.ts
Normal file
4
src/components/ui/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { GlassCard } from './GlassCard';
|
||||||
|
export { GlassButton } from './GlassButton';
|
||||||
|
export { ToastProvider, useToast } from './Toast';
|
||||||
|
export { Tooltip } from './Tooltip';
|
||||||
244
src/index.css
Normal file
244
src/index.css
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LIQUID GLASS DESIGN SYSTEM (macOS 26 Tahoe)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Glass Effect Variables */
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.85);
|
||||||
|
--glass-bg-heavy: rgba(255, 255, 255, 0.92);
|
||||||
|
--glass-blur: 40px;
|
||||||
|
--glass-blur-light: 20px;
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.18);
|
||||||
|
--glass-border-strong: rgba(255, 255, 255, 0.35);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||||
|
--glass-shadow-elevated: 0 16px 48px rgba(0, 0, 0, 0.12);
|
||||||
|
--glass-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
|
||||||
|
/* Radius System */
|
||||||
|
--radius-xs: 8px;
|
||||||
|
--radius-sm: 12px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-xl: 24px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Timing Functions */
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||||
|
|
||||||
|
/* Base Colors */
|
||||||
|
--color-surface: #f5f5f7;
|
||||||
|
--color-text: #1d1d1f;
|
||||||
|
--color-text-secondary: rgba(0, 0, 0, 0.55);
|
||||||
|
--color-accent: #007AFF;
|
||||||
|
--color-danger: #FF3B30;
|
||||||
|
--color-success: #34C759;
|
||||||
|
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--glass-bg: rgba(30, 30, 30, 0.72);
|
||||||
|
--glass-bg-light: rgba(40, 40, 40, 0.85);
|
||||||
|
--glass-bg-heavy: rgba(20, 20, 20, 0.92);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--glass-border-strong: rgba(255, 255, 255, 0.15);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
--glass-shadow-elevated: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||||
|
--glass-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
--color-surface: #000000;
|
||||||
|
--color-text: #f5f5f7;
|
||||||
|
--color-text-secondary: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--glass-bg: rgba(20, 20, 20, 0.7);
|
||||||
|
--glass-bg-light: rgba(30, 30, 30, 0.8);
|
||||||
|
--glass-bg-heavy: rgba(10, 10, 10, 0.9);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--glass-border-strong: rgba(255, 255, 255, 0.12);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
--glass-shadow-elevated: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
--glass-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
--color-surface: #000000;
|
||||||
|
--color-text: #f5f5f7;
|
||||||
|
--color-text-secondary: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
GLASS UTILITY CLASSES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.liquid-glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow), var(--glass-inner-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-glass-light {
|
||||||
|
background: var(--glass-bg-light);
|
||||||
|
backdrop-filter: blur(var(--glass-blur-light));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur-light));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-glass-heavy {
|
||||||
|
background: var(--glass-bg-heavy);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border-strong);
|
||||||
|
box-shadow: var(--glass-shadow-elevated), var(--glass-inner-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-hover {
|
||||||
|
transition: transform 0.2s var(--ease-spring), box-shadow 0.2s var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-hover:hover {
|
||||||
|
transform: translateY(-2px) scale(1.01);
|
||||||
|
box-shadow: var(--glass-shadow-elevated), var(--glass-inner-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-press {
|
||||||
|
transition: transform 0.1s var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-press:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ANIMATION KEYFRAMES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: var(--glass-shadow-elevated), 0 0 20px rgba(0, 122, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation Utility Classes */
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.4s var(--ease-spring) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slideUp 0.5s var(--ease-spring) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scale-in {
|
||||||
|
animation: scaleIn 0.3s var(--ease-spring) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stagger children animations */
|
||||||
|
.stagger-children>* {
|
||||||
|
animation: fadeIn 0.4s var(--ease-spring) forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-children>*:nth-child(1) {
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-children>*:nth-child(2) {
|
||||||
|
animation-delay: 0.05s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-children>*:nth-child(3) {
|
||||||
|
animation-delay: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-children>*:nth-child(4) {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-children>*:nth-child(5) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-children>*:nth-child(6) {
|
||||||
|
animation-delay: 0.25s;
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
22
src/vite-env.d.ts
vendored
Normal file
22
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ElectronAPI {
|
||||||
|
scanDirectory: (path: string) => Promise<any[]>;
|
||||||
|
disableUpdates: () => Promise<boolean>;
|
||||||
|
cleanSystem: () => Promise<boolean>;
|
||||||
|
purgePath: (path: string) => Promise<boolean>;
|
||||||
|
cleanupDocker: () => Promise<boolean>;
|
||||||
|
cleanupTmp: () => Promise<boolean>;
|
||||||
|
cleanupXcode: () => Promise<boolean>;
|
||||||
|
cleanupTurnkey: () => Promise<boolean>;
|
||||||
|
deepDiveScan: () => Promise<any[]>;
|
||||||
|
getDiskUsage: () => Promise<DiskUsage | null>;
|
||||||
|
deepestScan: (path?: string) => Promise<ScanResult[]>;
|
||||||
|
updateTrayTitle: (title: string) => Promise<void>;
|
||||||
|
getAppIcon: (path: string) => Promise<string>;
|
||||||
|
updateTrayIcon: (dataUrl: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI;
|
||||||
|
}
|
||||||
55
start-dev.ps1
Normal file
55
start-dev.ps1
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Start-Dev.ps1 - Windows equivalent of start-go.sh
|
||||||
|
|
||||||
|
Write-Host "Starting Antigravity (Windows Mode)..." -ForegroundColor Green
|
||||||
|
|
||||||
|
# 1. Kill existing backend on port 36969 if running
|
||||||
|
$port = 36969
|
||||||
|
$process = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique
|
||||||
|
if ($process) {
|
||||||
|
Write-Host "Killing existing backend process (PID: $process)..." -ForegroundColor Yellow
|
||||||
|
Stop-Process -Id $process -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Check for Go
|
||||||
|
if (-not (Get-Command "go" -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host "Go is not installed or not in PATH." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Check for pnpm or fallback
|
||||||
|
$pkgManager = "pnpm"
|
||||||
|
if (-not (Get-Command "pnpm" -ErrorAction SilentlyContinue)) {
|
||||||
|
if (Get-Command "npm" -ErrorAction SilentlyContinue) {
|
||||||
|
$pkgManager = "npm"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "pnpm/npm not found." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Start Backend in background
|
||||||
|
Write-Host "Starting Go Backend..." -ForegroundColor Cyan
|
||||||
|
$env:APP_ENV = "development"
|
||||||
|
$backendJob = Start-Process -FilePath "go" -ArgumentList "run backend/main.go" -NoNewWindow -PassThru
|
||||||
|
Write-Host "Backend started (Simple PID: $($backendJob.Id))" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# 5. Start Frontend
|
||||||
|
Write-Host "Checking dependencies..." -ForegroundColor Cyan
|
||||||
|
if (-not (Test-Path "node_modules\.bin\vite.ps1") -and -not (Test-Path "node_modules\.bin\vite.cmd")) {
|
||||||
|
Write-Host "Dependencies missing. Running install..." -ForegroundColor Yellow
|
||||||
|
if ($pkgManager -eq "pnpm") {
|
||||||
|
pnpm install
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
npm install
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting Frontend ($pkgManager run dev)..." -ForegroundColor Cyan
|
||||||
|
if ($pkgManager -eq "pnpm") {
|
||||||
|
pnpm run dev
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
npm run dev
|
||||||
|
}
|
||||||
72
start-dev.sh
Normal file
72
start-dev.sh
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Define where our temporary node shim lives
|
||||||
|
SHIM_DIR="/tmp/antigravity_bin"
|
||||||
|
NODE_SHIM="$SHIM_DIR/node"
|
||||||
|
|
||||||
|
# Check if 'node' is already in PATH
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo "⚠️ Node.js not found in PATH."
|
||||||
|
|
||||||
|
# Check if 'bun' is available to fill in
|
||||||
|
if command -v bun &> /dev/null; then
|
||||||
|
echo "✅ Found Bun! Creating a compatibility shim for Node..."
|
||||||
|
|
||||||
|
# Create shim directory
|
||||||
|
mkdir -p "$SHIM_DIR"
|
||||||
|
|
||||||
|
# Create the shim script
|
||||||
|
echo '#!/bin/sh' > "$NODE_SHIM"
|
||||||
|
echo 'exec bun "$@"' >> "$NODE_SHIM"
|
||||||
|
chmod +x "$NODE_SHIM"
|
||||||
|
|
||||||
|
# Add to PATH
|
||||||
|
export PATH="$SHIM_DIR:$PATH"
|
||||||
|
|
||||||
|
echo "🔧 Shim created at $NODE_SHIM"
|
||||||
|
else
|
||||||
|
echo "❌ Error: Neither 'node' nor 'bun' was found."
|
||||||
|
echo "Please install Node.js or Bun to run this application."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure dependencies are installed (specifically checking for vite)
|
||||||
|
if [ ! -f "node_modules/.bin/vite" ]; then
|
||||||
|
echo "📦 Dependencies incomplete (vite binary missing)."
|
||||||
|
echo "⏳ Running pnpm install to fix... (This may take a while on network drives)"
|
||||||
|
|
||||||
|
# Run install and show output
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ Installation failed. Please check the network connection or drive permissions."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Starting Antigravity..."
|
||||||
|
# Check for vite again to be sure
|
||||||
|
if [ ! -f "node_modules/.bin/vite" ]; then
|
||||||
|
echo "❌ Error: 'vite' binary still not found after install."
|
||||||
|
echo "This might be due to the 'noexec' mount option on your drive preventing symlink creation in node_modules/.bin."
|
||||||
|
echo "Trying to run via node directly..."
|
||||||
|
|
||||||
|
# Fallback: finding the vite script manually if bin is missing
|
||||||
|
VITE_PATH=$(find node_modules -name vite.js | head -n 1)
|
||||||
|
if [ -z "$VITE_PATH" ]; then
|
||||||
|
# try package.json
|
||||||
|
VITE_PKG="node_modules/vite/bin/vite.js"
|
||||||
|
if [ -f "$VITE_PKG" ]; then
|
||||||
|
node "$VITE_PKG"
|
||||||
|
else
|
||||||
|
echo "❌ Could not find vite.js source."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
node "$VITE_PATH"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Run the electron dev environment
|
||||||
|
pnpm dev:electron
|
||||||
|
fi
|
||||||
36
start-go.sh
Normal file
36
start-go.sh
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Define where our temporary node shim lives
|
||||||
|
SHIM_DIR="/tmp/antigravity_bin"
|
||||||
|
export PATH="$SHIM_DIR:$PATH"
|
||||||
|
|
||||||
|
# Create shim if node is missing but bun exists
|
||||||
|
if ! command -v node &> /dev/null && command -v bun &> /dev/null; then
|
||||||
|
mkdir -p "$SHIM_DIR"
|
||||||
|
echo '#!/bin/sh' > "$SHIM_DIR/node"
|
||||||
|
echo 'exec bun "$@"' >> "$SHIM_DIR/node"
|
||||||
|
chmod +x "$SHIM_DIR/node"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill any existing go server on port 36969
|
||||||
|
lsof -ti :36969 | xargs kill -9 2>/dev/null
|
||||||
|
|
||||||
|
echo "🚀 Starting Antigravity (Go Edition)..."
|
||||||
|
|
||||||
|
# Start Go Backend
|
||||||
|
echo "📦 Building Backend..."
|
||||||
|
go run backend/main.go &
|
||||||
|
GO_PID=$!
|
||||||
|
|
||||||
|
# Start Frontend
|
||||||
|
echo "✨ Starting Frontend..."
|
||||||
|
# Check for pnpm or fallback
|
||||||
|
if command -v pnpm &> /dev/null; then
|
||||||
|
pnpm run dev
|
||||||
|
else
|
||||||
|
# Fallback if pnpm is also missing from PATH but bun is there
|
||||||
|
bun run dev
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup on exit
|
||||||
|
kill $GO_PID
|
||||||
16
tailwind.config.js
Normal file
16
tailwind.config.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: "#0f0f0f",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
darkMode: 'class',
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
BIN
tracked_files.txt
Normal file
BIN
tracked_files.txt
Normal file
Binary file not shown.
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue