A macOS menu bar app that checks your connection every 2 seconds and tells you which part of it broke: your Wi-Fi, your router, DNS, or your ISP.
Everything stays on your machine. Nothing is uploaded, nothing is fetched, and there are no dependencies to resolve: it builds against Apple's system frameworks and the system SQLite.
A ping in a loop tells you that something broke. It cannot tell you what, and "what" is the only part you can act on.
NetWatcher probes several points along the same chain on every tick: the Wi-Fi link, the router, DNS, the public internet, and a captive-portal check, so the point where the lanes stop passing is the location of the fault. Link down: the radio. Link up, router silent: your router. Router answering, internet silent: your ISP, and there is nothing on your side to fix. Everything passing but the answers wrong: you are behind a captive portal.
That is the difference between rebooting a router at 9pm and knowing you don't have to. It is also the difference between a support call you lose and one you win. The report NetWatcher writes can state, of a single outage, that the router answered 2,700 of 2,700 measured ticks and the internet answered 0 of 2,700, timestamped, measured every two seconds, and quoted from raw samples that were never rewritten to agree with the verdict. That is a claim an ISP cannot wave away.
This was not a hypothetical. It was written to diagnose a real problem on a new Optimum line, after the escalation tier had already looked at it and not found the cause. Sampling the chain continuously showed the break was consistently past the router rather than at it (the local segment kept answering while the path beyond it did not), which pointed at the line outside the house rather than anything on the network inside it. That was specific enough for the technician who came out to look at the pole, where the fault turned out to be, and to fix it.
The point of the tool is that last step. "My internet is bad" gets you a router reboot. "The router answered every tick and the internet answered none, for this long, on these dates" gets you someone looking at the right thing.
A menu bar agent that samples continuously, and a dashboard with five panes: the live view, the timeline behind it, the outage table, the insights those outages add up to, and the raw log with CSV and JSON Lines export. Plus the report.
The ISP report is the artifact the rest of it exists to produce: one self-contained HTML file stating the uptime and the time it was measured over, listing every outage with the probe results behind its classification, and explaining how the numbers were arrived at. It loads nothing from the internet, contains no scripts, and leaves access point addresses out unless you explicitly ask for them. Attach it to a support ticket, or print it to PDF.
git clone https://github.com/RajParmar2003/NetWatcher.git
cd NetWatcher
make runThat builds the release binary, assembles dist/NetWatcher.app, ad-hoc signs it, and opens
it. The icon appears in the menu bar: by default there is no Dock icon and no window until
you ask for one, which is the right shape for something that runs all day. If you would
rather have it in the Dock, Diagnostics › Appearance › Show in Dock switches the app to a
normal Dock icon and ⌘-Tab entry; the menu bar icon stays either way, closing the dashboard
window keeps it collecting, and the choice is remembered across launches. First run creates
~/Library/Application Support/NetWatcher/netwatcher.sqlite, and sampling starts
immediately.
The menu bar icon is a stack of four bars widening upward: the WiFi radio at the bottom, the router above it, the path out through DNS and the ISP above that, and the wider internet at the top. The solid bars are how far up that chain the connection still reaches, so a router that has stopped answering (one solid bar) looks different from an ISP outage (two) without opening anything. All four solid is a healthy line; three is degraded. It is a template image, so it works on a light or dark menu bar and carries no state in colour, which also means it still says the same thing to a colour-blind user. Samples failing to reach the database replaces the mark with a storage symbol, because that is data being lost rather than a reading to re-take.
Click it for the current verdict and today's uptime, or open the dashboard from there. Quit
from that menu rather than force-quitting, so the session is closed cleanly and the next
launch records a quit gap rather than a crash.
| Command | What it does |
|---|---|
make run |
Build dist/NetWatcher.app and open it |
make app |
Assemble and ad-hoc sign the bundle only |
make install |
Copy the bundle to /Applications |
make demo-db |
Write a reproducible demo database to demo.sqlite |
make run-cli |
Headless collector in the terminal, same engine |
make test |
Run the test suite |
swift run netwatcher-cli report --out report.html |
Write an ISP report without opening the app |
If something looks wrong, the app records what happened at launch:
log show --style compact --last 10m --predicate 'subsystem == "app.netwatcher.NetWatcher"'Xcode command line tools with a Swift 6 toolchain, and macOS 15 or later, the floor CI actually builds, tests and bundles on (macOS 15 and macOS 26; development is on macOS 26.6 with Swift 6.3). The macOS 14 caveat is below.
An empty database is a poor way to judge a tool that diagnoses outages, and a real one takes days to become interesting. So there is a generated one:
make demo-db # writes demo.sqlite, 14 days
open dist/NetWatcher.app --args --db "$PWD/demo.sqlite"Same seed, same database, every time: fourteen days of samples with real outages, overnight sleep gaps, degraded stretches and two fictional networks. It is what the screenshots above were taken against, because a real database names the machine, dates every night its owner slept, lists every network they have joined, and (on a healthy line) contains no outages at all.
--db works for any database, not just the demo: one copied over from another machine, or a
second one you want to keep separate. A path that can't be opened fails visibly rather than
quietly falling back to the default, and whenever the database in use isn't the default one
its path is named in Diagnostics and, in a small unobtrusive line, on the Now view too.
Every 2 seconds NetWatcher probes the connection at several distances down the same chain. It isn't guessing; it is finding the boundary between the lanes that still work and the ones that don't.
Three details the diagram flattens slightly:
Lanes 1 and 2 reach the same box but prove different things. Lane 1 is radio-level: your Mac is associated with the access point, with a signal strength. Lane 2 is IP-level: the router will actually answer packets. You can absolutely have 1 pass while 2 fails (strong bars, dead router), and that combination is precisely case B.
Lane 3's endpoint is a simplification. Your DNS resolver is usually the router itself, forwarding upstream to the ISP. It's drawn terminating at the ISP because that's the furthest it depends on: a DNS failure means something in that whole forwarding chain broke, which is why lane 3 dying while lane 4 lives is the signature of "DNS is the problem, the connection is fine."
There's a fourth case the path can't show: everything passes but the answer is wrong. A
captive portal (hotel, airport, café) lets every lane succeed while silently intercepting
your traffic. That's why lane 4 checks the content of the response from
captive.apple.com rather than just whether bytes came back: a 200 with the wrong body
means "sign in", not "outage".
| Lane | Cadence |
|---|---|
| link, gateway, internet | every tick (2 s) |
| dns, captive portal | every 30 s, or every tick while something is failing |
| destination checks | every 5 min, or every 30 s while the internet lane is failing |
Every 2 seconds is 43,200 requests a day per install. ICMP at that rate is ordinary monitoring traffic. Recursive DNS queries and HTTPS requests to third-party servers at that rate are not: the predictable result is rate-limiting, and NetWatcher would then report someone else's rate limiter as your outage. A monitoring tool must not provoke the failure it measures. So the slow tiers idle and escalate to full resolution the instant anything fails, exactly when the answers matter, and never when they don't.
The DNS check asks for a random label under a random nonexistent top-level domain
(a7f3k2z9xkqp.mvqhtdzblnrw), which no cache can answer and no domain owner pays for. The
root servers reply "no such name", and that reply is the proof your resolver can reach the
outside world.
The internet check pings three public resolvers run by unrelated operators: Cloudflare, Quad9 and Google, and counts as failed only when every one goes quiet, so one company deprioritising ping traffic never shows up in your report as your packet loss.
Pings prove packets reach public infrastructure. They do not prove the places you actually go are reachable, and "I can ping 1.1.1.1 but Google won't load" is a real and confusing state. So NetWatcher also asks google.com, bing.com and duckduckgo.com for a response over HTTPS. Any response counts, a redirect or an error page included: the question is whether the destination answers, not what it says.
When an outage is confirmed (three consecutive down samples, not one dropped tick), the app tells you, and it tells you again when the connection comes back, naming the cause and how long it lasted. Three independent channels, each switchable in the dashboard's Outage alerts section:
| Channel | Availability |
|---|---|
| System notification | Once you allow the prompt on first launch |
| Sound | Always |
| NetWatcher's own alert panel | Always (a floating panel the app draws itself) |
Notifications work. A freshly cloned, ad-hoc signed build raises the standard "NetWatcher
would like to send you notifications" prompt on first launch, and allowing it is all there
is to it: no certificate, no configuration, no workaround. Verified on macOS 26, from
/Applications and from a build directory alike.
The default is a sound plus a system notification, with the app's own panel filling in only when a system notification cannot be delivered, so an outage is still announced on a machine where the prompt was declined, and never announced twice on one where it wasn't.
The panel is a non-activating floating window: it appears top-right where a notification
would have, over full-screen apps, without taking focus from what you were typing into, and
it leaves on its own after twenty seconds. Not an NSAlert: a modal dialog for an event you
cannot act on is a punishment, not a notice.
Every alert is written to the log with the channels it actually used, so "did it fire, and how" is a question with an answer:
log show --last 1h --predicate 'subsystem == "app.netwatcher.NetWatcher"' | grep alert:
# alert: channels=panel,sound title=Internet down body=ISP outage; your equipment is fine
# alert: channels=panel,sound title=Back online body=ISP outage; down for 24 seconds.Every 2-second sample is written to SQLite immediately and is never deleted, pruned, or downsampled. A sample holds the result and latency of every lane, the signal readings (RSSI, noise, tx rate, channel), the network name, and the interface actually carrying traffic.
That's 43,200 samples per day, roughly 1.1 GB per year, which NetWatcher discloses in its own UI. Hourly rollups are maintained alongside the raw data purely so a year of history can be charted instantly; they are derived data and can be rebuilt from the raw samples at any time.
The destination checks live in a table of their own rather than in columns on samples:
they happen 288 times a day and a sample happens 43,200 times, so a column would be mostly
nulls, written and indexed and kept forever. A round that did not run is stored as nothing at
all, never as the previous round carried forward.
Periods the app genuinely could not observe (system sleep, or the app being quit) are recorded as explicit gaps rather than invented data, and are excluded from uptime percentages. An overnight sleep must never read as an outage.
The database is location-sensitive. It records the hardware addresses (BSSIDs) of every access point you connect to, and those are geolocatable through public databases, so over time the file becomes a detailed record of where this machine has been. It is created readable only by you, and BSSIDs are redacted by default from any report you share with an ISP. Treat un-redacted exports as personal location data.
Things this project knows it has not proven, stated rather than left to be discovered.
- Sleep gap recording has never been observed firing in the wild. The code path that records a sleep gap is covered by tests and by synthetic wake notifications; an actual overnight sleep producing an actual gap row has not been watched happening. Treat it as implemented, not as verified.
- Notifications depend on a prompt being answered. macOS keeps the authorization record
against the bundle identifier, and a first prompt that is dismissed (or that is on
screen when the app is quit) becomes a permanent
deniedthat the app can never ask again about. It is not the ad-hoc signature's doing (the same binary under an identifier macOS has no record for prompts and is granted normally), but the effect is the same, and the only remedy afterwards is the switch in System Settings › Notifications, which the dashboard links to directly. This is why notifications are one channel of three rather than the mechanism. - macOS 14 is compiled against but never run on. The binary carries a macOS 14.0 deployment target and CI fails if that drifts, so the compiler rejects any API newer than 14.0. That is a real guarantee but a narrower one than it looks: it means nothing here needs macOS 15, not that macOS 14 works. No macOS 14 machine has ever run this. GitHub's macOS 14 runner image entered deprecation in July 2026, so a CI job pinned to it would expire within weeks of being written, and an expired job is a worse claim than an honest sentence. Treat macOS 14 as probably fine, never verified.
- Reading the network name requires Location permission. Since macOS Sonoma, Apple gates the Wi-Fi SSID behind Location Services. Signal strength, channel, and every probe work regardless; refuse the prompt and the network simply shows as "Wi-Fi (name unavailable)".
- NetWatcher cannot sample while the Mac is asleep. No user-space process can. Those periods become gaps, not guesses.
- Turning "Show in Dock" on mid-session leaves the menu bar a beat behind. The Dock icon and the ⌘-Tab entry appear immediately, but macOS hands the newly promoted app the application menu only at the next real activation: click a NetWatcher window or its Dock icon and it is there. Every launch after that starts in the chosen mode with the menu already correct.
- Launch at login depends on the code signature. A locally built app is ad-hoc signed,
and macOS may refuse to register it, particularly from outside
/Applications. The app never assumes it worked: the dashboard shows the state macOS actually holds and says what to do if registration was refused.make installis the supported placement, and a login item registered from a build directory will break the moment you runmake clean. - Upgrading from a build older than 0.4 costs two small things. The bundle identifier
changed to
app.netwatcher.NetWatcherin 0.4, and macOS keys two per-app records by identifier. Login-item registrations: System Settings › General › Login Items may now list NetWatcher twice, and the stale one has to go with the−button, becauseSMAppServicecan only ever speak for the running bundle's own identifier and no public API reaches another's. Preferences: your alert switches return to their defaults, a sound plus a system notification, with the app's own panel filling in when a notification cannot be delivered, which is the combination that works on every machine anyway. Nothing else moves; the database is addressed by path, not by identifier, and is untouched.
docs/ARCHITECTURE.md is the hour-saving tour: the two-target split,
what happens to a sample between a probe and the disk, why outages are an interpretation
stored beside the raw samples rather than instead of them, and why the samples table is not
keyed on the wall clock. docs/DESIGN.md is the detailed document behind
it, docs/DESIGN-REVIEW.md is the review that design was put
through and what it changed, and CONTRIBUTING.md covers building,
testing and the two rules the codebase actually enforces.
CHANGELOG.md is the shorter read: what the software does and the defects
worth remembering from building it.
Every push builds, tests and bundles the app on macOS 15 and macOS 26
(.github/workflows/ci.yml).
MIT. See LICENSE.


