From f6acfa774ca6b5bba80ebb739bf6e876a16cb3a3 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 14 Mar 2026 07:48:16 +0800 Subject: [PATCH] feat(disk): enhance APFS disk usage reporting with Finder integration --- cmd/status/metrics_disk.go | 127 ++++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 2 deletions(-) diff --git a/cmd/status/metrics_disk.go b/cmd/status/metrics_disk.go index 8c8202b..9f2fc48 100644 --- a/cmd/status/metrics_disk.go +++ b/cmd/status/metrics_disk.go @@ -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 = "APFSContainerFree" + idx := strings.Index(out, key) + if idx == -1 { + return 0, fmt.Errorf("APFSContainerFree not found") + } + + rest := out[idx+len(key):] + start := strings.Index(rest, "") + if start == -1 { + return 0, fmt.Errorf("APFSContainerFree value not found") + } + rest = rest[start+len(""):] + end := strings.Index(rest, "") + 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 {