Compare commits
No commits in common. "5f5643cf42ea7f9b327aa1617072c71c86358d14" and "eb4a4007365cefa42a8af731bbebc302e9678d69" have entirely different histories.
5f5643cf42
...
eb4a400736
35 changed files with 9886 additions and 9263 deletions
61
.gitignore
vendored
61
.gitignore
vendored
|
|
@ -1,31 +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?
|
||||
*.sw?
|
||||
|
||||
# Release Artifacts
|
||||
Release/
|
||||
*.zip
|
||||
*.exe
|
||||
backend/dist/
|
||||
# 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/
|
||||
|
|
|
|||
136
README.md
136
README.md
|
|
@ -1,68 +1,68 @@
|
|||
# KV Clearnup (Antigravity) 🚀
|
||||
|
||||
A modern, high-performance system optimizer for macOS, built with **Electron**, **React**, and **Go**.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
- **Flash Clean**: Instantly remove system caches, logs, and trash.
|
||||
- **Deep Clean**: Scan for large files and heavy folders.
|
||||
- **Real-time Monitoring**: Track disk usage and category sizes.
|
||||
- **Universal Binary**: Runs natively on both Apple Silicon (M1/M2/M3) and Intel Macs.
|
||||
- **High Performance**: Heavy lifting is handled by a compiled Go backend.
|
||||
|
||||
## Prerequisites
|
||||
- **Node.js** (v18+)
|
||||
- **Go** (v1.20+)
|
||||
- **pnpm** (preferred) or npm
|
||||
|
||||
## Development
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Run in Development Mode
|
||||
This starts the Go backend (port 36969) and the Vite/Electron frontend concurrently.
|
||||
```bash
|
||||
./start-go.sh
|
||||
```
|
||||
*Note: Do not run `npm run dev` directly if you want the backend to work. Use the script.*
|
||||
|
||||
## Building for Production
|
||||
|
||||
To create a distributable `.dmg` file for macOS:
|
||||
|
||||
### 1. Build the App
|
||||
```bash
|
||||
npm run build:mac
|
||||
```
|
||||
This command will:
|
||||
1. Compile the Go backend for both `amd64` and `arm64`.
|
||||
2. Create a universal binary using `lipo`.
|
||||
3. Build the React frontend.
|
||||
4. Package the Electron app and bundle the backend.
|
||||
5. Generate a universal `.dmg`.
|
||||
|
||||
### 2. Locate the Installer
|
||||
The output file will be at:
|
||||
```
|
||||
release/KV Clearnup-0.0.0-universal.dmg
|
||||
```
|
||||
|
||||
## Running the App
|
||||
1. **Mount the DMG**: Double-click the `.dmg` file in the `release` folder.
|
||||
2. **Install**: Drag the app to your `Applications` folder.
|
||||
3. **Launch**: Open "KV Clearnup" from Applications.
|
||||
|
||||
*Troubleshooting*: If you see "System Extension Blocked" or similar OS warnings, go to **System Settings > Privacy & Security** and allow the application.
|
||||
|
||||
## Architecture
|
||||
- **Frontend**: React, TypeScript, TailwindCSS, Framer Motion.
|
||||
- **Main Process**: Electron (TypeScript).
|
||||
- **Backend**: Go (Golang) for file system operations and heavy scanning.
|
||||
- **Communication**: Electron uses `child_process` to spawn the Go binary. Frontend communicates with backend via HTTP (localhost:36969).
|
||||
|
||||
## License
|
||||
MIT
|
||||
# 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
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -1,22 +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"`
|
||||
}
|
||||
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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,248 +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
|
||||
}
|
||||
//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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package platform
|
||||
|
||||
type SystemInfo struct {
|
||||
Model string `json:"model"`
|
||||
Chip string `json:"chip"`
|
||||
Memory string `json:"memory"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
package platform
|
||||
|
||||
type SystemInfo struct {
|
||||
Model string `json:"model"`
|
||||
Chip string `json:"chip"`
|
||||
Memory string `json:"memory"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,114 +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()
|
||||
}
|
||||
//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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,106 +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()
|
||||
}
|
||||
//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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,92 +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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,435 +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
|
||||
}
|
||||
//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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,40 @@
|
|||
//go:build darwin
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func GetScanTargets(category string) []string {
|
||||
home, _ := os.UserHomeDir()
|
||||
switch category {
|
||||
case "apps":
|
||||
return []string{"/Applications", filepath.Join(home, "Applications")}
|
||||
case "photos":
|
||||
return []string{filepath.Join(home, "Pictures")}
|
||||
case "icloud":
|
||||
return []string{filepath.Join(home, "Library", "Mobile Documents")}
|
||||
case "docs":
|
||||
return []string{filepath.Join(home, "Documents")}
|
||||
case "downloads":
|
||||
return []string{filepath.Join(home, "Downloads")}
|
||||
case "desktop":
|
||||
return []string{filepath.Join(home, "Desktop")}
|
||||
case "music":
|
||||
return []string{filepath.Join(home, "Music")}
|
||||
case "movies":
|
||||
return []string{filepath.Join(home, "Movies")}
|
||||
case "system":
|
||||
return []string{
|
||||
filepath.Join(home, "Library", "Caches"),
|
||||
filepath.Join(home, "Library", "Logs"),
|
||||
filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData"),
|
||||
}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
//go:build darwin
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func GetScanTargets(category string) []string {
|
||||
home, _ := os.UserHomeDir()
|
||||
switch category {
|
||||
case "apps":
|
||||
return []string{"/Applications", filepath.Join(home, "Applications")}
|
||||
case "photos":
|
||||
return []string{filepath.Join(home, "Pictures")}
|
||||
case "icloud":
|
||||
return []string{filepath.Join(home, "Library", "Mobile Documents")}
|
||||
case "docs":
|
||||
return []string{filepath.Join(home, "Documents")}
|
||||
case "downloads":
|
||||
return []string{filepath.Join(home, "Downloads")}
|
||||
case "desktop":
|
||||
return []string{filepath.Join(home, "Desktop")}
|
||||
case "music":
|
||||
return []string{filepath.Join(home, "Music")}
|
||||
case "movies":
|
||||
return []string{filepath.Join(home, "Movies")}
|
||||
case "system":
|
||||
return []string{
|
||||
filepath.Join(home, "Library", "Caches"),
|
||||
filepath.Join(home, "Library", "Logs"),
|
||||
filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData"),
|
||||
}
|
||||
case "trash":
|
||||
return []string{filepath.Join(home, ".Trash")}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +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{}
|
||||
}
|
||||
}
|
||||
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{}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
963
backend/main.go
963
backend/main.go
|
|
@ -1,448 +1,515 @@
|
|||
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})
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/kv/clearnup/backend/internal/apps"
|
||||
"github.com/kv/clearnup/backend/internal/cleaner"
|
||||
"github.com/kv/clearnup/backend/internal/platform"
|
||||
"github.com/kv/clearnup/backend/internal/scanner"
|
||||
)
|
||||
|
||||
//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/clean-xcode", handleCleanXcode)
|
||||
http.HandleFunc("/api/clean-homebrew", handleCleanHomebrew)
|
||||
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 {
|
||||
message := string(output)
|
||||
if message == "" || len(message) > 500 { // fallback if output is empty mapping or huge
|
||||
message = err.Error()
|
||||
}
|
||||
// If the daemon isn't running, provide a helpful message
|
||||
if strings.Contains(message, "connect: no such file or directory") || strings.Contains(message, "Is the docker daemon running") {
|
||||
message = "Docker daemon is not running. Please start Docker to clean it."
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"cleared": 0,
|
||||
"message": message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"cleared": 1,
|
||||
"message": string(output),
|
||||
})
|
||||
}
|
||||
|
||||
func handleCleanXcode(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"cleared": 0, "message": "Could not find home directory"})
|
||||
return
|
||||
}
|
||||
|
||||
paths := []string{
|
||||
filepath.Join(home, "Library/Developer/Xcode/DerivedData"),
|
||||
filepath.Join(home, "Library/Developer/Xcode/iOS DeviceSupport"),
|
||||
filepath.Join(home, "Library/Developer/Xcode/Archives"),
|
||||
filepath.Join(home, "Library/Caches/com.apple.dt.Xcode"),
|
||||
}
|
||||
|
||||
totalCleared := int64(0)
|
||||
for _, p := range paths {
|
||||
if stat, err := os.Stat(p); err == nil && stat.IsDir() {
|
||||
size := scanner.GetDirectorySize(p)
|
||||
if err := os.RemoveAll(p); err == nil {
|
||||
totalCleared += size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"cleared": totalCleared, "message": "Xcode Caches Cleared"})
|
||||
}
|
||||
|
||||
func handleCleanHomebrew(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command("brew", "cleanup", "--prune=all")
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"cleared": 0,
|
||||
"message": fmt.Sprintf("Brew cleanup failed: %s", string(output)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"cleared": 1,
|
||||
"message": "Homebrew Cache Cleared",
|
||||
})
|
||||
}
|
||||
|
||||
func handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
||||
enableCors(&w)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +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
|
||||
}
|
||||
$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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +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
|
||||
# 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
|
||||
|
|
|
|||
467
dist-electron/main.cjs
Normal file
467
dist-electron/main.cjs
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
|
||||
// electron/main.ts
|
||||
var import_electron = require("electron");
|
||||
var import_path3 = __toESM(require("path"), 1);
|
||||
var import_child_process3 = require("child_process");
|
||||
|
||||
// electron/features/scanner.ts
|
||||
var import_promises = __toESM(require("fs/promises"), 1);
|
||||
var import_path = __toESM(require("path"), 1);
|
||||
var import_os = __toESM(require("os"), 1);
|
||||
var import_child_process = require("child_process");
|
||||
var import_util = __toESM(require("util"), 1);
|
||||
async function scanDirectory(rootDir, maxDepth = 5) {
|
||||
const results = [];
|
||||
async function traverse(currentPath, depth) {
|
||||
if (depth > maxDepth) return;
|
||||
try {
|
||||
const entries = await import_promises.default.readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = import_path.default.join(currentPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "vendor" || entry.name === ".venv") {
|
||||
try {
|
||||
const stats = await import_promises.default.stat(fullPath);
|
||||
results.push({
|
||||
path: fullPath,
|
||||
size: 0,
|
||||
// Calculating size is expensive, might do lazily or separate task
|
||||
lastAccessed: stats.atime,
|
||||
type: entry.name
|
||||
});
|
||||
continue;
|
||||
} catch (e) {
|
||||
console.error(`Error stat-ing ${fullPath}`, e);
|
||||
}
|
||||
} else if (!entry.name.startsWith(".")) {
|
||||
await traverse(fullPath, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error scanning ${currentPath}`, error);
|
||||
}
|
||||
}
|
||||
await traverse(rootDir, 0);
|
||||
return results;
|
||||
}
|
||||
async function findLargeFiles(rootDir, threshold = 100 * 1024 * 1024) {
|
||||
const results = [];
|
||||
async function traverse(currentPath) {
|
||||
try {
|
||||
const stats = await import_promises.default.stat(currentPath);
|
||||
if (stats.size > threshold && !stats.isDirectory()) {
|
||||
results.push({ path: currentPath, size: stats.size, isDirectory: false });
|
||||
return;
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
if (import_path.default.basename(currentPath) === "node_modules") return;
|
||||
const entries = await import_promises.default.readdir(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") && entry.name !== ".Trash") continue;
|
||||
await traverse(import_path.default.join(currentPath, entry.name));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
await traverse(rootDir);
|
||||
return results.sort((a, b) => b.size - a.size);
|
||||
}
|
||||
async function getDeepDiveSummary() {
|
||||
const home = import_os.default.homedir();
|
||||
const targets = [
|
||||
import_path.default.join(home, "Downloads"),
|
||||
import_path.default.join(home, "Documents"),
|
||||
import_path.default.join(home, "Desktop"),
|
||||
import_path.default.join(home, "Library/Application Support")
|
||||
];
|
||||
const results = [];
|
||||
for (const t of targets) {
|
||||
console.log(`Scanning ${t}...`);
|
||||
const large = await findLargeFiles(t, 50 * 1024 * 1024);
|
||||
console.log(`Found ${large.length} large files in ${t}`);
|
||||
results.push(...large);
|
||||
}
|
||||
return results.slice(0, 20);
|
||||
}
|
||||
var execPromise = import_util.default.promisify(import_child_process.exec);
|
||||
async function getDiskUsage() {
|
||||
try {
|
||||
const { stdout } = await execPromise("df -k /");
|
||||
const lines = stdout.trim().split("\n");
|
||||
if (lines.length < 2) return null;
|
||||
const parts = lines[1].split(/\s+/);
|
||||
const total = parseInt(parts[1]) * 1024;
|
||||
const used = parseInt(parts[2]) * 1024;
|
||||
const available = parseInt(parts[3]) * 1024;
|
||||
return {
|
||||
totalGB: (total / 1024 / 1024 / 1024).toFixed(2),
|
||||
usedGB: (used / 1024 / 1024 / 1024).toFixed(2),
|
||||
freeGB: (available / 1024 / 1024 / 1024).toFixed(2)
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Error getting disk usage:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function findHeavyFolders(rootDir) {
|
||||
try {
|
||||
console.log(`Deepest scan on: ${rootDir}`);
|
||||
const { stdout } = await execPromise(`du -k -d 2 "${rootDir}" | sort -nr | head -n 50`);
|
||||
const lines = stdout.trim().split("\n");
|
||||
const results = lines.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
const firstSpace = trimmed.indexOf(" ");
|
||||
const match = trimmed.match(/^(\d+)\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
const sizeK = parseInt(match[1]);
|
||||
const fullPath = match[2];
|
||||
return {
|
||||
path: fullPath,
|
||||
size: sizeK * 1024,
|
||||
// Convert KB to Bytes
|
||||
isDirectory: true
|
||||
};
|
||||
}).filter((item) => item !== null && item.path !== rootDir);
|
||||
return results;
|
||||
} catch (e) {
|
||||
console.error("Deepest scan failed:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// electron/features/updater.ts
|
||||
var import_child_process2 = require("child_process");
|
||||
var import_util2 = __toESM(require("util"), 1);
|
||||
var execAsync = import_util2.default.promisify(import_child_process2.exec);
|
||||
async function disableAutoUpdates(password) {
|
||||
const cmds = [
|
||||
"sudo -S softwareupdate --schedule off",
|
||||
"sudo -S defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool false",
|
||||
"sudo -S defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -bool false",
|
||||
"sudo -S defaults write /Library/Preferences/com.apple.commerce AutoUpdate -bool false"
|
||||
];
|
||||
try {
|
||||
await execWithSudo("softwareupdate --schedule off");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to disable updates", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function execWithSudo(command) {
|
||||
const script = `do shell script "${command}" with administrator privileges`;
|
||||
return execAsync(`osascript -e '${script}'`);
|
||||
}
|
||||
|
||||
// electron/features/cleaner.ts
|
||||
var import_promises2 = __toESM(require("fs/promises"), 1);
|
||||
var import_path2 = __toESM(require("path"), 1);
|
||||
var import_os2 = __toESM(require("os"), 1);
|
||||
async function clearCaches() {
|
||||
const cacheDir = import_path2.default.join(import_os2.default.homedir(), "Library/Caches");
|
||||
try {
|
||||
const entries = await import_promises2.default.readdir(cacheDir);
|
||||
let freedSpace = 0;
|
||||
for (const entry of entries) {
|
||||
const fullPath = import_path2.default.join(cacheDir, entry);
|
||||
await import_promises2.default.rm(fullPath, { recursive: true, force: true });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error clearing caches", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function purgePath(targetPath) {
|
||||
try {
|
||||
await import_promises2.default.rm(targetPath, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`Failed to purge ${targetPath}`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function cleanupDocker() {
|
||||
try {
|
||||
const { exec: exec3 } = await import("child_process");
|
||||
const util3 = await import("util");
|
||||
const execAsync2 = util3.promisify(exec3);
|
||||
await execAsync2("docker system prune -a --volumes -f");
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Failed to cleanup docker:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function cleanupTmp() {
|
||||
const tmpDir = import_os2.default.tmpdir();
|
||||
let success = true;
|
||||
try {
|
||||
const entries = await import_promises2.default.readdir(tmpDir);
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await import_promises2.default.rm(import_path2.default.join(tmpDir, entry), { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.warn(`Skipped ${entry}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to access tmp dir:", e);
|
||||
success = false;
|
||||
}
|
||||
return success;
|
||||
}
|
||||
async function cleanupXcode() {
|
||||
try {
|
||||
const home = import_os2.default.homedir();
|
||||
const paths = [
|
||||
import_path2.default.join(home, "Library/Developer/Xcode/DerivedData"),
|
||||
import_path2.default.join(home, "Library/Developer/Xcode/iOS DeviceSupport"),
|
||||
import_path2.default.join(home, "Library/Developer/Xcode/Archives"),
|
||||
import_path2.default.join(home, "Library/Caches/com.apple.dt.Xcode")
|
||||
];
|
||||
for (const p of paths) {
|
||||
try {
|
||||
await import_promises2.default.rm(p, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean ${p}`, e);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Failed to cleanup Xcode:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function cleanupTurnkey() {
|
||||
try {
|
||||
const home = import_os2.default.homedir();
|
||||
const paths = [
|
||||
import_path2.default.join(home, ".npm/_cacache"),
|
||||
import_path2.default.join(home, ".yarn/cache"),
|
||||
import_path2.default.join(home, "Library/pnpm/store"),
|
||||
// Mac default for pnpm store if not configured otherwise
|
||||
import_path2.default.join(home, ".cache/yarn"),
|
||||
import_path2.default.join(home, ".gradle/caches")
|
||||
];
|
||||
for (const p of paths) {
|
||||
try {
|
||||
await import_promises2.default.rm(p, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean ${p}`, e);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Failed to cleanup package managers:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// electron/main.ts
|
||||
var mainWindow = null;
|
||||
var backendProcess = null;
|
||||
var tray = null;
|
||||
var startBackend = () => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("Development mode: Backend should be running via start-go.sh");
|
||||
return;
|
||||
}
|
||||
const backendPath = import_path3.default.join(process.resourcesPath, "backend");
|
||||
console.log("Starting backend from:", backendPath);
|
||||
try {
|
||||
backendProcess = (0, import_child_process3.spawn)(backendPath, [], {
|
||||
stdio: "inherit"
|
||||
});
|
||||
backendProcess.on("error", (err) => {
|
||||
console.error("Failed to start backend:", err);
|
||||
});
|
||||
backendProcess.on("exit", (code, signal) => {
|
||||
console.log(`Backend exited with code ${code} and signal ${signal}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error spawning backend:", error);
|
||||
}
|
||||
};
|
||||
function createTray() {
|
||||
const iconPath = import_path3.default.join(__dirname, "../dist/tray/tray-iconTemplate.png");
|
||||
let finalIconPath = iconPath;
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
finalIconPath = import_path3.default.join(__dirname, "../public/tray/tray-iconTemplate.png");
|
||||
}
|
||||
let image = import_electron.nativeImage.createFromPath(finalIconPath);
|
||||
image.setTemplateImage(true);
|
||||
tray = new import_electron.Tray(image.resize({ width: 18, height: 18 }));
|
||||
tray.setToolTip("Antigravity Cleaner");
|
||||
updateTrayMenu("Initializing...");
|
||||
}
|
||||
var isDockVisible = true;
|
||||
function updateTrayMenu(statusText) {
|
||||
if (!tray) return;
|
||||
const contextMenu = import_electron.Menu.buildFromTemplate([
|
||||
{ label: `Storage: ${statusText}`, enabled: false },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Open Dashboard",
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Free Up Storage",
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Show Dock Icon",
|
||||
type: "checkbox",
|
||||
checked: isDockVisible,
|
||||
click: (menuItem) => {
|
||||
isDockVisible = menuItem.checked;
|
||||
if (isDockVisible) {
|
||||
import_electron.app.dock.show();
|
||||
} else {
|
||||
import_electron.app.dock.hide();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ label: "Quit", click: () => import_electron.app.quit() }
|
||||
]);
|
||||
tray.setContextMenu(contextMenu);
|
||||
tray.setTitle(statusText);
|
||||
}
|
||||
function createWindow() {
|
||||
mainWindow = new import_electron.BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
backgroundColor: "#FFFFFF",
|
||||
// Helps prevent white flash
|
||||
webPreferences: {
|
||||
preload: import_path3.default.join(__dirname, "preload.cjs"),
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const port = process.env.PORT || 5173;
|
||||
if (isDev) {
|
||||
mainWindow.loadURL(`http://localhost:${port}`);
|
||||
} else {
|
||||
mainWindow.loadFile(import_path3.default.join(__dirname, "../dist/index.html"));
|
||||
}
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
import_electron.app.whenReady().then(() => {
|
||||
import_electron.ipcMain.handle("scan-directory", async (event, path4) => {
|
||||
return scanDirectory(path4);
|
||||
});
|
||||
import_electron.ipcMain.handle("deep-dive-scan", async () => {
|
||||
return getDeepDiveSummary();
|
||||
});
|
||||
import_electron.ipcMain.handle("get-disk-usage", async () => {
|
||||
return getDiskUsage();
|
||||
});
|
||||
import_electron.ipcMain.handle("deepest-scan", async (event, targetPath) => {
|
||||
const target = targetPath || import_path3.default.join(import_electron.app.getPath("home"), "Documents");
|
||||
return findHeavyFolders(target);
|
||||
});
|
||||
import_electron.ipcMain.handle("disable-updates", async () => {
|
||||
return disableAutoUpdates();
|
||||
});
|
||||
import_electron.ipcMain.handle("clean-system", async () => {
|
||||
return clearCaches();
|
||||
});
|
||||
import_electron.ipcMain.handle("cleanup-docker", async () => {
|
||||
return cleanupDocker();
|
||||
});
|
||||
import_electron.ipcMain.handle("cleanup-tmp", async () => {
|
||||
return cleanupTmp();
|
||||
});
|
||||
import_electron.ipcMain.handle("cleanup-xcode", async () => {
|
||||
return cleanupXcode();
|
||||
});
|
||||
import_electron.ipcMain.handle("cleanup-turnkey", async () => {
|
||||
return cleanupTurnkey();
|
||||
});
|
||||
import_electron.ipcMain.handle("purge-path", async (event, targetPath) => {
|
||||
return purgePath(targetPath);
|
||||
});
|
||||
import_electron.ipcMain.handle("update-tray-title", (event, title) => {
|
||||
if (tray) {
|
||||
tray.setTitle(title);
|
||||
updateTrayMenu(title);
|
||||
}
|
||||
});
|
||||
import_electron.ipcMain.handle("get-app-icon", async (event, appPath) => {
|
||||
try {
|
||||
const icon = await import_electron.app.getFileIcon(appPath, { size: "normal" });
|
||||
return icon.toDataURL();
|
||||
} catch (e) {
|
||||
console.error("Failed to get icon for:", appPath, e);
|
||||
return "";
|
||||
return "";
|
||||
}
|
||||
});
|
||||
import_electron.ipcMain.handle("update-tray-icon", (event, dataUrl) => {
|
||||
if (tray && dataUrl) {
|
||||
const image = import_electron.nativeImage.createFromDataURL(dataUrl);
|
||||
tray.setImage(image.resize({ width: 22, height: 22 }));
|
||||
}
|
||||
});
|
||||
createWindow();
|
||||
createTray();
|
||||
startBackend();
|
||||
});
|
||||
import_electron.app.on("will-quit", () => {
|
||||
if (backendProcess) {
|
||||
console.log("Killing backend process...");
|
||||
backendProcess.kill();
|
||||
backendProcess = null;
|
||||
}
|
||||
});
|
||||
import_electron.app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
import_electron.app.quit();
|
||||
}
|
||||
});
|
||||
import_electron.app.on("activate", () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
20
dist-electron/preload.cjs
Normal file
20
dist-electron/preload.cjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"use strict";
|
||||
|
||||
// electron/preload.ts
|
||||
var import_electron = require("electron");
|
||||
import_electron.contextBridge.exposeInMainWorld("electronAPI", {
|
||||
scanDirectory: (path) => import_electron.ipcRenderer.invoke("scan-directory", path),
|
||||
disableUpdates: () => import_electron.ipcRenderer.invoke("disable-updates"),
|
||||
cleanSystem: () => import_electron.ipcRenderer.invoke("clean-system"),
|
||||
purgePath: (path) => import_electron.ipcRenderer.invoke("purge-path", path),
|
||||
cleanupDocker: () => import_electron.ipcRenderer.invoke("cleanup-docker"),
|
||||
cleanupTmp: () => import_electron.ipcRenderer.invoke("cleanup-tmp"),
|
||||
cleanupXcode: () => import_electron.ipcRenderer.invoke("cleanup-xcode"),
|
||||
cleanupTurnkey: () => import_electron.ipcRenderer.invoke("cleanup-turnkey"),
|
||||
deepDiveScan: () => import_electron.ipcRenderer.invoke("deep-dive-scan"),
|
||||
getDiskUsage: () => import_electron.ipcRenderer.invoke("get-disk-usage"),
|
||||
deepestScan: (path) => import_electron.ipcRenderer.invoke("deepest-scan", path),
|
||||
updateTrayTitle: (title) => import_electron.ipcRenderer.invoke("update-tray-title", title),
|
||||
getAppIcon: (path) => import_electron.ipcRenderer.invoke("get-app-icon", path),
|
||||
updateTrayIcon: (dataUrl) => import_electron.ipcRenderer.invoke("update-tray-icon", dataUrl)
|
||||
});
|
||||
|
|
@ -49,12 +49,14 @@ function createTray() {
|
|||
|
||||
// Check if dist/tray exists, if not try public/tray (dev mode)
|
||||
let finalIconPath = iconPath;
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
finalIconPath = path.join(__dirname, '../public/tray/tray-iconTemplate.png');
|
||||
}
|
||||
|
||||
const image = nativeImage.createFromPath(finalIconPath);
|
||||
tray = new Tray(image.resize({ width: 16, height: 16 }));
|
||||
let image = nativeImage.createFromPath(finalIconPath);
|
||||
image.setTemplateImage(true);
|
||||
|
||||
tray = new Tray(image.resize({ width: 18, height: 18 }));
|
||||
|
||||
tray.setToolTip('Antigravity Cleaner');
|
||||
updateTrayMenu('Initializing...');
|
||||
|
|
|
|||
20
electron/scripts/gen-tray-logo.cjs
Normal file
20
electron/scripts/gen-tray-logo.cjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const { app, nativeImage } = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const iconPath = path.join(__dirname, '../../build/icon.png');
|
||||
const image = nativeImage.createFromPath(iconPath);
|
||||
|
||||
const resized = image.resize({ width: 22, height: 22, quality: 'best' });
|
||||
|
||||
const pngPath = path.join(__dirname, '../../public/tray/tray-icon.png');
|
||||
const pngPath2 = path.join(__dirname, '../../public/tray/tray-iconTemplate.png');
|
||||
|
||||
const pngBuffer = resized.toPNG();
|
||||
fs.writeFileSync(pngPath, pngBuffer);
|
||||
fs.writeFileSync(pngPath2, pngBuffer);
|
||||
|
||||
console.log('Saved resized built icon to', pngPath);
|
||||
app.quit();
|
||||
});
|
||||
18
electron/scripts/gen-tray.cjs
Normal file
18
electron/scripts/gen-tray.cjs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
const { app, nativeImage } = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const svgBuffer = fs.readFileSync('/tmp/tray-iconTemplate.svg');
|
||||
const image = nativeImage.createFromBuffer(svgBuffer, { scaleFactor: 2.0 });
|
||||
|
||||
const pngPath = path.join(__dirname, '../../public/tray/tray-iconTemplate.png');
|
||||
const pngPath2 = path.join(__dirname, '../../public/tray/tray-icon.png');
|
||||
|
||||
const pngBuffer = image.toPNG();
|
||||
fs.writeFileSync(pngPath, pngBuffer);
|
||||
fs.writeFileSync(pngPath2, pngBuffer);
|
||||
|
||||
console.log('Saved transparent PNG template to', pngPath);
|
||||
app.quit();
|
||||
});
|
||||
10
go.mod
10
go.mod
|
|
@ -1,5 +1,5 @@
|
|||
module github.com/kv/clearnup
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require golang.org/x/sys v0.40.0 // indirect
|
||||
module github.com/kv/clearnup
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require golang.org/x/sys v0.40.0 // indirect
|
||||
|
|
|
|||
166
package.json
166
package.json
|
|
@ -1,81 +1,87 @@
|
|||
{
|
||||
"name": "Lumina",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "dist-electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:electron": "node scripts/build-electron.mjs && concurrently -k \"vite\" \"wait-on tcp:5173 && cross-env NODE_ENV=development electron dist-electron/main.cjs\"",
|
||||
"electron:build": "node scripts/build-electron.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:go:mac": "sh scripts/build-go.sh",
|
||||
"build:mac": "npm run build:go:mac && npm run build && npm run electron:build && electron-builder --mac --universal",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"preinstall": "node scripts/check-pnpm.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.29.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^26.4.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4",
|
||||
"wait-on": "^8.0.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.kv.clearnup",
|
||||
"productName": "KV Clearnup",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"compression": "maximum",
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"category": "public.app-category.utilities",
|
||||
"hardenedRuntime": false
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "backend/dist/universal/backend",
|
||||
"to": "backend"
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"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
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"portable"
|
||||
],
|
||||
"icon": "build/icon.png"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "backend/dist/universal/backend",
|
||||
"to": "backend"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
10216
pnpm-lock.yaml
10216
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
Before Width: | Height: | Size: 513 KiB After Width: | Height: | Size: 0 B |
Binary file not shown.
|
Before Width: | Height: | Size: 431 KiB After Width: | Height: | Size: 0 B |
|
|
@ -1,207 +1,227 @@
|
|||
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 '';
|
||||
}
|
||||
};
|
||||
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" };
|
||||
}
|
||||
},
|
||||
|
||||
cleanXcode: async (): Promise<{ cleared: number; message: string }> => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/clean-xcode`, { method: "POST" });
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { cleared: 0, message: "Xcode cleanup failed" };
|
||||
}
|
||||
},
|
||||
|
||||
cleanHomebrew: async (): Promise<{ cleared: number; message: string }> => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/clean-homebrew`, { method: "POST" });
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { cleared: 0, message: "Homebrew cleanup failed" };
|
||||
}
|
||||
},
|
||||
|
||||
getSystemInfo: async (): Promise<SystemInfo | null> => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/system-info`);
|
||||
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 '';
|
||||
}
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,328 +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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,212 +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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { GlassCard } from './GlassCard';
|
||||
export { GlassButton } from './GlassButton';
|
||||
export { ToastProvider, useToast } from './Toast';
|
||||
export { Tooltip } from './Tooltip';
|
||||
export { GlassCard } from './GlassCard';
|
||||
export { GlassButton } from './GlassButton';
|
||||
export { ToastProvider, useToast } from './Toast';
|
||||
export { Tooltip } from './Tooltip';
|
||||
|
|
|
|||
486
src/index.css
486
src/index.css
|
|
@ -1,244 +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;
|
||||
@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;
|
||||
}
|
||||
110
start-dev.ps1
110
start-dev.ps1
|
|
@ -1,55 +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
|
||||
}
|
||||
# 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ GO_PID=$!
|
|||
echo "✨ Starting Frontend..."
|
||||
# Check for pnpm or fallback
|
||||
if command -v pnpm &> /dev/null; then
|
||||
pnpm run dev
|
||||
pnpm run dev:electron
|
||||
else
|
||||
# Fallback if pnpm is also missing from PATH but bun is there
|
||||
bun run dev
|
||||
|
|
|
|||
BIN
tracked_files.txt
Normal file
BIN
tracked_files.txt
Normal file
Binary file not shown.
Loading…
Reference in a new issue