package main
import (
"context"
"errors"
"fmt"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/shirou/gopsutil/v4/disk"
)
var skipDiskMounts = map[string]bool{
"/System/Volumes/VM": true,
"/System/Volumes/Preboot": true,
"/System/Volumes/Update": true,
"/System/Volumes/xarts": true,
"/System/Volumes/Hardware": true,
"/System/Volumes/Data": true,
"/dev": true,
}
var skipDiskFSTypes = map[string]bool{
"afpfs": true,
"autofs": true,
"cifs": true,
"devfs": true,
"fuse": true,
"fuseblk": true,
"fusefs": true,
"macfuse": true,
"nfs": true,
"osxfuse": true,
"procfs": true,
"smbfs": true,
"tmpfs": true,
"webdav": true,
}
func collectDisks() ([]DiskStatus, error) {
partitions, err := disk.Partitions(false)
if err != nil {
return nil, err
}
var (
disks []DiskStatus
seenDevice = make(map[string]bool)
seenVolume = make(map[string]bool)
)
for _, part := range partitions {
if shouldSkipDiskPartition(part) {
continue
}
baseDevice := baseDeviceName(part.Device)
if baseDevice == "" {
baseDevice = part.Device
}
if seenDevice[baseDevice] {
continue
}
usage, err := disk.Usage(part.Mountpoint)
if err != nil || usage.Total == 0 {
continue
}
// Skip <1GB volumes.
if usage.Total < 1<<30 {
continue
}
// Use size-based dedupe key for shared pools.
volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total)
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: used,
Total: usage.Total,
UsedPercent: usedPercent,
Fstype: part.Fstype,
})
seenDevice[baseDevice] = true
seenVolume[volKey] = true
}
annotateDiskTypes(disks)
sort.Slice(disks, func(i, j int) bool {
// First, prefer internal disks over external
if disks[i].External != disks[j].External {
return !disks[i].External
}
// Then sort by size (largest first)
return disks[i].Total > disks[j].Total
})
if len(disks) > 3 {
disks = disks[:3]
}
return disks, nil
}
func shouldSkipDiskPartition(part disk.PartitionStat) bool {
if strings.HasPrefix(part.Device, "/dev/loop") {
return true
}
if skipDiskMounts[part.Mountpoint] {
return true
}
if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") {
return true
}
if strings.HasPrefix(part.Mountpoint, "/private/") {
return true
}
fstype := strings.ToLower(part.Fstype)
if skipDiskFSTypes[fstype] || strings.Contains(fstype, "fuse") {
return true
}
// On macOS, local disks should come from /dev. This filters sshfs/macFUSE-style
// mounts that can mirror the root volume and show up as duplicate internal disks.
if runtime.GOOS == "darwin" && part.Device != "" && !strings.HasPrefix(part.Device, "/dev/") {
return true
}
return false
}
var (
// External disk cache.
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) {
if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") {
return
}
now := time.Now()
// Clear stale cache.
if now.Sub(lastDiskCacheAt) > diskCacheTTL {
diskTypeCache = make(map[string]bool)
lastDiskCacheAt = now
}
for i := range disks {
base := baseDeviceName(disks[i].Device)
if base == "" {
base = disks[i].Device
}
if val, ok := diskTypeCache[base]; ok {
disks[i].External = val
continue
}
external, err := isExternalDisk(base)
if err != nil {
external = strings.HasPrefix(disks[i].Mount, "/Volumes/")
}
disks[i].External = external
diskTypeCache[base] = external
}
}
func baseDeviceName(device string) string {
device = strings.TrimPrefix(device, "/dev/")
if !strings.HasPrefix(device, "disk") {
return device
}
for i := 4; i < len(device); i++ {
if device[i] == 's' {
return device[:i]
}
}
return device
}
func isExternalDisk(device string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
out, err := runCmd(ctx, "diskutil", "info", device)
if err != nil {
return false, err
}
var (
found bool
external bool
)
for line := range strings.Lines(out) {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "Internal:") {
found = true
external = strings.Contains(trim, "No")
break
}
if strings.HasPrefix(trim, "Device Location:") {
found = true
external = strings.Contains(trim, "External")
}
}
if !found {
return false, errors.New("diskutil info missing Internal field")
}
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 {
return DiskIOStatus{}
}
var total disk.IOCountersStat
for _, v := range counters {
total.ReadBytes += v.ReadBytes
total.WriteBytes += v.WriteBytes
}
if c.lastDiskAt.IsZero() {
c.prevDiskIO = total
c.lastDiskAt = now
return DiskIOStatus{}
}
elapsed := now.Sub(c.lastDiskAt).Seconds()
if elapsed <= 0 {
elapsed = 1
}
readRate := float64(total.ReadBytes-c.prevDiskIO.ReadBytes) / 1024 / 1024 / elapsed
writeRate := float64(total.WriteBytes-c.prevDiskIO.WriteBytes) / 1024 / 1024 / elapsed
c.prevDiskIO = total
c.lastDiskAt = now
if readRate < 0 {
readRate = 0
}
if writeRate < 0 {
writeRate = 0
}
return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate}
}