mirror of
https://github.com/tw93/Mole.git
synced 2026-03-22 22:30:08 +00:00
feat(disk): enhance APFS disk usage reporting with Finder integration
This commit is contained in:
@@ -6,7 +6,9 @@ import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
@@ -74,12 +76,18 @@ func collectDisks() ([]DiskStatus, error) {
|
||||
if seenVolume[volKey] {
|
||||
continue
|
||||
}
|
||||
used := usage.Used
|
||||
usedPercent := usage.UsedPercent
|
||||
if runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" {
|
||||
used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, usage.Total, usage.Used)
|
||||
}
|
||||
|
||||
disks = append(disks, DiskStatus{
|
||||
Mount: part.Mountpoint,
|
||||
Device: part.Device,
|
||||
Used: usage.Used,
|
||||
Used: used,
|
||||
Total: usage.Total,
|
||||
UsedPercent: usage.UsedPercent,
|
||||
UsedPercent: usedPercent,
|
||||
Fstype: part.Fstype,
|
||||
})
|
||||
seenDevice[baseDevice] = true
|
||||
@@ -137,6 +145,12 @@ var (
|
||||
lastDiskCacheAt time.Time
|
||||
diskTypeCache = make(map[string]bool)
|
||||
diskCacheTTL = 2 * time.Minute
|
||||
|
||||
// Finder startup disk usage cache (macOS APFS purgeable-aware).
|
||||
finderDiskCacheMu sync.Mutex
|
||||
finderDiskCachedAt time.Time
|
||||
finderDiskFree uint64
|
||||
finderDiskTotal uint64
|
||||
)
|
||||
|
||||
func annotateDiskTypes(disks []DiskStatus) {
|
||||
@@ -214,6 +228,115 @@ func isExternalDisk(device string) (bool, error) {
|
||||
return external, nil
|
||||
}
|
||||
|
||||
// correctAPFSDiskUsage returns Finder-accurate used bytes and percent for an
|
||||
// APFS volume, accounting for purgeable caches and APFS local snapshots that
|
||||
// statfs incorrectly counts as "used". Uses a three-tier fallback:
|
||||
// 1. Finder via osascript (startup disk only) — exact match with macOS Finder
|
||||
// 2. diskutil APFSContainerFree — corrects APFS snapshot space
|
||||
// 3. Raw gopsutil values — original statfs-based calculation
|
||||
func correctAPFSDiskUsage(mountpoint string, total, rawUsed uint64) (used uint64, usedPercent float64) {
|
||||
// Tier 1: Finder via osascript (startup disk at "/" only).
|
||||
if mountpoint == "/" && commandExists("osascript") {
|
||||
if finderFree, finderTotal, err := getFinderStartupDiskFreeBytes(); err == nil &&
|
||||
finderTotal > 0 && finderFree <= finderTotal {
|
||||
used = finderTotal - finderFree
|
||||
usedPercent = float64(used) / float64(finderTotal) * 100.0
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: diskutil APFSContainerFree (corrects APFS local snapshots).
|
||||
if commandExists("diskutil") {
|
||||
if containerFree, err := getAPFSContainerFreeBytes(mountpoint); err == nil && containerFree <= total {
|
||||
corrected := total - containerFree
|
||||
// Only apply if it meaningfully differs (>1GB) from raw to avoid noise.
|
||||
if rawUsed > corrected && rawUsed-corrected > 1<<30 {
|
||||
used = corrected
|
||||
usedPercent = float64(used) / float64(total) * 100.0
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: fall back to raw gopsutil values.
|
||||
return rawUsed, float64(rawUsed) / float64(total) * 100.0
|
||||
}
|
||||
|
||||
// getAPFSContainerFreeBytes returns the APFS container free space (including
|
||||
// purgeable snapshot space) by parsing `diskutil info -plist`. This corrects
|
||||
// for APFS local snapshots which statfs counts as used.
|
||||
func getAPFSContainerFreeBytes(mountpoint string) (uint64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := runCmd(ctx, "diskutil", "info", "-plist", mountpoint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
const key = "<key>APFSContainerFree</key>"
|
||||
idx := strings.Index(out, key)
|
||||
if idx == -1 {
|
||||
return 0, fmt.Errorf("APFSContainerFree not found")
|
||||
}
|
||||
|
||||
rest := out[idx+len(key):]
|
||||
start := strings.Index(rest, "<integer>")
|
||||
if start == -1 {
|
||||
return 0, fmt.Errorf("APFSContainerFree value not found")
|
||||
}
|
||||
rest = rest[start+len("<integer>"):]
|
||||
end := strings.Index(rest, "</integer>")
|
||||
if end == -1 {
|
||||
return 0, fmt.Errorf("APFSContainerFree end tag not found")
|
||||
}
|
||||
|
||||
val, err := strconv.ParseUint(strings.TrimSpace(rest[:end]), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse APFSContainerFree: %v", err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// getFinderStartupDiskFreeBytes queries Finder via osascript for the startup
|
||||
// disk free space. Finder's value includes purgeable caches and APFS snapshots,
|
||||
// matching the "X GB of Y GB used" display. Results are cached for 2 minutes.
|
||||
func getFinderStartupDiskFreeBytes() (free, total uint64, err error) {
|
||||
finderDiskCacheMu.Lock()
|
||||
defer finderDiskCacheMu.Unlock()
|
||||
|
||||
if !finderDiskCachedAt.IsZero() && time.Since(finderDiskCachedAt) < diskCacheTTL {
|
||||
return finderDiskFree, finderDiskTotal, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Single call returns both values as a comma-separated pair.
|
||||
out, err := runCmd(ctx, "osascript", "-e",
|
||||
`tell application "Finder" to return {free space of startup disk, capacity of startup disk}`)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// Output format: "3.2489E+11, 4.9438E+11" or "324892202048, 494384795648"
|
||||
parts := strings.SplitN(strings.TrimSpace(out), ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("unexpected osascript output: %q", out)
|
||||
}
|
||||
|
||||
freeF, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
totalF, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if err1 != nil || err2 != nil || freeF <= 0 || totalF <= 0 {
|
||||
return 0, 0, fmt.Errorf("failed to parse osascript output: %q", out)
|
||||
}
|
||||
|
||||
finderDiskFree = uint64(freeF)
|
||||
finderDiskTotal = uint64(totalF)
|
||||
finderDiskCachedAt = time.Now()
|
||||
return finderDiskFree, finderDiskTotal, nil
|
||||
}
|
||||
|
||||
func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus {
|
||||
counters, err := disk.IOCounters()
|
||||
if err != nil || len(counters) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user