A lightweight native macOS system monitor in a floating, always-on-top window — a btop replacement that never freezes. Swift + AppKit/SwiftUI, no dependencies, every metric from a direct syscall or IOKit call. See PLAN.md for the spec and PROGRESS.md for status.
Download the latest Glasstop-x.y.z.dmg from
Releases, open it and drag Glasstop
to the Applications shortcut. Needs an Apple silicon Mac with macOS 14 Sonoma or later.
Releases from 0.9.1 on are signed with a Developer ID and notarized by Apple, so Glasstop opens like any other app. 0.9.0 is ad-hoc signed: macOS blocks its first launch, so open it once, then click Open Anyway in System Settings → Privacy & Security.
swift build # debug build
swift run # run from terminal
./scripts/bundle.sh # release .app in build/
./scripts/bundle.sh --install # …and copy it to /Applications
./scripts/make-dmg.sh # drag-to-install disk image in build/ (needs: pip install dmgbuild)
open build/Glasstop.appGlasstop is a regular Dock app. Closing the window keeps it running; click the Dock icon to bring it back (that also turns click-through off). Quit with ⌘Q. Keep on Top floats the window above other windows on every desktop (macOS doesn't let a Dock app's windows over other apps' fullscreen Spaces).
| Shortcut | Action |
|---|---|
| ⌘T | Keep on top (all desktops) |
| ⌘1 / ⌘2 | Full / compact mode (each remembers its own size) |
| ⌘F | Focus the process filter |
| − / + (header) | Refresh interval in 100 ms steps (hold to repeat, ⌥-click for 1 s), 100 ms–5 s, default 1000 ms |
| ⌘, | Settings (refresh interval, history length, process refresh, launch at login, hotkey) |
| ⌃⌥⌘G | Show/hide from anywhere (optional global hotkey, off by default) |
Window menu: opacity (also a slider in Settings) and click-through (the window ignores the mouse; click the Dock icon to turn it off). Process table: right-click for Copy PID / Copy Path / Reveal in Finder / Terminate / Force Kill; click GPU% to sort by GPU use; the Lazy checkbox next to the filter makes the CPU% sort work like btop's "cpu lazy" (by each process's lifetime average CPU, with processes busy right now pulled to the top), so rows don't reshuffle every refresh; the filter button has only-my-processes, hide-idle and tree view. Double-click a row (or right-click → Pin) to pin it to the PINNED strip at the top of the table, where it keeps its place while the list sorts and scrolls and shows the process's live values; the red × unpins it. A pinned process that exits stays there, dimmed, until you unpin it. Esc in the filter (or ↓) moves focus back to the table. Filter text, sort order and column layout persist. The net panel's title is a dropdown: all Wi-Fi/Ethernet adapters (default) or one interface (adapters by name, plus VPN tunnels and VM bridges).
In a narrow window the side panels stack in two columns and the table drops Command, User, Thr and PID (in that order) to keep Name readable:
main is protected: changes land through pull requests, and the CI workflow (release build
with warnings as errors, plus the app bundle) has to pass first.
To publish a release, tag a commit on main and push the tag:
git tag v0.9.1 && git push origin v0.9.1The Release workflow builds Glasstop.app stamped with that version, packs it into a
drag-to-Applications disk image (scripts/make-dmg.sh, layout in scripts/dmg-settings.py)
with a SHA-256 checksum and publishes a GitHub release with notes generated from the merged pull requests.
A tag with a suffix (v1.0.0-beta.1) becomes a pre-release. Running the workflow by hand
(Actions → Release → Run workflow) makes a draft release instead, which only you can see; check
it, then click Publish release. Publishing creates the tag, and the tag's run sees the release
already has its disk image and skips the build. A release drafted on GitHub's own form (no files)
works too: when you publish it, the tag's run builds, notarizes and attaches the DMG.
The Release workflow signs and notarizes when these repository secrets exist (Settings → Secrets and variables → Actions); without them it falls back to an ad-hoc build.
| Secret | What it is |
|---|---|
MACOS_CERTIFICATE_P12 |
Your Developer ID Application certificate + private key, exported as .p12, base64-encoded |
MACOS_CERTIFICATE_PASSWORD |
The password chosen when exporting the .p12 |
NOTARY_API_KEY |
Contents of an App Store Connect API key file (AuthKey_XXXXXXXXXX.p8) |
NOTARY_KEY_ID |
That key's Key ID |
NOTARY_ISSUER_ID |
The Issuer ID shown above the keys list |
- Certificate (Account Holder or Admin): Xcode → Settings → Accounts → your team → Manage
Certificates → + → Developer ID Application. Then in Keychain Access → My
Certificates, right-click "Developer ID Application: …" → Export as
.p12with a password. - API key: App Store Connect
→ Users and Access → Integrations → Team Keys → +, role Developer. Download the
.p8(only once) and note the Key ID and Issuer ID. - Secrets (values are read from stdin or files, so they don't land in shell history):
base64 -i DeveloperID.p12 | gh secret set MACOS_CERTIFICATE_P12 gh secret set MACOS_CERTIFICATE_PASSWORD gh secret set NOTARY_API_KEY < AuthKey_XXXXXXXXXX.p8 gh secret set NOTARY_KEY_ID gh secret set NOTARY_ISSUER_ID
- Check it with a draft: Actions → Release → Run workflow. The log shows "Signing as:
Developer ID Application: …", two "Notarization …: Accepted" lines and
spctlreporting source=Notarized Developer ID.
Releases build on GitHub's Intel runners (the Apple silicon pool is often too busy to get a machine) and cross-compile the arm64 app. The Release workflow can also run on a self-hosted Mac, which is usually faster (CI stays on GitHub's runners: on a public repo, pull requests must never run on your machine):
- Settings → Actions → Runners → New self-hosted runner → macOS, ARM64. Run the commands it
shows in e.g.
~/actions-runner; at the labels prompt addglasstop-release. Then./svc.sh install && ./svc.sh startso it runs in the background at login. - Once the runner shows Idle, add the repository variable
RELEASE_RUNNER(Settings → Secrets and variables → Actions → Variables) with the value["self-hosted", "macOS", "ARM64", "glasstop-release"].
Delete the variable to go back to GitHub's runners. The signing step uses a throwaway keychain and restores your keychain search list afterwards.
Locally: SIGN_IDENTITY="Developer ID Application: …" ./scripts/bundle.sh, then
scripts/make-dmg.sh (signs the image too) and scripts/notarize.sh <file> with a profile from
xcrun notarytool store-credentials (NOTARY_PROFILE=name).
Other scripts: swift scripts/make-icon.swift regenerates Resources/AppIcon.icns;
scripts/soak.sh [hours] [interval_min] runs the reliability soak (results in soak/).
Measured 2026-10-19 on an Apple M5 Pro (18 logical CPUs: 6 Super + 12 Performance,
48 GB), macOS 27.0.1 (26A434), release build. Reference tools ran over the same window as
Glasstop's readers (harness: the app's own CPUReader, MemoryReader, ProcessReader).
| Metric | Glasstop | Reference | Result |
|---|---|---|---|
| Total CPU | 11.4% | top 9.9% (2.96 user + 6.91 sys) |
within ~1.5 points (windows offset by a few ms) |
Process CPU, busy yes |
99.9% | kernel CPU-time delta via ps: 99.9%; top: 99.8–99.9% |
✅ timebase conversion correct (not 41× off) |
| Process CPU, other apps | e.g. 6.5 / 2.0 / 1.7% | top |
✅ match top's steady-state samples* |
| Memory Used | 30.23 GB = app 18.50 + wired 4.88 + compressed 6.85 | top: 4995M wired, 7013M compressor |
✅ wired/compressed match; Used = Activity Monitor's definition |
| Memory column | e.g. 1.08 GB, 707 MB, 652 MB | footprint tool 1109 / 707 / 652 MB; top MEM |
✅ (see note on VM hosts) |
| Tick cost (header) | ~7 ms on process ticks (every 2nd), < 0.1 ms otherwise; ~1,030 processes | budget < 5 ms | wall time on utility-QoS (low-clock) cores; process sampling is syscall-bound (__proc_info ≈ 93%) |
* top -l under-reports its first interval (68–86% for a process that is really at
100%); every later top sample agrees with Glasstop.
Notes:
- The Memory column is the kernel's
phys_footprintledger — the same valuetop(MEM) and Activity Monitor show. ThefootprintCLI sums VM regions instead and misses hypervisor-owned memory, so for VM hosts (e.g. OrbStack Helper: Glasstop 2.9 GB,top3031M,footprint288 MB) it reads much lower. - Other users' processes (root daemons, WindowServer) show "—" for CPU/memory/threads: their task info needs privileges.
- The first process sample isn't published (every CPU % would be 0), so the table never flashes a meaningless sort order.
- Per-core CPU uses the same
host_processor_infotick counters as the total (the total is the tick-weighted aggregate of all cores).
| Metric | Source |
|---|---|
| CPU total / per core | host_processor_info(PROCESSOR_CPU_LOAD_INFO) tick deltas |
| Core groups (Super / Performance) | IODeviceTree:/cpus/cpuN cluster-type + hw.perflevelN.name |
| Memory | host_statistics64(HOST_VM_INFO64), vm.swapusage |
| Processes | proc_listallpids, proc_pidinfo(PROC_PIDTASKALLINFO), proc_pid_rusage, proc_pidpath |
| GPU utilization | IOAccelerator PerformanceStatistics → Device Utilization % |
| Process GPU % | IOAccelerator's Metal clients (AGXDeviceUserClient): IOUserClientCreator pid + AppUsage → accumulatedGPUTime (ns) deltas, like Activity Monitor's % GPU; readable for every user's processes |
| Temperatures | AppleSMC float keys (Tp* Super cluster, Tm* Performance cluster, Tg* GPU, TB*T battery); HID PMU tdie* fallback |
| Network | sysctl IFMIB_IFDATA (ifmibdata, 64-bit counters) for en* interfaces — NET_RT_IFLIST2 byte counters come back truncated to 32 bits on macOS 27 |
| Disk throughput | IOKit IOBlockStorageDriver → Statistics → Bytes (Read) / Bytes (Write) |
| Volume usage | getfsstat(MNT_NOWAIT) to list mounts, statfs on local browsable volumes only (a hung network mount can't block sampling) |
| Load average / uptime | getloadavg, kern.boottime |
Apache License 2.0. See NOTICE for attributions (the Lazy CPU sort is adapted from btop).


