kv-clearnup/backend/internal/scanner/scanner_darwin.go
2026-02-02 08:33:46 +07:00

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
}