TreeMap
A free, open-source disk-space visualizer. Squarified treemap, duplicate hunting, disk-full forecasting, and cleanup that only ever moves files to the Trash.
Network speed and link quality indicator for the macOS menu bar. Native Swift, no dependencies.
ProductivitySwift · ShellGitHub @sergeyerinOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Loading the full README. The saved excerpt is shown below.
A network speed indicator for the macOS menu bar. Native Swift, no dependencies,
no permission prompts: a 0.9 MB universal binary, around 25 MB of memory and
well under 1% of a single core at idle.
The point is to see what the connection is doing without clicking anything —
especially on a phone hotspot, where the link keeps swinging.
A ready disk image is on the
releases page, or at
netspeed.biplane.cc, which links to the same
file. macOS 13 or newer, on Apple silicon or Intel.Read the full README ↗A network speed indicator for the macOS menu bar. Native Swift, no dependencies, no permission prompts: a 0.9 MB universal binary, around 25 MB of memory and well under 1% of a single core at idle.
The point is to see what the connection is doing without clicking anything — especially on a phone hotspot, where the link keeps swinging.
A ready disk image is on the releases page, or at netspeed.biplane.cc, which links to the same file. macOS 13 or newer, on Apple silicon or Intel.
The app is signed ad-hoc rather than with a paid Apple developer certificate, so the first launch is refused. Either allow it once in System Settings → Privacy & Security → Open Anyway, or clear the download flag:
xattr -dr com.apple.quarantine /Applications/NetSpeed.app
./build.sh # build into ./build/NetSpeed.app
./build.sh install # build, install into ~/Applications and launch
tools/make-dmg.sh # build and pack dist/NetSpeed-<version>.dmg
Only the Command Line Tools (swiftc) are needed. The app has no Dock icon
(LSUIElement) and no windows; enable startup from the menu, Settings →
Launch at login.
It asks for no permissions and runs fully without any. One row costs one: the
name of the Wi-Fi network. macOS treats it as location data — reasonably, since
knowing which access point a Mac is on places it about as well as GPS would —
so CWInterface.ssid() is gated behind Location access. ipconfig getsummary
used to answer anyway and on current macOS no longer does: both now return the
literal string <redacted>.
So the row says hidden and offers the fix where the problem is visible —
click it and the system asks. Refuse, and it changes to point at Location
Services, because the prompt does not come back. Everything else about the
radio — signal, noise, rate, channel, band, width, PHY, security — needs no
permission and is there either way.
About NetSpeed opens a window rather than a submenu: checking for updates takes a second or two, and a menu cannot wait — the click that started the request also closed the menu, leaving the answer to arrive in a row nobody was looking at. The window holds the version, the update check and its result, the daily-check setting and the links.
It also says which build this is. build.sh stamps git describe into the
bundle, so a release reads v1.29 and anything else reads v1.29-3-gabc1234
or -dirty. Without that a local build and a release are indistinguishable at
a glance, which has already cost an afternoon of wondering why a fix was not in
the app. The menu item says About NetSpeed — local build when it is one.
A release is the image plus its checksum, attached to a tag:
gh release create v1.12 dist/NetSpeed-1.12.dmg dist/NetSpeed-1.12.dmg.sha256 \
--title "NetSpeed 1.12" --notes "..."
The version comes from CFBundleShortVersionString in Info.plist — bump it
there and the image, the release and the app's own update check all follow.
On the left, the link indicator: a pair of chevron scales, down and up. On the right, two monospaced lines — download and upload.
The arrows answer two questions at once, and it is worth knowing which is which. How far the lit part reaches — five divisions per direction, each direction on its own — is how much is crossing right now. What colour it is is how well the link carries. They are independent, so a full amber stack is not a contradiction: plenty of data moving over a mediocre connection is an ordinary state, and so is a single green mark with nothing to send.
All five divisions are always drawn and the unreached ones are dimmed, the way the scale on a tape deck stays visible while only the level lights up. A stack that grew and shrank never showed how much room was left above it, and one mark alone could not say whether it was the bottom of a ladder or the whole of it.
Nothing tells the two scales apart better than watching them disagree. A strength meter cannot read five down and one up; traffic does it all the time. The counts also repeat what the figures beside them already say, which is how the scale gets learned without a legend — and the only reading left when the numbers are switched off and the arrows stand alone.
The shape went through a correction. A four-bar scale came first: every
phone and Wi-Fi menu uses that one for signal strength, while this has always
meant how well the link carries — and on a hotspot the two disagree completely,
full bars to a phone getting nothing from the tower. Labelling a measured estimate LTE or E
was wrong the same way, making it look like a technology read off a modem, so
those letters are gone too. A label appears only when
there is a fact to state: the technology a tethering phone reports about itself
(5G, LTE, E, …), shown with Settings → Menu bar display → Arrows and
network type, which also marks OFF for a dead network and WEB for a
captive portal.
The label uses the short forms a phone puts in its own status bar — G, E,
3G, 4G, LTE, 5G — because the person reading it is comparing it against
the phone in their hand. The full names appear wherever there is room for them:
the menu header, the phone's row, the tooltip, the copied summary.
It is set smaller than the figures, a shade lighter, and raised off their line, so it reads as a label for the icon rather than a prefix on the number. Not faded, though: the menu bar is translucent, and its colour is whatever the desktop behind it happens to be — a mid-tone wallpaper leaves half-strength white with almost no contrast. Weight and position separate it; opacity cannot be relied on there.
| Colour | Menu says |
|---|---|
| green | Good (LTE/5G-like) |
| amber | Fair (weak 4G-like) |
| orange | Slow (3G-like) |
| red | Awful (EDGE-like) |
| crossed-out network, red | Offline |
| blue | Sign-in needed |
| Lit divisions | That direction is carrying |
|---|---|
| one | under 4 KB/s — idle, or background chatter |
| two | 4 KB/s to 32 KB/s |
| three | 32 KB/s to 256 KB/s |
| four | 256 KB/s to 2 MB/s |
| five | over 2 MB/s |
Five steps of eight times each, because that is the range this meets in practice: a phone on EDGE tops out around the second division, a hotspot on LTE lives in the middle, wired gigabit reaches the fifth. The bands are read from a three-second peak, so a short burst stays visible long enough to be seen instead of flickering past in one sample.
From the third division a highlight runs along the lit marks, the way a chase light runs along a sign. It steps one mark at a time, three steps a second: the smooth version cost four and a half per cent of a core against the one per cent the whole app uses, and not because of the drawing — handing the status bar a new image makes it lay the item out again, about half a per cent per frame per second however the image was made. Bulbs on a sign do not slide either. Below the third division, which is where an idle Mac lives, it sleeps entirely; it switches off at Settings → Menu bar display → Animate the indicator.
The menu leads with the verdict and puts the qualifier in brackets. On a hotspot
the brackets hold a fact instead of a comparison — Awful (phone: 5G) says the
phone claims 5G while the link crawls, which is the whole point of showing both.
Menu bar running out of room? Settings → Menu bar display → Indicator only drops the numbers and leaves just the arrows — the item then takes about 20 points. The same menu picks what the numbers show (download, combined, download with latency) and the unit: bytes or bits.
See preview/tray-light.png and preview/tray-dark.png.
Two things make a naive readout jitter: the unit flipping between B/s and KB/s
every idle second, and the digit count changing with the value — the status item
resizes and shoves everything next to it sideways. Here the scale never drops
below kilo, steps up only past a full 1024 and back down only below 85% of it,
and the number is padded to a constant five characters in a fully monospaced
face. The result is always exactly ten characters: 0.0 KB/s, 14.6 KB/s,
1023 KB/s, 12.0 MB/s. The indicator also reserves the width of its widest
label, so a change of state does not move the numbers either.
A header with the verdict in one word, its qualifier, and the current speed in large figures. No indicator: it would sit a few points under the one in the menu bar and repeat it, without the chase. Below it, two minutes of history — download up, upload down. Then a short table:
Appears in 🇳🇱 NL, or VPN 🇳🇱 NL when a tunnel put it there.Show all details under the table adds what the diagnostics need — uptime, the phone as its own section, the Wi-Fi signal scale with RSSI and SNR, link rate, channel with band and width, PHY mode, security, noise, IPv4, gateway, IPv6, the external address in full and the VPN's own interfaces. It is a hosted row rather than a menu item, so the menu stays open and the table grows under the pointer.
The split exists because one list was answering two questions. Is my connection all right wants six rows; why is it behaving like this wants the radio, the addresses and the route — and pays for them by burying the first answer.
Why the colour is what it is hangs on the verdict word as a hint, and on the
menu bar icon too: Awful because replies take 1.50 s — over 900 ms, Offline because 83% of the last 6 checks got no reply. The sentence comes back from the
same branch that sets the verdict, so a threshold cannot move without it
following.
Traffic by process lists what is actually using the network, read from
nettop while the menu is open. Tunnels and virtual-machine NATs are separated
under a heading of their own: they are the heaviest rows in any such list and
the least informative, since their bytes belong to whatever is behind them and
are counted twice.
Both axes of the chart are labelled — the value its tallest point stands for and how far back the left edge reaches — and hovering puts a cursor on it and reads out that moment: direction, value, how long ago.
Below that, four groups, sorted by who each item is about:
Traffic by process, and
Copy summary, which puts all of the above on the clipboard as text;Settings and About NetSpeed;Quit, alone, where every Mac menu keeps it."Ask the phone to share again" appears only while disconnected from it. The request switches a hotspot back on, which has no subject while already sitting on that hotspot.
The data is drawn as custom views rather than assembled from menu items: macOS paints disabled menu items grey no matter what color is set on them, and grey on grey cannot be read. Notes wrap by word instead of being cut off with an ellipsis.
See preview/menu-light.png and preview/menu-dark.png.
Traffic. Byte counters come from a single sysctl(NET_RT_IFLIST2) — no
netstat process, no privileges. Checked against netstat -ib: within 2%, the
difference being the sampling moment.
Which interface counts. The physical link is the one to measure. With a VPN
up, the default route leads into a utun, but the bytes still travel over Wi-Fi
and the tunnel's traffic is already counted there — so auto-pick takes the first
non-tunnel interface holding a default route. Pin one manually in
Settings → Interface.
Latency to the access point — ICMP echo. On macOS a SOCK_DGRAM socket with
IPPROTO_ICMP opens without root, and routers — a phone in hotspot mode
included — answer pings. Where ICMP is filtered, three failures in a row switch
it to TCP handshake timing; a refused connection counts as an answer there, since
the RST arrives in the same time as a SYN/ACK, so no open port is needed.
Latency to the internet — a short HTTP request to a connectivity-check endpoint (Cloudflare, Google or Apple). Not TCP to 1.1.1.1: with a VPN or a local proxy in play, that handshake terminates on this very machine and reports a fake 1–3 ms instead of a real 100–600 ms. HTTP travels the whole path. The connection is reused, so from the second measurement on it times a clean round trip. As a bonus, any answer other than 204 means a captive portal.
If "the internet" ever answers faster than the gateway itself, the menu says so — that is a local proxy talking, not the network.
The external address. One request to Cloudflare's cdn-cgi/trace returns
both the address and a country code, so a single round trip does what two lookup
services would. It runs only when the menu is opened, and only if the previous
answer is older than five minutes or the network changed — a switch almost
certainly means a new address. Behind a VPN or a proxy this reports the exit
node, which is usually the point of asking. Turn it off in
Settings → Show external IP and no such request is ever made.
Staying current. The verdict is judged on the last six probes — one minute —
rather than the whole ring. Over the full history a burst of failures would keep
the indicator red for minutes after the link had recovered. On top of that, the
app watches the interface, its address, the gateway and the SSID: joining another
network keeps the same en0, so without that check the previous network's
measurements would live on. When the identity changes, the latency history is
dropped, the pooled HTTP connection is flushed and a fresh probe fires at once.
When an iPhone or iPad shares its connection, macOS shows its cellular bars,
battery and network type in the Wi-Fi menu. That data is kept in the dynamic
store under State:/Network/Interface/<bsd>/AirPort, in a LastTetherDevice
blob — a keyed archive of CoreWLAN's private CWTetherDevice. The archive is
ordinary NSCoding, so a stand-in class decodes it without calling private API,
and the numbers track the phone live.
That gives the app what no measurement can: the technology the phone is actually
on. It is the one label the menu bar will show, while the colour still comes
from the measured latency. So 5G painted orange reads exactly as it should:
the phone claims 5G, the link behaves like weak 4G. The phone's own reception
sits in the menu, next to its battery, where there is room to say whose signal
it is.
The technology codes come from the enum ControlCenter uses for the same purpose
(WiFiHotspotNetworkType). ControlCenter carries seven labels for it, in this
order: 1x, GPRS, EDGE, 3G, 4G, LTE, 5G — and LTE arrives as 7. For it to land
there, two cases ahead of them must carry no label at all: nothing reported, and
something unrecognised. So LTE is 7 and 5G is 8.
That numbering was wrong here for several releases, and the app told someone it had 5G while both their phone and their own Wi-Fi menu said LTE. The thing that settles such a question is seeing the two together — the system's menu naming the device while the raw value is read at the same moment — rather than the label order on its own, which was what the earlier guess rested on.
macOS keeps the last tethering device around after the phone disconnects, so the data is only used while the Mac actually holds a hotspot address.
Two different signal scales. The menu shows both, and they mean opposite
things: Cellular is the phone's reception from the tower (what makes the link
fast or slow), while Wi-Fi signal is only the hop between the Mac and the phone
sitting next to it — usually excellent and telling you nothing about the
connection. Without a hotspot, only the Wi-Fi scale appears and the menu bar
label falls back to the measured estimate.
The internet latency probe is one HTTP request with an empty body every 10 seconds — roughly 1 KB per minute. Pinging the access point over ICMP costs no mobile data at all. Both stop at Settings → Latency → Measure latency.
The update check asks GitHub for the latest release once a day, and the external address is looked up only while the menu is open and only if the previous answer is over five minutes old. Each has its own switch — Check daily in the About window, and Settings → Show external IP.
Every running copy adds its own status item, and once the menu bar runs out of room macOS silently drops the ones that no longer fit — so three copies show up as no icon at all, which reads as a crash. That is how three of them accumulated here: a build, one started straight from a disk image, and an installed one.
Starting a copy while an older one runs replaces it. Starting one while an equal or newer copy runs leaves that one alone — it already holds two minutes of history and the session totals, and swapping it for an identical build would throw those away to put back what was there.
Launching NetSpeed when it is already running does not start a second process at all: macOS sends the running copy a reopen event instead. That opens its menu, which is both an answer and a way to find the icon. If the click goes nowhere the icon did not fit in the menu bar, and a message says so and offers to quit — otherwise an app with no icon, no window and no Dock presence cannot be quit at all.
The app asks api.github.com for the latest release at launch and once a day
after that, and compares the tag with its own bundle version. The release is
already the one place the disk image lives, so it is also the one place worth
asking: a manifest on the site would be a second copy of the same fact, free to
disagree with the first.
It only reports. Downloading and replacing the app in place would need a developer certificate to be safe, and without one Gatekeeper would refuse the result anyway — so a newer version becomes a menu row pointing at the release page.
| File | Purpose |
|---|---|
Sources/Kernel.swift | byte counters, default routes, addresses — via sysctl/getifaddrs |
Sources/Interfaces.swift | interface classification, auto-pick, Wi-Fi readout |
Sources/Monitor.swift | rates, history, session totals |
Sources/Latency.swift | ICMP and HTTP probes, the overall verdict |
Sources/Hotspot.swift | the tethering phone's cellular type, bars and battery |
Sources/ExternalIP.swift | the address the outside world sees, with its country |
Sources/AppDelegate.swift | status item and menu assembly |
Sources/Indicator.swift | link indicator: arrows, label, colors for both themes |
Sources/Processes.swift | per-process traffic, read from nettop |
Sources/SparklineView.swift | history chart |
Sources/Updates.swift | version comparison and the release check |
Sources/MenuContent.swift | what the panel says, from plain values — used by the screenshot generator too |
Sources/AboutWindow.swift | version, build, the update check and its answer |
Sources/MenuViews.swift | header, details table, the clickable rows |
Sources/NetworkName.swift | the Location permission behind Wi-Fi names |
Sources/CellularLog.swift | every cellular type seen, for when the table is wrong again |
Sources/Hotspot+Connect.swift | asking a phone to switch its hotspot back on |
Sources/MessageWindow.swift | the window a redundant launch shows before leaving |
Sources/Format.swift | constant-width rate formatter and value formatting |
Sources/Settings.swift | preferences |
Resources/AppIcon.icnsapp icon, generated by tools/make-icon.swift |
tools/make-dmg.sh | packs the built app into a disk image |
tools/make-icon.swift | draws the app icon at every size and writes the .icns |
tools/make-banner.sh | renders the banner above, from tools/banner.html |