369 lines
8.6 KiB
Go
369 lines
8.6 KiB
Go
//go:build darwin
|
|
|
|
package scanner
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Structs moved to scanner_common.go
|
|
|
|
// GetDiskUsage uses diskutil for accurate APFS disk usage
|
|
func GetDiskUsage() ([]*DiskUsage, error) {
|
|
cmd := exec.Command("diskutil", "info", "/")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lines := strings.Split(string(out), "\n")
|
|
|
|
var containerTotal, containerFree int64
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Parse "Container Total Space:" line
|
|
if strings.HasPrefix(line, "Container Total Space:") {
|
|
// Format: "Container Total Space: 245.1 GB (245107195904 Bytes)"
|
|
if start := strings.Index(line, "("); start != -1 {
|
|
if end := strings.Index(line[start:], " Bytes)"); end != -1 {
|
|
bytesStr := line[start+1 : start+end]
|
|
containerTotal, _ = strconv.ParseInt(bytesStr, 10, 64)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse "Container Free Space:" line
|
|
if strings.HasPrefix(line, "Container Free Space:") {
|
|
if start := strings.Index(line, "("); start != -1 {
|
|
if end := strings.Index(line[start:], " Bytes)"); end != -1 {
|
|
bytesStr := line[start+1 : start+end]
|
|
containerFree, _ = strconv.ParseInt(bytesStr, 10, 64)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate used space
|
|
containerUsed := containerTotal - containerFree
|
|
|
|
toGB := func(bytes int64) string {
|
|
gb := float64(bytes) / 1024 / 1024 / 1024
|
|
return fmt.Sprintf("%.2f", gb)
|
|
}
|
|
|
|
return []*DiskUsage{{
|
|
Name: "Macintosh HD",
|
|
TotalGB: toGB(containerTotal),
|
|
UsedGB: toGB(containerUsed),
|
|
FreeGB: toGB(containerFree),
|
|
}}, nil
|
|
}
|
|
|
|
// FindLargeFiles moved to scanner_common.go
|
|
|
|
// FindHeavyFolders uses `du` to find large directories
|
|
func FindHeavyFolders(root string) ([]ScanResult, error) {
|
|
// du -k -d 2 <root> | sort -nr | head -n 50
|
|
cmd := exec.Command("bash", "-c", fmt.Sprintf("du -k -d 2 \"%s\" | sort -nr | head -n 50", root))
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var results []ScanResult
|
|
lines := strings.Split(string(out), "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Attempt to parse first part as size
|
|
firstSpace := strings.IndexAny(line, " \t")
|
|
if firstSpace == -1 {
|
|
continue
|
|
}
|
|
|
|
sizeStr := line[:firstSpace]
|
|
pathStr := strings.TrimSpace(line[firstSpace:])
|
|
|
|
if pathStr == root {
|
|
continue
|
|
}
|
|
|
|
sizeK, err := strconv.ParseInt(sizeStr, 10, 64)
|
|
if err == nil {
|
|
results = append(results, ScanResult{
|
|
Path: pathStr,
|
|
Size: sizeK * 1024,
|
|
IsDirectory: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func ScanUserDocuments() ([]ScanResult, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
targets := []string{
|
|
filepath.Join(home, "Documents"),
|
|
filepath.Join(home, "Downloads"),
|
|
filepath.Join(home, "Desktop"),
|
|
}
|
|
|
|
var allResults []ScanResult
|
|
for _, t := range targets {
|
|
// 10MB threshold
|
|
res, _ := FindLargeFiles(t, 10*1024*1024)
|
|
allResults = append(allResults, res...)
|
|
}
|
|
|
|
// Sort combined
|
|
sort.Slice(allResults, func(i, j int) bool {
|
|
return allResults[i].Size > allResults[j].Size
|
|
})
|
|
|
|
if len(allResults) > 50 {
|
|
return allResults[:50], nil
|
|
}
|
|
return allResults, nil
|
|
}
|
|
|
|
func ScanSystemData() ([]ScanResult, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// System targets: Caches, Logs, Application Support (selective)
|
|
targets := []string{
|
|
filepath.Join(home, "Library", "Caches"),
|
|
filepath.Join(home, "Library", "Logs"),
|
|
filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData"),
|
|
}
|
|
|
|
var allResults []ScanResult
|
|
for _, t := range targets {
|
|
// 10MB threshold
|
|
res, _ := FindLargeFiles(t, 10*1024*1024)
|
|
allResults = append(allResults, res...)
|
|
}
|
|
|
|
sort.Slice(allResults, func(i, j int) bool {
|
|
return allResults[i].Size > allResults[j].Size
|
|
})
|
|
|
|
if len(allResults) > 50 {
|
|
return allResults[:50], nil
|
|
}
|
|
return allResults, nil
|
|
}
|
|
|
|
func GetDirectorySize(path string) int64 {
|
|
// du -s -k <path>
|
|
cmd := exec.Command("du", "-s", "-k", path)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
// Output is "size path"
|
|
parts := strings.Fields(string(out))
|
|
if len(parts) > 0 {
|
|
sizeK, _ := strconv.ParseInt(parts[0], 10, 64)
|
|
return sizeK * 1024 // Bytes
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func GetCategorySizes() (*CategorySizes, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Paths to check
|
|
docPath := filepath.Join(home, "Documents")
|
|
downPath := filepath.Join(home, "Downloads")
|
|
deskPath := filepath.Join(home, "Desktop")
|
|
musicPath := filepath.Join(home, "Music")
|
|
moviesPath := filepath.Join(home, "Movies")
|
|
|
|
caches := filepath.Join(home, "Library", "Caches")
|
|
logs := filepath.Join(home, "Library", "Logs")
|
|
xcode := filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")
|
|
|
|
trash := filepath.Join(home, ".Trash")
|
|
|
|
apps := "/Applications"
|
|
photos := filepath.Join(home, "Pictures")
|
|
icloud := filepath.Join(home, "Library", "Mobile Documents")
|
|
|
|
// Run in parallel
|
|
type result struct {
|
|
name string
|
|
size int64
|
|
}
|
|
// Increased buffer for new categories
|
|
c := make(chan result)
|
|
totalChecks := 12
|
|
|
|
check := func(name, p string) {
|
|
c <- result{name, GetDirectorySize(p)}
|
|
}
|
|
|
|
go check("docs", docPath)
|
|
go check("down", downPath)
|
|
go check("desk", deskPath)
|
|
go check("music", musicPath)
|
|
go check("movies", moviesPath)
|
|
|
|
go check("caches", caches)
|
|
go check("logs", logs)
|
|
go check("xcode", xcode)
|
|
|
|
go check("trash", trash)
|
|
go check("apps", apps)
|
|
go check("photos", photos)
|
|
go check("icloud", icloud)
|
|
|
|
sizes := &CategorySizes{}
|
|
|
|
// Get total disk usage to calculate System Data remainder using diskutil (APFS aware)
|
|
cmd := exec.Command("diskutil", "info", "/")
|
|
out, err := cmd.Output()
|
|
var totalUsed int64
|
|
|
|
if err == nil {
|
|
lines := strings.Split(string(out), "\n")
|
|
var containerTotal, containerFree int64
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "Container Total Space:") {
|
|
if start := strings.Index(line, "("); start != -1 {
|
|
if end := strings.Index(line[start:], " Bytes)"); end != -1 {
|
|
bytesStr := line[start+1 : start+end]
|
|
containerTotal, _ = strconv.ParseInt(bytesStr, 10, 64)
|
|
}
|
|
}
|
|
}
|
|
if strings.HasPrefix(line, "Container Free Space:") {
|
|
if start := strings.Index(line, "("); start != -1 {
|
|
if end := strings.Index(line[start:], " Bytes)"); end != -1 {
|
|
bytesStr := line[start+1 : start+end]
|
|
containerFree, _ = strconv.ParseInt(bytesStr, 10, 64)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
totalUsed = containerTotal - containerFree // In Bytes
|
|
}
|
|
|
|
var systemSpecific int64
|
|
|
|
for i := 0; i < totalChecks; i++ {
|
|
res := <-c
|
|
switch res.name {
|
|
case "docs":
|
|
sizes.Documents = res.size
|
|
case "down":
|
|
sizes.Downloads = res.size
|
|
case "desk":
|
|
sizes.Desktop = res.size
|
|
case "music":
|
|
sizes.Music = res.size
|
|
case "movies":
|
|
sizes.Movies = res.size
|
|
case "caches", "logs", "xcode":
|
|
systemSpecific += res.size
|
|
case "trash":
|
|
sizes.Trash += res.size
|
|
case "apps":
|
|
sizes.Apps = res.size
|
|
case "photos":
|
|
sizes.Photos = res.size
|
|
case "icloud":
|
|
sizes.ICloud = res.size
|
|
}
|
|
}
|
|
|
|
// Calculate System Data
|
|
// Ideally: System = Total - (Docs + Down + Desk + Music + Movies + Apps + Photos + iCloud + Trash)
|
|
known := sizes.Documents + sizes.Downloads + sizes.Desktop + sizes.Music + sizes.Movies + sizes.Trash + sizes.Apps + sizes.Photos + sizes.ICloud
|
|
|
|
var remainder int64 = 0
|
|
if totalUsed > known {
|
|
remainder = totalUsed - known
|
|
}
|
|
|
|
if remainder > systemSpecific {
|
|
sizes.System = remainder
|
|
} else {
|
|
sizes.System = systemSpecific
|
|
}
|
|
|
|
return sizes, nil
|
|
}
|
|
|
|
// CleaningEstimates struct moved to scanner_common.go
|
|
|
|
func GetCleaningEstimates() (*CleaningEstimates, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Paths for Flash Clean: Caches, Logs, Trash, Xcode
|
|
caches := filepath.Join(home, "Library", "Caches")
|
|
logs := filepath.Join(home, "Library", "Logs")
|
|
trash := filepath.Join(home, ".Trash")
|
|
xcode := filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")
|
|
|
|
// Paths for Deep Clean (proxy): Downloads
|
|
downloads := filepath.Join(home, "Downloads")
|
|
|
|
type result struct {
|
|
name string
|
|
size int64
|
|
}
|
|
c := make(chan result)
|
|
totalChecks := 5
|
|
|
|
check := func(name, p string) {
|
|
c <- result{name, GetDirectorySize(p)}
|
|
}
|
|
|
|
go check("caches", caches)
|
|
go check("logs", logs)
|
|
go check("trash", trash)
|
|
go check("xcode", xcode)
|
|
go check("downloads", downloads)
|
|
|
|
estimates := &CleaningEstimates{}
|
|
|
|
for i := 0; i < totalChecks; i++ {
|
|
res := <-c
|
|
switch res.name {
|
|
case "caches", "logs", "trash", "xcode":
|
|
estimates.FlashEst += res.size
|
|
case "downloads":
|
|
estimates.DeepEst += res.size
|
|
}
|
|
}
|
|
|
|
return estimates, nil
|
|
}
|