A lightweight macOS menu-bar system monitor for Apple Silicon — CPU, unified memory, memory pressure, fan RPM, and top-consuming apps, all in a single SwiftUI panel that drops down from your menu bar.
Status: experimental. Built for Apple Silicon (M1+) running macOS 13 Ventura or later. Intel Macs are not supported.
Website: https://menustat.adhishthite.vercel.app/
- CPU — total load, user / system / idle split, per-core activity, top CPU-consuming processes.
- Memory — used / available / active / wired / compressed breakdown of unified memory, plus top memory-consuming processes.
- Memory pressure — system pressure gauge (normal / moderate / high) with a heat-proxy "likely culprit" list.
- Fans — real-time RPM per fan, normalized range percentage, status bucket (Quiet / Cooling / High) sourced from
AppleSMCKeysEndpoint. See fan reading caveats. - Menu-bar resident — runs as an
LSUIElement(no Dock icon, no window). - Companion CLI —
menustatprovides a live terminal dashboard plussnapshot,top, andfanscommands with JSON output for scripts. - Configurable refresh rate — choose 1 s for live build/debug sessions, 5 s for balanced monitoring, or 30 s for quiet menu-bar residency; heavier top-app sampling runs when details are visible.
| OS | macOS 13.0 (Ventura) or later |
| Architecture | Apple Silicon required (M1 or later). Intel Macs show an unsupported-hardware alert and quit. |
Apple Silicon required: MenuStat is for Apple Silicon Macs (M1 or later). On Intel Macs, the app shows an unsupported-hardware alert and quits.
- Download the latest Apple Silicon release: MenuStat.dmg
- Open the DMG.
- Drag
MenuStat.appintoApplications. - Open
MenuStat.app.
The app appears as MS in your menu bar. Click it for the panel; click outside or hit the menu-bar item again to dismiss.
To start MenuStat automatically when you sign in: right-click the menu-bar item → enable Launch at Login.
To quit: right-click the menu-bar item → Quit.
MenuStat is Developer ID signed and notarized. If macOS asks for confirmation the first time you open it, choose Open.
The release also includes MenuStatCLI.zip for terminal users:
unzip MenuStatCLI.zip
sudo install -m 755 menustat /usr/local/bin/menustat
menustatThe CLI is observe-only. It reads the same telemetry as the menu-bar app, but it does not launch, quit, or configure MenuStat.app.
Developer requirements:
| Swift | 5.9+ |
| Xcode CLT | Latest |
| Homebrew | For installing lint / format tools |
git clone <repo-url> menustat
cd menustat
make install-tools # one-time: SwiftLint + SwiftFormat via Homebrew
make install-hooks # one-time: link the pre-commit hook
make run # build, bundle, sign, launch the menu-bar app
swift run menustat # run the CLI dashboardFor source builds, you can also quit from the terminal with pkill -x MenuStat.
Useful CLI commands:
swift run menustat snapshot
swift run menustat snapshot --json
swift run menustat top --by cpu --limit 5
swift run menustat fansmake help prints the full list. The ones you'll actually use:
| Target | What it does |
|---|---|
make run |
Build (release), bundle into MenuStat.app, codesign locally, launch. |
make package-release |
Build, Developer ID sign, optionally notarize, then create DMG and zip release artifacts. |
make build / make release |
Debug / release build only — no bundle, no launch. |
make strict |
Release build with -warnings-as-errors. The "type-check + compile" gate. |
make test |
Run XCTest unit tests (parallel). |
make lint / make format |
Run SwiftLint / SwiftFormat. |
make fix |
Auto-format and auto-fix lint where possible. Run this before commit. |
make check |
Full quality gate: format-check + lint + strict build + tests. CI runs this. |
make debug |
Launch under lldb. |
make logs / make verify |
Launch and stream os_log / sanity-check the process. |
make clean |
Remove .build, .swiftpm, DerivedData, and the staged binary. |
make install-tools |
Install SwiftLint + SwiftFormat (Homebrew). |
make install-hooks |
Symlink script/pre-commit into .git/hooks/. |
.
├── Package.swift # SPM manifest (shared core + app/CLI executables)
├── Makefile # Single entry point for all dev tasks
├── Sources/MenuStatCore/
│ ├── SystemMonitor.swift # Pure value types + SystemSampler (Mach/libproc/IOKit)
│ ├── SMCFanReader.swift # AppleSMC IOKit client
│ └── HardwareSupport.swift # Apple Silicon support checks
├── Sources/MenuStat/
│ ├── MenuStatApp.swift # NSApp delegate, status item, panel wiring
│ ├── MenuStatPanelView.swift # SwiftUI panel: header, metric tiles, detail layer
│ └── DisplayPreferences.swift # UserDefaults-backed panel/menu visibility settings
├── Sources/MenuStatCLIKit/ # ArgumentParser commands, terminal renderer, JSON output
├── Sources/MenuStatCLI/ # Thin CLI entrypoint
├── Tests/MenuStatTests/
│ ├── FanSnapshotTests.swift # Pure-logic tests for fan status / bucketing
│ ├── CLIRendererTests.swift # CLI renderer / JSON / parser tests
│ └── MemorySnapshotTests.swift # Memory / CPU / formatter tests
├── script/
│ ├── build_and_run.sh # Build → bundle → codesign → launch pipeline
│ ├── package_release.sh # Developer ID signing + notarization packaging
│ └── pre-commit # Git hook: format + lint staged Swift files
├── .swiftlint.yml # Lint config (strict mode in CI)
├── .swiftformat # Format config (4-space, 140-col, sorted imports)
└── .github/workflows/ # GitHub Actions: CI plus signed/notarized release packaging
MenuStat is a SwiftPM package with a shared telemetry core and two executables:
┌────────────────────────────────────────────────────────────────┐
│ MenuStatCore │
│ └── SystemSampler.sample() │
│ ├── host_statistics / host_statistics64 (CPU + VM) │
│ ├── libproc (per-app usage) │
│ ├── GPUReader.readGPU() (AGX IORegistry counters) │
│ └── SMCFanReader.readFans() (AppleSMC keys) │
├────────────────────────────────────────────────────────────────┤
│ MenuStat.app (LSUIElement — no Dock, no window) │
│ └── MenuStatAppDelegate │
│ ├── NSStatusItem ("MS" in menu bar) │
│ ├── MenuStatStatusPanel (NSPanel, custom) │
│ │ └── NSHostingController<MenuStatPanelRoot> │
│ │ └── MenuStatPanelView (SwiftUI) │
│ └── refreshTimer → serial sampling queue 1/5/30 s │
├────────────────────────────────────────────────────────────────┤
│ menustat │
│ └── live dashboard / snapshot / top / fans commands │
└────────────────────────────────────────────────────────────────┘
Design notes:
- The UI and CLI never touch IOKit directly — they consume immutable
SystemSnapshotvalue types produced bySystemSampler. This is why the unit tests can cover the entireFanSnapshot/MemorySnapshot/CPUSnapshotsurface without mocking syscalls. - Fans, CPU, and memory are sampled on the selected refresh cadence to keep snapshots internally consistent.
- Sampling runs on a serial utility queue. The main thread schedules work, publishes completed snapshots, and skips overlapping ticks; if the panel opens during a hidden-panel sample, a visible app-usage sample is queued next.
- The CLI warms up sampling before
snapshotandtopso delta-based CPU and process readings are useful;--instantskips that warm-up for scripts that need the fastest possible response. GPUReadercaches the AGX service after discovery and uses singular IORegistry property reads forPerformanceStatisticsand static GPU fields, falling back to full properties only when needed.SMCFanReaderreuses working AppleSMC connections and caches confirmed fanless results so fanless Macs do not keep probing every tick.
# Edit code, then before committing:
make fix # auto-format and auto-fix lint
make check # format-check + lint + strict build + testsThe pre-commit hook (make install-hooks) runs format + lint on staged .swift files only — fast enough you won't be tempted to --no-verify.
MenuStat ships outside the Mac App Store as a Developer ID signed and notarized app. The release script uses local environment variables or GitHub Actions configuration for account-specific signing values:
| Setting | Value |
|---|---|
| Bundle ID | com.adhishthite.MenuStat |
| Team ID | TEAM_ID locally, APPLE_TEAM_ID in GitHub Actions |
| Signing identity | SIGNING_IDENTITY locally, DEVELOPER_ID_SIGNING_IDENTITY in GitHub Actions |
| Minimum macOS | 13.0 |
| Architecture | Universal app and CLI wrappers (arm64 monitor + x86_64 unsupported-hardware alert); Apple Silicon required |
Build a signed local release:
make package-releaseThat creates:
dist/work/MenuStat.app
dist/work/cli/menustat
dist/MenuStat-0.1.0.zip
dist/MenuStat-0.1.0.dmg
dist/MenuStatCLI-0.1.0.zip
Published downloads live under GitHub Releases. The GitHub Packages tab is intentionally unused because MenuStat ships as notarized macOS app artifacts, not as a package-registry dependency.
The unsigned/notarization-sensitive values can be overridden:
MARKETING_VERSION=1.0.0 BUILD_NUMBER=100 make package-releaseTo notarize, first store an Apple notary credential in Keychain. Use an app-specific password for the Apple ID that belongs to the Developer Team:
xcrun notarytool store-credentials "$NOTARY_PROFILE" \
--apple-id "YOUR_APPLE_ID_EMAIL" \
--team-id "$TEAM_ID"Then run:
TEAM_ID="$TEAM_ID" NOTARY_PROFILE="$NOTARY_PROFILE" make package-releaseThe script submits the app zip to Apple's notary service, staples the notarization ticket to MenuStat.app, notarizes the standalone CLI zip, verifies Gatekeeper assessment, then writes the final distributable zips.
- Add the value-type fields and any derived computed properties to
SystemMonitor.swift. - Extend
SystemSampler.sample()to populate them. - Add a
MetricSectioncase (or extend a detail view) inMenuStatPanelView.swift. - Add tests for any non-trivial derived properties to
Tests/MenuStatTests/. make check.
Edit .swiftlint.yml (opt_in_rules: for style, analyzer_rules: for ones requiring swiftlint analyze). Run make lint locally. Don't add a rule without first running make fix to clear pre-existing violations — strict mode treats every warning as fatal.
make test # parallel, quiet
make test-verbose # full xctest outputTests live in Tests/MenuStatTests/ and cover pure value-type logic only. They do not exercise SystemSampler or SMCFanReader, both of which call directly into Mach / IOKit syscalls. Testing those meaningfully would require a dependency-injection refactor (e.g., introducing a FanReading protocol that the real reader and a fake both satisfy). See Roadmap.
.github/workflows/ci.yml runs on every push and pull request to main:
make format-check— fails on any formatting drift.make lint— strict mode (warnings = errors).make strict— release build with-warnings-as-errors.swift test --parallel.
SwiftPM artifacts are cached on Package.resolved + Package.swift hashes. Concurrent runs on the same ref are cancelled.
SMCFanReader reads F0Ac, F1Ac, … from AppleSMCKeysEndpoint and AppleSMC user-clients via IOConnectCallStructMethod (selector 2). Two things worth knowing:
- Apple Silicon SMC reports a floor, not an absolute zero. On M1 Pro / M1 Max machines,
F0Mntypically returns ~1200 RPM andF0Acmay report values at or above that floor even when the physical fan blade is coasting or stopped. The reading is faithful to what SMC publishes — it just isn't a tachometer measurement in the Intel-SMC sense. Cross-check withsudo powermetrics -n 1 -i 200 --samplers smc | grep -i fanif in doubt. - No private entitlements required.
IOServiceOpensucceeds for unprivileged user processes on Apple Silicon — nocom.apple.private.smc.user-clientneeded. This is what enables the app to run codesigned with an ad-hoc identity fromscript/build_and_run.sh.
- Inject
FanReaderandSamplerprotocols so end-to-end sampling can be unit-tested with fakes. - Add a dedicated settings window for less-common preferences.
- Optional notarized & signed distribution via a Developer ID certificate.
- Battery / thermal-state section.
- Launch-at-login via
SMAppService. - GPU activity (when a public API lands).
| Symptom | Likely cause / fix |
|---|---|
MS appears but the panel is blank |
Sampler hasn't completed first tick yet. Wait ~5 s, then click again. |
Fan tile says Unavailable |
This Mac doesn't expose FNum / F0Ac over the SMC user-client. Check MenuStat --probe-fans. |
| Build fails with "no such module 'AppKit'" | You're not on macOS, or Xcode CLT is missing. Run xcode-select --install. |
make lint fails after a fresh clone |
Run make install-tools first. |
| Commit blocked by pre-commit hook | Run make fix, re-stage, commit again. Don't bypass with --no-verify. |
For ad-hoc fan diagnostics:
.build/release/MenuStat --probe-fansThis prints raw FanSnapshot state to stdout and exits — useful for verifying SMC connectivity without launching the UI.
MIT © 2026 Adhish Thite.
