feat: finalize app features, menubar icons, and release binaries
This commit is contained in:
parent
8e30ac6825
commit
eb4a400736
34 changed files with 9886 additions and 9262 deletions
60
.gitignore
vendored
60
.gitignore
vendored
|
|
@ -1,30 +1,30 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
# Release Artifacts
|
||||
Release/
|
||||
release/
|
||||
*.zip
|
||||
*.exe
|
||||
backend/dist/
|
||||
# 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-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
|
||||
# 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": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "dist-electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:electron": "node scripts/build-electron.mjs && concurrently -k \"vite\" \"wait-on tcp:5173 && cross-env NODE_ENV=development electron dist-electron/main.cjs\"",
|
||||
"electron:build": "node scripts/build-electron.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:go:mac": "sh scripts/build-go.sh",
|
||||
"build:mac": "pnpm run build:go:mac && pnpm run build && pnpm run electron:build && electron-builder --mac --universal",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"preinstall": "node scripts/check-pnpm.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.29.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^26.4.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4",
|
||||
"wait-on": "^8.0.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.kv.clearnup",
|
||||
"productName": "KV Clearnup",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"compression": "maximum",
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"category": "public.app-category.utilities",
|
||||
"hardenedRuntime": false
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "backend/dist/universal/backend",
|
||||
"to": "backend"
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
"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
|
||||
|
|
|
|||
Loading…
Reference in a new issue