diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5fe6e6..4f1530f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: jobs: tests: - name: Test + name: Unit & Integration Tests runs-on: macos-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index 103208b..1ee928d 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -83,7 +83,8 @@ type MemoryStatus struct { UsedPercent float64 SwapUsed uint64 SwapTotal uint64 - Pressure string // macOS memory pressure: normal/warn/critical + Cached uint64 // File cache that can be freed if needed + Pressure string // macOS memory pressure: normal/warn/critical } type DiskStatus struct { @@ -115,6 +116,7 @@ type BatteryStatus struct { TimeLeft string Health string CycleCount int + Capacity int // Maximum capacity percentage (e.g., 85 means 85% of original) } type ThermalStatus struct { diff --git a/cmd/status/metrics_battery.go b/cmd/status/metrics_battery.go index bbf4f73..ecdb463 100644 --- a/cmd/status/metrics_battery.go +++ b/cmd/status/metrics_battery.go @@ -32,9 +32,9 @@ func collectBatteries() (batts []BatteryStatus, err error) { // macOS: pmset for real-time percentage/status. if runtime.GOOS == "darwin" && commandExists("pmset") { if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { - // Health/cycles from cached system_profiler. - health, cycles := getCachedPowerData() - if batts := parsePMSet(out, health, cycles); len(batts) > 0 { + // Health/cycles/capacity from cached system_profiler. + health, cycles, capacity := getCachedPowerData() + if batts := parsePMSet(out, health, cycles, capacity); len(batts) > 0 { return batts, nil } } @@ -67,7 +67,7 @@ func collectBatteries() (batts []BatteryStatus, err error) { return nil, errors.New("no battery data found") } -func parsePMSet(raw string, health string, cycles int) []BatteryStatus { +func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus { lines := strings.Split(raw, "\n") var out []BatteryStatus var timeLeft string @@ -115,16 +115,17 @@ func parsePMSet(raw string, health string, cycles int) []BatteryStatus { TimeLeft: timeLeft, Health: health, CycleCount: cycles, + Capacity: capacity, }) } return out } -// getCachedPowerData returns condition and cycles from cached system_profiler. -func getCachedPowerData() (health string, cycles int) { +// getCachedPowerData returns condition, cycles, and capacity from cached system_profiler. +func getCachedPowerData() (health string, cycles int, capacity int) { out := getSystemPowerOutput() if out == "" { - return "", 0 + return "", 0, 0 } lines := strings.Split(out, "\n") @@ -140,8 +141,15 @@ func getCachedPowerData() (health string, cycles int) { health = strings.TrimSpace(after) } } + if strings.Contains(lower, "maximum capacity") { + if _, after, found := strings.Cut(line, ":"); found { + capacityStr := strings.TrimSpace(after) + capacityStr = strings.TrimSuffix(capacityStr, "%") + capacity, _ = strconv.Atoi(strings.TrimSpace(capacityStr)) + } + } } - return health, cycles + return health, cycles, capacity } func getSystemPowerOutput() string { diff --git a/cmd/status/metrics_memory.go b/cmd/status/metrics_memory.go index e23c258..5851dd5 100644 --- a/cmd/status/metrics_memory.go +++ b/cmd/status/metrics_memory.go @@ -3,6 +3,7 @@ package main import ( "context" "runtime" + "strconv" "strings" "time" @@ -18,16 +19,62 @@ func collectMemory() (MemoryStatus, error) { swap, _ := mem.SwapMemory() pressure := getMemoryPressure() + // On macOS, vm.Cached is 0, so we calculate from file-backed pages. + cached := vm.Cached + if runtime.GOOS == "darwin" && cached == 0 { + cached = getFileBackedMemory() + } + return MemoryStatus{ Used: vm.Used, Total: vm.Total, UsedPercent: vm.UsedPercent, SwapUsed: swap.Used, SwapTotal: swap.Total, + Cached: cached, Pressure: pressure, }, nil } +func getFileBackedMemory() uint64 { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + out, err := runCmd(ctx, "vm_stat") + if err != nil { + return 0 + } + + // Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)" + var pageSize uint64 = 4096 // Default + lines := strings.Split(out, "\n") + if len(lines) > 0 { + firstLine := lines[0] + if strings.Contains(firstLine, "page size of") { + if _, after, found := strings.Cut(firstLine, "page size of "); found { + if before, _, found := strings.Cut(after, " bytes"); found { + if size, err := strconv.ParseUint(strings.TrimSpace(before), 10, 64); err == nil { + pageSize = size + } + } + } + } + } + + // Parse "File-backed pages: 388975." + for _, line := range lines { + if strings.Contains(line, "File-backed pages:") { + if _, after, found := strings.Cut(line, ":"); found { + numStr := strings.TrimSpace(after) + numStr = strings.TrimSuffix(numStr, ".") + if pages, err := strconv.ParseUint(numStr, 10, 64); err == nil { + return pages * pageSize + } + } + } + } + return 0 +} + func getMemoryPressure() string { if runtime.GOOS != "darwin" { return "" diff --git a/cmd/status/view.go b/cmd/status/view.go index 8c15a1b..8eb0f4e 100644 --- a/cmd/status/view.go +++ b/cmd/status/view.go @@ -5,19 +5,18 @@ import ( "sort" "strconv" "strings" - "time" "github.com/charmbracelet/lipgloss" ) var ( - titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true) - subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#737373")) - warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")) - dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true) - okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7")) - lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040")) - hatStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4D4D")) + titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true) + subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#737373")) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")) + dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7")) + lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040")) + primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")) ) @@ -33,14 +32,6 @@ const ( iconProcs = "❊" ) -// isChristmasSeason reports Dec 10-31. -func isChristmasSeason() bool { - now := time.Now() - month := now.Month() - day := now.Day() - return month == time.December && day >= 10 && day <= 31 -} - // Mole body frames. var moleBody = [][]string{ { @@ -69,55 +60,10 @@ var moleBody = [][]string{ }, } -// Mole body frames with Christmas hat. -var moleBodyWithHat = [][]string{ - { - ` *`, - ` /o\`, - ` {/\_/\}`, - ` ___/ o o \`, - ` /___ =-= /`, - ` \____)-m-m)`, - }, - { - ` *`, - ` /o\`, - ` {/\_/\}`, - ` ___/ o o \`, - ` /___ =-= /`, - ` \____)mm__)`, - }, - { - ` *`, - ` /o\`, - ` {/\_/\}`, - ` ___/ · · \`, - ` /___ =-= /`, - ` \___)-m__m)`, - }, - { - ` *`, - ` /o\`, - ` {/\_/\}`, - ` ___/ o o \`, - ` /___ =-= /`, - ` \____)-mm-)`, - }, -} - // getMoleFrame renders the animated mole. func getMoleFrame(animFrame int, termWidth int) string { - var body []string - var bodyIdx int - isChristmas := isChristmasSeason() - - if isChristmas { - bodyIdx = animFrame % len(moleBodyWithHat) - body = moleBodyWithHat[bodyIdx] - } else { - bodyIdx = animFrame % len(moleBody) - body = moleBody[bodyIdx] - } + bodyIdx := animFrame % len(moleBody) + body := moleBody[bodyIdx] moleWidth := 15 maxPos := termWidth - moleWidth @@ -137,18 +83,8 @@ func getMoleFrame(animFrame int, termWidth int) string { padding := strings.Repeat(" ", pos) var lines []string - if isChristmas { - for i, line := range body { - if i < 3 { - lines = append(lines, padding+hatStyle.Render(line)) - } else { - lines = append(lines, padding+line) - } - } - } else { - for _, line := range body { - lines = append(lines, padding+line) - } + for _, line := range body { + lines = append(lines, padding+line) } return strings.Join(lines, "\n") @@ -193,7 +129,7 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int infoParts = append(infoParts, m.Hardware.OSVersion) } - headerLine := title + " " + scoreText + " " + subtleStyle.Render(strings.Join(infoParts, " · ")) + headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ") mole := getMoleFrame(animFrame, termWidth) @@ -245,14 +181,6 @@ func renderCPUCard(cpu CPUStatus) cardData { var lines []string lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage)) - if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 { - lines = append(lines, subtleStyle.Render(fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)", - cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount))) - } else { - lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%d cores)", - cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU))) - } - if cpu.PerCoreEstimated { lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)")) } else if len(cpu.PerCore) > 0 { @@ -276,6 +204,15 @@ func renderCPUCard(cpu CPUStatus) cardData { } } + // Load line at the end + if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 { + lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)", + cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount)) + } else { + lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%d cores)", + cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU)) + } + return cardData{icon: iconCPU, title: "CPU", lines: lines} } @@ -290,9 +227,9 @@ func renderGPUCard(gpus []GPUStatus) cardData { } coreInfo := "" if g.CoreCount > 0 { - coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount) + coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount) } - lines = append(lines, subtleStyle.Render(g.Name+coreInfo)) + lines = append(lines, g.Name+coreInfo) if g.Usage < 0 { lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics")) } @@ -302,22 +239,48 @@ func renderGPUCard(gpus []GPUStatus) cardData { } func renderMemoryCard(mem MemoryStatus) cardData { + // Check if swap is being used (or at least allocated). + hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0 + var lines []string + // Line 1: Used lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent)) - lines = append(lines, subtleStyle.Render(fmt.Sprintf("%s / %s total", humanBytes(mem.Used), humanBytes(mem.Total)))) - available := mem.Total - mem.Used + + // Line 2: Free freePercent := 100 - mem.UsedPercent lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent)) - lines = append(lines, subtleStyle.Render(fmt.Sprintf("%s available", humanBytes(available)))) - if mem.SwapTotal > 0 || mem.SwapUsed > 0 { + + if hasSwap { + // Layout with Swap: + // 3. Swap (progress bar + text) + // 4. Total + // 5. Avail var swapPercent float64 if mem.SwapTotal > 0 { swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0 } - swapText := subtleStyle.Render(fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal))) + swapText := fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal)) lines = append(lines, fmt.Sprintf("Swap %s %5.1f%% %s", progressBar(swapPercent), swapPercent, swapText)) + + lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) + lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Total-mem.Used))) // Simplified avail logic for consistency } else { - lines = append(lines, fmt.Sprintf("Swap %s", subtleStyle.Render("not in use"))) + // Layout without Swap: + // 3. Total + // 4. Cached (if > 0) + // 5. Avail + lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) + + if mem.Cached > 0 { + lines = append(lines, fmt.Sprintf("Cached %s", humanBytes(mem.Cached))) + } + // Calculate available if not provided directly, or use Total-Used as proxy if needed, + // but typically available is more nuanced. Using what we have. + // Re-calculating available based on logic if needed, but mem.Total - mem.Used is often "Avail" + // in simple terms for this view or we could use the passed definition. + // Original code calculated: available := mem.Total - mem.Used + available := mem.Total - mem.Used + lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(available))) } // Memory pressure status. if mem.Pressure != "" { @@ -464,7 +427,7 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData { infoParts = append(infoParts, primaryIP) } if len(infoParts) > 0 { - lines = append(lines, subtleStyle.Render(strings.Join(infoParts, " · "))) + lines = append(lines, strings.Join(infoParts, " · ")) } } return cardData{icon: iconNetwork, title: "Network", lines: lines} @@ -501,6 +464,17 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { } lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText)) + // Add capacity line if available. + if b.Capacity > 0 { + capacityText := fmt.Sprintf("%5d%%", b.Capacity) + if b.Capacity < 70 { + capacityText = dangerStyle.Render(capacityText) + } else if b.Capacity < 85 { + capacityText = warnStyle.Render(capacityText) + } + lines = append(lines, fmt.Sprintf("Health %s %s", batteryProgressBar(float64(b.Capacity)), capacityText)) + } + statusIcon := "" statusStyle := subtleStyle if statusLower == "charging" || statusLower == "charged" { @@ -537,13 +511,13 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { } if thermal.CPUTemp > 0 { - tempStyle := subtleStyle + tempText := fmt.Sprintf("%.0f°C", thermal.CPUTemp) if thermal.CPUTemp > 80 { - tempStyle = dangerStyle + tempText = dangerStyle.Render(tempText) } else if thermal.CPUTemp > 60 { - tempStyle = warnStyle + tempText = warnStyle.Render(tempText) } - healthParts = append(healthParts, tempStyle.Render(fmt.Sprintf("%.0f°C", thermal.CPUTemp))) + healthParts = append(healthParts, tempText) } if thermal.FanSpeed > 0 { @@ -551,7 +525,7 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { } if len(healthParts) > 0 { - lines = append(lines, subtleStyle.Render(strings.Join(healthParts, " · "))) + lines = append(lines, strings.Join(healthParts, " · ")) } } diff --git a/lib/core/base.sh b/lib/core/base.sh index 9b29e26..2c216a7 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -55,21 +55,6 @@ readonly MOLE_TM_BACKUP_SAFE_HOURS=48 # TM backup safety window (hours) readonly MOLE_MAX_DS_STORE_FILES=500 # Max .DS_Store files to clean per scan readonly MOLE_MAX_ORPHAN_ITERATIONS=100 # Max iterations for orphaned app data scan -# ============================================================================ -# Seasonal Functions -# ============================================================================ -is_christmas_season() { - local month day - month=$(date +%-m) - day=$(date +%-d) - - # December 10 to December 31 - if [[ $month -eq 12 && $day -ge 10 && $day -le 31 ]]; then - return 0 - fi - return 1 -} - # ============================================================================ # Whitelist Configuration # ============================================================================ diff --git a/mole b/mole index ed0814a..e3a3d5d 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.17.0" +VERSION="1.18.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { @@ -129,24 +129,9 @@ animate_mole_intro() { local -a mole_lines=() - if is_christmas_season; then - while IFS= read -r line; do - mole_lines+=("$line") - done << 'EOF' - * - /o\ - {/\_/\} - ____/ o o \ - /~____ =o= / -(______)__m_m) - / \ - __/ /\ \__ - /__/ \__\_ -EOF - else - while IFS= read -r line; do - mole_lines+=("$line") - done << 'EOF' + while IFS= read -r line; do + mole_lines+=("$line") + done << 'EOF' /\_/\ ____/ o o \ /~____ =o= / @@ -155,37 +140,20 @@ EOF __/ /\ \__ /__/ \__\_ EOF - fi local idx - local hat_color="${RED}" - local body_cutoff + local body_cutoff=4 local body_color="${PURPLE}" local ground_color="${GREEN}" - if is_christmas_season; then - body_cutoff=6 - for idx in "${!mole_lines[@]}"; do - if ((idx < 3)); then - printf "%s\n" "${hat_color}${mole_lines[$idx]}${NC}" - elif ((idx < body_cutoff)); then - printf "%s\n" "${body_color}${mole_lines[$idx]}${NC}" - else - printf "%s\n" "${ground_color}${mole_lines[$idx]}${NC}" - fi - sleep 0.1 - done - else - body_cutoff=4 - for idx in "${!mole_lines[@]}"; do - if ((idx < body_cutoff)); then - printf "%s\n" "${body_color}${mole_lines[$idx]}${NC}" - else - printf "%s\n" "${ground_color}${mole_lines[$idx]}${NC}" - fi - sleep 0.1 - done - fi + for idx in "${!mole_lines[@]}"; do + if ((idx < body_cutoff)); then + printf "%s\n" "${body_color}${mole_lines[$idx]}${NC}" + else + printf "%s\n" "${ground_color}${mole_lines[$idx]}${NC}" + fi + sleep 0.1 + done printf '\n' sleep 0.5