macverdict 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- macverdict/__init__.py +8 -0
- macverdict/__main__.py +3 -0
- macverdict/checks.py +147 -0
- macverdict/cli.py +336 -0
- macverdict/collectors.py +245 -0
- macverdict/fmt.py +37 -0
- macverdict/insights.py +434 -0
- macverdict/notifier/MacverdictNotifier.swift +121 -0
- macverdict/notifier/__init__.py +129 -0
- macverdict/report.py +221 -0
- macverdict/scheduler.py +140 -0
- macverdict/storage.py +144 -0
- macverdict-0.2.0.dist-info/METADATA +227 -0
- macverdict-0.2.0.dist-info/RECORD +17 -0
- macverdict-0.2.0.dist-info/WHEEL +4 -0
- macverdict-0.2.0.dist-info/entry_points.txt +2 -0
- macverdict-0.2.0.dist-info/licenses/LICENSE +21 -0
macverdict/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""macverdict: tells you why your Mac was slow, and what to do about it. (Formerly macpulse.)"""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
|
+
|
|
5
|
+
from .checks import Finding, evaluate, health_score
|
|
6
|
+
from .collectors import collect
|
|
7
|
+
|
|
8
|
+
__all__ = ["collect", "evaluate", "health_score", "Finding", "__version__"]
|
macverdict/__main__.py
ADDED
macverdict/checks.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Turn a snapshot into findings with plain-English advice, and a 0-100 score."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .fmt import human_bytes
|
|
9
|
+
|
|
10
|
+
GB = 1024 ** 3
|
|
11
|
+
LEVELS = ("ok", "info", "warn", "critical")
|
|
12
|
+
PENALTY = {"ok": 0, "info": 2, "warn": 10, "critical": 25}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Finding:
|
|
17
|
+
level: str # one of LEVELS
|
|
18
|
+
area: str
|
|
19
|
+
message: str
|
|
20
|
+
advice: str = ""
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict[str, Any]:
|
|
23
|
+
return asdict(self)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cpu(s: dict) -> list[Finding]:
|
|
27
|
+
cpu, cores = s["cpu"], s["system"]["cpu_count"] or 1
|
|
28
|
+
load_ratio = cpu["load_avg"][1] / cores
|
|
29
|
+
out = []
|
|
30
|
+
if cpu["percent"] >= 90:
|
|
31
|
+
out.append(Finding("warn", "cpu", f"CPU is busy at {cpu['percent']:.0f}%.",
|
|
32
|
+
"Check the top CPU processes below; quit anything you are not using."))
|
|
33
|
+
if load_ratio >= 1.5:
|
|
34
|
+
out.append(Finding("warn", "cpu", f"5-minute load is {cpu['load_avg'][1]:.1f} on {cores} cores.",
|
|
35
|
+
"Sustained load above core count means work is queuing up."))
|
|
36
|
+
if not out:
|
|
37
|
+
out.append(Finding("ok", "cpu", f"CPU {cpu['percent']:.0f}%, load {cpu['load_avg'][1]:.1f} on {cores} cores."))
|
|
38
|
+
for p in s.get("top_cpu", [])[:1]:
|
|
39
|
+
if p["cpu_percent"] >= 80:
|
|
40
|
+
out.append(Finding("info", "cpu", f"{p['name']} (pid {p['pid']}) is using {p['cpu_percent']:.0f}% CPU.",
|
|
41
|
+
"Expected if it is doing real work; otherwise it may be stuck."))
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _memory(s: dict) -> list[Finding]:
|
|
46
|
+
m = s["memory"]
|
|
47
|
+
level = (m.get("pressure") or {}).get("level")
|
|
48
|
+
swap = m["swap_used"]
|
|
49
|
+
out = []
|
|
50
|
+
if level == "critical":
|
|
51
|
+
out.append(Finding("critical", "memory", "Memory pressure is CRITICAL.",
|
|
52
|
+
"macOS is compressing and swapping heavily. Close large apps or browser tabs."))
|
|
53
|
+
elif level == "warn":
|
|
54
|
+
out.append(Finding("warn", "memory", "Memory pressure is elevated.",
|
|
55
|
+
"Close apps you are not using; this is the main cause of slowdowns on 8-16 GB Macs."))
|
|
56
|
+
if swap >= 6 * GB:
|
|
57
|
+
out.append(Finding("critical", "memory", f"Swap in use: {human_bytes(swap)}.",
|
|
58
|
+
"Heavy swapping wears the SSD and slows everything. A restart clears it."))
|
|
59
|
+
elif swap >= 2 * GB:
|
|
60
|
+
out.append(Finding("warn", "memory", f"Swap in use: {human_bytes(swap)}.",
|
|
61
|
+
"Some swapping is normal, but sustained growth means you need more free RAM."))
|
|
62
|
+
if not out:
|
|
63
|
+
out.append(Finding("ok", "memory", f"Memory pressure {level or 'n/a'}, {m['percent']:.0f}% used, "
|
|
64
|
+
f"swap {human_bytes(swap)}."))
|
|
65
|
+
biggest = (s.get("apps") or s.get("top_memory") or [])[:1]
|
|
66
|
+
for p in biggest:
|
|
67
|
+
if p["rss"] >= 0.25 * m["total"]:
|
|
68
|
+
out.append(Finding("info", "memory", f"{p['name']} is using {human_bytes(p['rss'])} of RAM.",
|
|
69
|
+
"Restarting a long-running app often returns leaked memory."))
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _disk(s: dict) -> list[Finding]:
|
|
74
|
+
d = s["disk"]
|
|
75
|
+
pct, free = d["free_percent"], d["free"]
|
|
76
|
+
if pct < 5 or free < 10 * GB:
|
|
77
|
+
return [Finding("critical", "disk", f"Only {human_bytes(free)} free ({pct:.0f}%).",
|
|
78
|
+
"macOS needs free space for swap and updates. Clear Downloads, caches, or old iOS backups.")]
|
|
79
|
+
if pct < 15:
|
|
80
|
+
return [Finding("warn", "disk", f"Disk is getting full: {human_bytes(free)} free ({pct:.0f}%).",
|
|
81
|
+
"Use System Settings > General > Storage to find large files.")]
|
|
82
|
+
return [Finding("ok", "disk", f"{human_bytes(free)} free ({pct:.0f}%).")]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _battery(s: dict) -> list[Finding]:
|
|
86
|
+
b = s.get("battery")
|
|
87
|
+
if not b:
|
|
88
|
+
return []
|
|
89
|
+
out = []
|
|
90
|
+
health = b.get("max_capacity_percent") or b.get("health_percent")
|
|
91
|
+
if health is not None and health < 80:
|
|
92
|
+
out.append(Finding("warn", "battery", f"Battery maximum capacity is {health:.0f}%.",
|
|
93
|
+
"Apple considers batteries below 80% consumed; consider a service appointment."))
|
|
94
|
+
if (b.get("cycle_count") or 0) >= 1000:
|
|
95
|
+
out.append(Finding("info", "battery", f"Battery has {b['cycle_count']} cycles (rated for 1000).", ""))
|
|
96
|
+
temp = b.get("temperature_c")
|
|
97
|
+
if temp is not None and temp >= 45:
|
|
98
|
+
out.append(Finding("critical", "battery", f"Battery is hot: {temp:.0f}°C.",
|
|
99
|
+
"Move the Mac off soft surfaces and out of the sun; stop heavy work until it cools."))
|
|
100
|
+
elif temp is not None and temp >= 38:
|
|
101
|
+
out.append(Finding("warn", "battery", f"Battery is warm: {temp:.0f}°C.",
|
|
102
|
+
"The MacBook Air is fanless, so sustained heat leads to throttling."))
|
|
103
|
+
if not out:
|
|
104
|
+
out.append(Finding("ok", "battery", f"{b['percent']:.0f}% charged, capacity {health}%, "
|
|
105
|
+
f"{b.get('cycle_count')} cycles, {temp}°C."))
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _thermal(s: dict) -> list[Finding]:
|
|
110
|
+
t = s["thermal"]
|
|
111
|
+
state = t.get("state")
|
|
112
|
+
if state == "critical":
|
|
113
|
+
return [Finding("critical", "thermal", "macOS reports CRITICAL heat and is slowing the Mac hard.",
|
|
114
|
+
"Stop heavy work now and give it airflow: hard surface, out of the sun.")]
|
|
115
|
+
if t["throttled"]:
|
|
116
|
+
detail = f"limited to {t['cpu_speed_limit']}% speed" if t["cpu_speed_limit"] < 100 else "thermal state is serious"
|
|
117
|
+
return [Finding("warn", "thermal", f"Your Mac is slowing itself down to cool off ({detail}).",
|
|
118
|
+
"Reduce load or improve airflow. On a fanless MacBook Air this is the main cause of "
|
|
119
|
+
"slowdowns during long heavy tasks.")]
|
|
120
|
+
if state == "fair":
|
|
121
|
+
return [Finding("info", "thermal", "Your Mac is warm (thermal state: fair).",
|
|
122
|
+
"Not slowing down yet, but sustained heavy work may start throttling.")]
|
|
123
|
+
return [Finding("ok", "thermal", "No thermal throttling.")]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _uptime(s: dict) -> list[Finding]:
|
|
127
|
+
days = (s["timestamp"] - s["system"]["boot_time"]) / 86400
|
|
128
|
+
if days >= 14:
|
|
129
|
+
return [Finding("info", "uptime", f"Up for {days:.0f} days.",
|
|
130
|
+
"A restart clears swap, leaked memory and pending updates.")]
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
RULES = (_cpu, _memory, _disk, _battery, _thermal, _uptime)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def evaluate(snapshot: dict) -> list[Finding]:
|
|
138
|
+
findings = [f for rule in RULES for f in rule(snapshot)]
|
|
139
|
+
return sorted(findings, key=lambda f: -LEVELS.index(f.level))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def health_score(findings: list[Finding]) -> int:
|
|
143
|
+
return max(0, 100 - sum(PENALTY[f.level] for f in findings))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def worst_level(findings: list[Finding]) -> str:
|
|
147
|
+
return max((f.level for f in findings), key=LEVELS.index, default="ok")
|
macverdict/cli.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Command-line interface: `macverdict check | record | report | history | schedule | export`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import webbrowser
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__, insights, notifier, report, scheduler, storage
|
|
12
|
+
from .checks import evaluate, health_score, worst_level
|
|
13
|
+
from .collectors import collect
|
|
14
|
+
from .fmt import color, days_label, human_bytes, human_duration
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _print_check(snap: dict, findings: list, score: int) -> None:
|
|
18
|
+
s = snap["system"]
|
|
19
|
+
tone = "ok" if score >= 85 else "warn" if score >= 60 else "critical"
|
|
20
|
+
print(color(f"macverdict · {s.get('chip')} · macOS {s.get('macos')} · "
|
|
21
|
+
f"{human_bytes(s['memory_total'])} RAM · up {human_duration(snap['timestamp'] - s['boot_time'])}", "dim"))
|
|
22
|
+
print(f"\nHealth score: {color(str(score), tone)} / 100\n")
|
|
23
|
+
for f in findings:
|
|
24
|
+
print(f" {color(f.level.upper().ljust(8), f.level)} {f.area.ljust(8)} {f.message}")
|
|
25
|
+
if f.advice and f.level != "ok":
|
|
26
|
+
print(color(f" {'':8} {'':8} → {f.advice}", "dim"))
|
|
27
|
+
def procs(a: dict) -> str:
|
|
28
|
+
n = a.get("processes", 1)
|
|
29
|
+
return color(f" ({n} processes)", "dim") if n > 1 else ""
|
|
30
|
+
|
|
31
|
+
print(color("\n Top CPU", "bold"))
|
|
32
|
+
for a in insights.apps_in(snap, "cpu_percent")[:3]:
|
|
33
|
+
print(f" {a['cpu_percent']:6.1f}% {a['name']}{procs(a)}")
|
|
34
|
+
print(color(" Top memory", "bold"))
|
|
35
|
+
for a in insights.apps_in(snap, "rss")[:3]:
|
|
36
|
+
print(f" {human_bytes(a['rss']):>9} {a['name']}{procs(a)}")
|
|
37
|
+
print()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_setup(args) -> int:
|
|
41
|
+
"""Everything a new user needs, in one command."""
|
|
42
|
+
print("Setting up macverdict on this Mac...\n")
|
|
43
|
+
if storage.legacy_dir().exists():
|
|
44
|
+
if (storage.data_dir() / "history.db").exists():
|
|
45
|
+
print(color(" ✓", "ok"), "Your history from macpulse (macverdict's old name) is here; "
|
|
46
|
+
"the old folder is kept as a backup.")
|
|
47
|
+
removed = scheduler.remove_legacy()
|
|
48
|
+
if removed:
|
|
49
|
+
print(color(" ✓", "ok"), f"Removed macpulse's old {' and '.join(removed)}.")
|
|
50
|
+
if args.no_notify:
|
|
51
|
+
print(color(" -", "dim"), "Alerts turned off (--no-notify).")
|
|
52
|
+
else:
|
|
53
|
+
try:
|
|
54
|
+
notifier.install()
|
|
55
|
+
print(color(" ✓", "ok"), "Built the macverdict alert app (clicking an alert opens your report).")
|
|
56
|
+
except RuntimeError as e:
|
|
57
|
+
print(color(" -", "dim"), f"Skipped clickable alerts: {e}")
|
|
58
|
+
print(color(" ", "dim"), "Alerts still work; they just open Script Editor when clicked.")
|
|
59
|
+
scheduler.install(args.every, notify=not args.no_notify)
|
|
60
|
+
shown_as = '"macverdict"' if notifier.is_installed() else '"python3"'
|
|
61
|
+
print(color(" ✓", "ok"), f"Recording in the background every {args.every} minutes "
|
|
62
|
+
f"(listed as {shown_as} in System Settings > General > Login Items).")
|
|
63
|
+
if not args.no_notify:
|
|
64
|
+
if notifier.is_installed():
|
|
65
|
+
status = notifier.send("macverdict is set up", "You'll only hear from macverdict when something is "
|
|
66
|
+
"critical. Click to see your report.", notifier.report_command(), timeout=60)
|
|
67
|
+
if status == "ok":
|
|
68
|
+
print(color(" ✓", "ok"), "Sent a test alert, and macOS is allowing alerts from macverdict.")
|
|
69
|
+
else:
|
|
70
|
+
print(color(" !", "warn"), f"The test alert didn't go through ({status}).")
|
|
71
|
+
print(" Allow alerts in System Settings > Notifications > macverdict, then run `macverdict notify-test`.")
|
|
72
|
+
else:
|
|
73
|
+
scheduler.notify("macverdict is set up", "You'll only hear from macverdict when something is critical.",
|
|
74
|
+
open_report=False)
|
|
75
|
+
print(color(" ✓", "ok"), "Sent a test alert.")
|
|
76
|
+
print("\nDone. Try these:")
|
|
77
|
+
print(" macverdict check your Mac right now")
|
|
78
|
+
print(" macverdict insights why it was slow, RAM verdict, battery forecast (best after a few days)")
|
|
79
|
+
print(" macverdict report everything in your browser")
|
|
80
|
+
print(" macverdict uninstall stop and remove the background parts")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_uninstall(args) -> int:
|
|
85
|
+
agent = scheduler.uninstall()
|
|
86
|
+
app = notifier.uninstall()
|
|
87
|
+
print("Stopped background recording." if agent else "Background recording wasn't on.")
|
|
88
|
+
if app:
|
|
89
|
+
print("Removed the macverdict alert app.")
|
|
90
|
+
print(f"Your history is kept in {storage.data_dir()}; delete that folder to remove it too.")
|
|
91
|
+
print("To remove the command itself: brew uninstall macverdict, pipx uninstall macverdict, "
|
|
92
|
+
"or uv tool uninstall macverdict (whichever you used).")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_check(args) -> int:
|
|
97
|
+
snap = collect(args.interval)
|
|
98
|
+
findings = evaluate(snap)
|
|
99
|
+
score = health_score(findings)
|
|
100
|
+
if args.json:
|
|
101
|
+
print(json.dumps({"score": score, "findings": [f.to_dict() for f in findings], "snapshot": snap}, indent=2))
|
|
102
|
+
else:
|
|
103
|
+
_print_check(snap, findings, score)
|
|
104
|
+
if not scheduler.status()["installed"]:
|
|
105
|
+
print(color("Tip: run `macverdict setup` to record in the background and unlock `macverdict insights`.\n",
|
|
106
|
+
"dim"))
|
|
107
|
+
if args.save:
|
|
108
|
+
with storage.connect() as conn:
|
|
109
|
+
storage.save(conn, snap, findings, score)
|
|
110
|
+
return 2 if worst_level(findings) == "critical" else 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_record(args) -> int:
|
|
114
|
+
snap = collect(args.interval)
|
|
115
|
+
findings = evaluate(snap)
|
|
116
|
+
score = health_score(findings)
|
|
117
|
+
with storage.connect() as conn:
|
|
118
|
+
storage.save(conn, snap, findings, score)
|
|
119
|
+
storage.prune(conn, args.keep_days)
|
|
120
|
+
if args.notify:
|
|
121
|
+
bad = [f for f in findings if f.level in ("warn", "critical")]
|
|
122
|
+
if any(f.level == "critical" for f in bad):
|
|
123
|
+
scheduler.notify(f"macverdict: health {score}/100", "; ".join(f.message for f in bad[:2]))
|
|
124
|
+
if not args.quiet:
|
|
125
|
+
print(f"recorded: score {score}, cpu {snap['cpu']['percent']:.0f}%, "
|
|
126
|
+
f"pressure {snap['memory']['pressure']['level']}, swap {human_bytes(snap['memory']['swap_used'])}")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
INSIGHT_STYLE = {"good": "ok", "ok": "info", "attention": "warn"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_insights(args) -> int:
|
|
134
|
+
with storage.connect() as conn:
|
|
135
|
+
rows = storage.load(conn, args.days, full=True)
|
|
136
|
+
if not rows:
|
|
137
|
+
print("No history yet. Run `macverdict schedule install` and check back in a day or two.")
|
|
138
|
+
return 0
|
|
139
|
+
memory_total = rows[-1]["snapshot"]["system"]["memory_total"]
|
|
140
|
+
model = insights.hardware_model()
|
|
141
|
+
found = insights.build(rows, memory_total, model)
|
|
142
|
+
if args.json:
|
|
143
|
+
print(json.dumps([i.to_dict() for i in found], indent=2))
|
|
144
|
+
return 0
|
|
145
|
+
print(color(f"macverdict insights · {model or 'Mac'} · {human_bytes(memory_total)} RAM · "
|
|
146
|
+
f"last {days_label(args.days)}", "dim"))
|
|
147
|
+
for i in found:
|
|
148
|
+
style = INSIGHT_STYLE[i.level]
|
|
149
|
+
print(f"\n{color('●', style)} {color(i.title, 'bold')}")
|
|
150
|
+
print(f" {color(i.headline, style)}")
|
|
151
|
+
for d in i.details:
|
|
152
|
+
print(f" · {d}")
|
|
153
|
+
if i.confidence:
|
|
154
|
+
print(color(f" {i.confidence}", "dim"))
|
|
155
|
+
print()
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_report(args) -> int:
|
|
160
|
+
snap = collect(args.interval)
|
|
161
|
+
findings = evaluate(snap)
|
|
162
|
+
with storage.connect() as conn:
|
|
163
|
+
storage.save(conn, snap, findings, health_score(findings))
|
|
164
|
+
rows = storage.load(conn, args.days, full=True)
|
|
165
|
+
found = insights.build(rows, snap["system"]["memory_total"], insights.hardware_model())
|
|
166
|
+
out = Path(args.out) if args.out else storage.data_dir() / "report.html"
|
|
167
|
+
out.write_text(report.build(rows, snap, findings, args.days, found))
|
|
168
|
+
print(f"Report written to {out}")
|
|
169
|
+
if not args.no_open:
|
|
170
|
+
webbrowser.open(out.resolve().as_uri())
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_history(args) -> int:
|
|
175
|
+
with storage.connect() as conn:
|
|
176
|
+
rows = storage.load(conn, args.days)
|
|
177
|
+
if not rows:
|
|
178
|
+
print("No history yet. Run `macverdict record`, or `macverdict schedule install` to record automatically.")
|
|
179
|
+
return 0
|
|
180
|
+
metrics = [("score", "Health score", ""), ("cpu_percent", "CPU %", ""), ("mem_percent", "Memory %", ""),
|
|
181
|
+
("swap_used", "Swap", "bytes"), ("disk_free_percent", "Disk free %", ""),
|
|
182
|
+
("battery_temp_c", "Battery °C", "")]
|
|
183
|
+
print(f"{len(rows)} samples over the last {days_label(args.days)}\n")
|
|
184
|
+
print(f" {'metric':<14}{'min':>10}{'avg':>10}{'max':>10}")
|
|
185
|
+
for key, label, kind in metrics:
|
|
186
|
+
vals = [r[key] for r in rows if r[key] is not None]
|
|
187
|
+
if not vals:
|
|
188
|
+
continue
|
|
189
|
+
lo, avg, hi = min(vals), sum(vals) / len(vals), max(vals)
|
|
190
|
+
f = human_bytes if kind == "bytes" else (lambda v: f"{v:.1f}")
|
|
191
|
+
print(f" {label:<14}{f(lo):>10}{f(avg):>10}{f(hi):>10}")
|
|
192
|
+
pressures = [r["pressure"] for r in rows]
|
|
193
|
+
elevated = sum(p in ("warn", "critical") for p in pressures)
|
|
194
|
+
print(f"\n Memory pressure elevated in {elevated}/{len(rows)} samples; "
|
|
195
|
+
f"throttled in {sum(r['throttled'] or 0 for r in rows)}/{len(rows)}.")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_schedule(args) -> int:
|
|
200
|
+
if args.action == "install":
|
|
201
|
+
path = scheduler.install(args.every, notify=not args.no_notify)
|
|
202
|
+
print(f"Installed launchd agent ({path}); recording every {args.every} min.")
|
|
203
|
+
print(f"Data: {storage.data_dir()}")
|
|
204
|
+
if not args.no_notify and not notifier.is_installed():
|
|
205
|
+
print("Tip: run `macverdict notifier install` so alerts open your report when clicked.")
|
|
206
|
+
elif args.action == "remove":
|
|
207
|
+
print("Removed launchd agent." if scheduler.uninstall() else "No agent was installed.")
|
|
208
|
+
else:
|
|
209
|
+
st = scheduler.status()
|
|
210
|
+
if not st["installed"]:
|
|
211
|
+
print("Not scheduled. Run `macverdict schedule install`.")
|
|
212
|
+
else:
|
|
213
|
+
print(f"Scheduled every {st['every_minutes']} min · loaded: {st['loaded']} · {st['plist']}")
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cmd_notify_test(args) -> int:
|
|
218
|
+
if not notifier.is_installed():
|
|
219
|
+
scheduler.notify("macverdict test", "Notifications work.", open_report=False)
|
|
220
|
+
print("Sent with osascript (shown as Script Editor; clicking opens Script Editor).\n"
|
|
221
|
+
"For banners that open your report when clicked: macverdict notifier install\n"
|
|
222
|
+
"No banner? Allow notifications for Script Editor in System Settings > Notifications.")
|
|
223
|
+
return 0
|
|
224
|
+
status = notifier.send("macverdict test", "Notifications work. Click to open your report.",
|
|
225
|
+
notifier.report_command(), timeout=60)
|
|
226
|
+
if status == "ok":
|
|
227
|
+
print("Sent. Click the banner to open your macverdict report.")
|
|
228
|
+
return 0
|
|
229
|
+
print(f"The macverdict notifier couldn't post ({status}).\n"
|
|
230
|
+
"Allow it in System Settings > Notifications > macverdict, then run this again.")
|
|
231
|
+
return 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cmd_notifier(args) -> int:
|
|
235
|
+
if args.action == "install":
|
|
236
|
+
try:
|
|
237
|
+
path = notifier.install()
|
|
238
|
+
except RuntimeError as e:
|
|
239
|
+
print(e, file=sys.stderr)
|
|
240
|
+
return 1
|
|
241
|
+
print(f"Built {path}.")
|
|
242
|
+
if scheduler.refresh():
|
|
243
|
+
print("Background agent now runs as \"macverdict\" in System Settings > General > Login Items.")
|
|
244
|
+
print("Run `macverdict notify-test` and click Allow if macOS asks.")
|
|
245
|
+
elif args.action == "remove":
|
|
246
|
+
print("Removed the notifier." if notifier.uninstall() else "The notifier wasn't installed.")
|
|
247
|
+
scheduler.refresh()
|
|
248
|
+
else:
|
|
249
|
+
print(f"Installed at {notifier.app_path()}" if notifier.is_installed()
|
|
250
|
+
else "Not installed. Run `macverdict notifier install` (needs Xcode Command Line Tools).")
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_export(args) -> int:
|
|
255
|
+
with storage.connect() as conn:
|
|
256
|
+
rows = storage.load(conn, args.days)
|
|
257
|
+
storage.export_csv(rows, Path(args.out))
|
|
258
|
+
print(f"Exported {len(rows)} rows to {args.out}")
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
263
|
+
p = argparse.ArgumentParser(prog="macverdict", description="Performance diagnostics and history for your Mac.")
|
|
264
|
+
p.add_argument("--version", action="version", version=f"macverdict {__version__}")
|
|
265
|
+
sub = p.add_subparsers(dest="command")
|
|
266
|
+
|
|
267
|
+
def add(name, func, help_):
|
|
268
|
+
sp = sub.add_parser(name, help=help_)
|
|
269
|
+
sp.set_defaults(func=func)
|
|
270
|
+
sp.add_argument("--interval", type=float, default=1.0, help="seconds to sample CPU/IO rates (default 1)")
|
|
271
|
+
return sp
|
|
272
|
+
|
|
273
|
+
st = sub.add_parser("setup", help="one-step setup: background recording and alerts")
|
|
274
|
+
st.add_argument("--every", type=int, default=15, help="minutes between samples (default 15)")
|
|
275
|
+
st.add_argument("--no-notify", action="store_true", help="never show alerts")
|
|
276
|
+
st.set_defaults(func=cmd_setup)
|
|
277
|
+
|
|
278
|
+
un = sub.add_parser("uninstall", help="stop background recording and remove the alert app")
|
|
279
|
+
un.set_defaults(func=cmd_uninstall)
|
|
280
|
+
|
|
281
|
+
c = add("check", cmd_check, "diagnose this Mac right now (default)")
|
|
282
|
+
c.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
283
|
+
c.add_argument("--save", action="store_true", help="also store this sample in history")
|
|
284
|
+
|
|
285
|
+
r = add("record", cmd_record, "take a sample and store it (used by the scheduler)")
|
|
286
|
+
r.add_argument("--notify", action="store_true", help="show a macOS notification on critical findings")
|
|
287
|
+
r.add_argument("--quiet", action="store_true")
|
|
288
|
+
r.add_argument("--keep-days", type=float, default=90, help="delete samples older than this (default 90)")
|
|
289
|
+
|
|
290
|
+
rp = add("report", cmd_report, "build an HTML report with trends and open it")
|
|
291
|
+
rp.add_argument("--days", type=float, default=7)
|
|
292
|
+
rp.add_argument("--out", help="output path (default: data folder/report.html)")
|
|
293
|
+
rp.add_argument("--no-open", action="store_true")
|
|
294
|
+
|
|
295
|
+
ins = sub.add_parser("insights", help="why your Mac was slow, RAM verdict, battery forecast, throttling")
|
|
296
|
+
ins.add_argument("--days", type=float, default=30)
|
|
297
|
+
ins.add_argument("--json", action="store_true")
|
|
298
|
+
ins.set_defaults(func=cmd_insights)
|
|
299
|
+
|
|
300
|
+
h = sub.add_parser("history", help="summarise stored samples in the terminal")
|
|
301
|
+
h.add_argument("--days", type=float, default=7)
|
|
302
|
+
h.set_defaults(func=cmd_history)
|
|
303
|
+
|
|
304
|
+
s = sub.add_parser("schedule", help="record automatically with launchd")
|
|
305
|
+
s.add_argument("action", choices=["install", "remove", "status"])
|
|
306
|
+
s.add_argument("--every", type=int, default=15, help="minutes between samples (default 15)")
|
|
307
|
+
s.add_argument("--no-notify", action="store_true", help="never show notifications")
|
|
308
|
+
s.set_defaults(func=cmd_schedule)
|
|
309
|
+
|
|
310
|
+
n = sub.add_parser("notify-test", help="send a test notification")
|
|
311
|
+
n.set_defaults(func=cmd_notify_test)
|
|
312
|
+
|
|
313
|
+
nt = sub.add_parser("notifier", help="build macverdict's own notification app (clickable alerts)")
|
|
314
|
+
nt.add_argument("action", choices=["install", "remove", "status"])
|
|
315
|
+
nt.set_defaults(func=cmd_notifier)
|
|
316
|
+
|
|
317
|
+
e = sub.add_parser("export", help="export history to CSV")
|
|
318
|
+
e.add_argument("out")
|
|
319
|
+
e.add_argument("--days", type=float, default=90)
|
|
320
|
+
e.set_defaults(func=cmd_export)
|
|
321
|
+
return p
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def main(argv: list[str] | None = None) -> int:
|
|
325
|
+
if sys.platform != "darwin":
|
|
326
|
+
print("macverdict only supports macOS.", file=sys.stderr)
|
|
327
|
+
return 1
|
|
328
|
+
parser = build_parser()
|
|
329
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
330
|
+
commands = {"check", "record", "report", "history", "schedule", "export", "notify-test", "notifier", "insights", "setup", "uninstall"}
|
|
331
|
+
# `macverdict` and `macverdict --json` mean `macverdict check ...`.
|
|
332
|
+
if not argv or (argv[0] not in commands and argv[0] not in ("-h", "--help", "--version")
|
|
333
|
+
and argv[0].startswith("-")):
|
|
334
|
+
argv.insert(0, "check")
|
|
335
|
+
args = parser.parse_args(argv)
|
|
336
|
+
return args.func(args)
|