435 lines
11 KiB
Go
435 lines
11 KiB
Go
//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
|
|
}
|