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

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
}