Repository navigation
Building Testing and Releasing
- To run: macOS 13 or later, Apple silicon or Intel.
-
To build: Apple's Command Line Tools (
xcode-select --install). Xcode and a developer account aren't needed, though everything works under Xcode too. Notarizing, which only a Developer ID build needs, usesnotarytoolandstapler, which come with Xcode. -
Swift: a Swift 6 toolchain.
Package.swiftdeclares tools version 5.9 on purpose, which keeps the Swift 5 language mode, so the package builds without Swift 6 strict-concurrency annotations. -
One dependency: Sparkle (#132), pinned to an exact release in
Package.swiftand fetched by SwiftPM with its checksum. Usage History uses the system's own SQLite. -
make dmgalso needspython3(part of the Command Line Tools) and network access the first time, to installdmgbuildinto.build/dmgbuild-venv. Nothing is added to the system Python.
git clone https://github.com/kevinabouhanna/NetworkMonitor.git
cd NetworkMonitor
make hooks # once per clone: run the tests before every push
make test # run the suite
make run # build build/NetworkMonitor.app and open itImportant
A development build has the same bundle identifier as the installed app, so it shares its settings and data. Quit the installed copy before running one, put back any setting you change, and relaunch the installed copy afterwards.
| Target | What it does |
|---|---|
make test |
Runs the test suite |
make build |
Debug build of the package |
make app |
Release build of build/NetworkMonitor.app for this Mac's architecture, signed (see step 4 below), with the helper inside |
make universal |
The same, for arm64 and x86_64 |
make dmg |
universal, then build/NetworkMonitor.dmg
|
make run |
app, then quit any running copy and open the new one |
make install |
Builds, notarizes it when it carries the content filter (the login keychain must be unlocked), copies to /Applications, and lets the app register itself to start at login |
make install-only |
The same without starting at login |
make update |
git pull --ff-only, the tests, then install. Tests first, so a bad pull can't replace a working copy |
make uninstall |
Scripts/uninstall.sh (see below) |
make helper |
Installs the sudo helper by hand, with its hourly self-check, for builds without a Developer ID. Asks for your password |
make helper-no-daemon |
The same without the hourly self-check |
make metering-report |
Prints what Hotspot mode reaches on this Mac, pending update downloads and the Low Data Mode state. Read-only, safe beside a running copy |
make icon |
Redraws Resources/AppIcon.icns from Scripts/make-icon.swift
|
make geodata |
Rebuilds Resources/Geo from DB-IP's current month. The files are committed, because DB-IP removes old months; refresh them in a pull request of their own |
make dmg-background |
Redraws the disk image background |
make hooks |
Points git at Scripts/hooks, so pre-push runs the tests |
make clean |
Removes .build and build
|
Two things in the Command Line Tools 27 shape the code:
-
SwiftUI's
@Stateis a macro in the macOS 27 SDK, and its implementation ships only with Xcode, so@Statedoesn't compile ("plugin for module 'SwiftUIMacros' not found"). View state lives in smallObservableObjects with@Publishedinstead; seeSettingsPaneState. -
Swift 6.4's default
swiftbuildbuild system fails before compiling anything ("Unknown error parsing property list"). Every build goes throughScripts/swiftpm.sh, which asks for the native build system. SwiftPM prints a deprecation warning about it on every run; that's expected. Drop the wrapper onceswiftbuildworks with the Command Line Tools.
| Target | Kind | What's in it |
|---|---|---|
NetworkMonitorCore |
library | Almost everything: measurement, attribution, storage, Hotspot mode, speed test, all the SwiftUI views |
NetworkMonitor |
executable | A thin shell: main.swift and AppDelegate.swift (status item, popover, menus, Settings window, lifecycle) |
LowDataMode |
library | Interface flags, the helper's report, and HelperProtocol.version, shared by the app and the helper so the two can't disagree |
HostsFile |
library |
/etc/hosts editing, shared by the helper and the tests |
ServerDirectory |
library | Which country an address is in and whose network it belongs to, from Resources/Geo. Pure Foundation, so the content filter can use it without the app's frameworks |
GeoDataBuilder |
executable | Converts DB-IP's CSVs into Resources/Geo. Run by make geodata, never part of the app |
MapDataBuilder |
executable | Converts Natural Earth's countries into Resources/Geo/world.bin, the Connections map. Run by Scripts/mapdata.sh, never part of the app |
FilterProtocol |
library | What the app and its content filter agree on: identities, the XPC interfaces, FlowReport. Foundation only |
FirewallPolicy |
library | What may connect (#114): the rules, the mode and the decision, for the app that keeps them and the filter that applies them. Foundation only |
NetworkMonitorFilter |
executable | The content filter, a Network Extension system extension (#128). In the bundle only for Developer ID builds |
NetworkMonitorHelper |
executable | The root helper. Links only LowDataMode and HostsFile, never Core: a root binary shouldn't carry a menu bar app's dependencies |
NetworkMonitorTests |
executable | The test suite |
Tested internals are public rather than reached with @testable import, which compiled
in debug but broke make app.
Architecture explains how the parts fit together.
make test runs NetworkMonitorTests, a plain executable rather than swift test,
because neither XCTest nor swift-testing ships with the Command Line Tools. It has a small
harness (Harness.swift): Check.suite, Check.test, expectEqual and friends, named
after XCTest so moving to a real test target later is a rename. It prints a line per test
and exits non-zero if anything fails.
-
Live tests, which need a real network and a real
nettop, are skipped whenCIis set. Run them anyway withNETMON_LIVE_TESTS=1. They include guards such as "the stream stays under 25% of a core" and "changing the interval leaves exactly onenettop". -
The speed test is never run for real in the suite: each real run costs 50–500 MB.
It runs against a stand-in
networkQualityscript; see speed test. - Popover layout constants are pinned by tests calibrated against what SwiftUI actually lays out.
There's no Xcode project. Scripts/bundle.sh assembles the bundle by hand, because
LSUIElement (menu bar only, no Dock icon) only works from a real Contents/Info.plist:
-
Version stamping.
CFBundleShortVersionStringis the latest git tag;CFBundleVersionis the commit count, which only ever increases (an updater compares it). Both are stamped into the plist before signing, andMARKETING_VERSIONandBUILD_NUMBERoverride them. Any CI checkout that bundles needsfetch-depth: 0, or every build is number 1. -
Release build of the app and the helper. A universal build compiles each architecture in its own scratch directory and joins them with
lipo, because the one-step--arch arm64 --arch x86_64needs Xcode's build system. The helper is universal too, since it's installed from inside the bundle on whatever Mac runs it. -
Layout: the app binary,
Info.plist, the icon, the helper inContents/Library/LaunchServices/,helper-setup.shinResources, the country and map data inResources/Geo, andSparkle.frameworkinContents/Frameworks, without its XPC services, which only sandboxed apps need. -
Signing, inside out and without
--deep: the helper first, with its own identifier, then the app.--deepre-signed the helper with an identifier guessed from its file name, which was the app's.Scripts/signing.shchooses what with, the first of these that's there:-
The Developer ID (issue #119):
SIGN_IDENTITYfor an identity already in a keychain, or the.p12inDEVID_P12and its password inDEVID_P12_PASSWORD, by default~/.config/networkmonitor-signing/developer-id.p12anddeveloper-id.password. Both get the hardened runtime and a timestamp, and the app getsResources/NetworkMonitor.entitlements, whose one entitlement is Location for Wi-Fi names (#85). Runningnettop, and AppleScript'sdo shell scriptfor the helper's prompt, were checked to need nothing. Offline there's no timestamp: the build is signed without one and says it can't be notarized. -
NetworkMonitor's own certificate (issue #86): the
.p12inSIGNING_P12and its password inSIGNING_P12_PASSWORD, orsigning.p12andpasswordin the same folder. It's self-signed, so Gatekeeper asks for Open Anyway, but every build has the same designated requirement, so macOS keeps Location across updates. - Ad-hoc, without either: forks and pull request CI. Each build is a new app to macOS.
A
.p12is imported into a throwaway keychain, deleted afterwards, so the key never sits in the login keychain and nothing prompts. Sparkle's pieces are re-signed inside out with whichever of these the build uses.With the Developer ID,
bundle.shalso adds the helper daemon's plist (Contents/Library/LaunchDaemons/com.kevinabouhanna.NetworkMonitor.daemon.plist, #131) and Sparkle's feed and public key toInfo.plist(#132), and assembles the content filter's bundle (Contents/Library/SystemExtensions/com.kevinabouhanna.NetworkMonitor.Filter.systemextension), embeds the two provisioning profiles fromResources/Profiles/, which grant its entitlements and ship in every copy, and signs the filter, the helper and the app inside out. The app then usesResources/NetworkMonitor+Filter.entitlements. The country and owner tables move into the filter, which needs them, and the app links to them there. The filter's XPC service is named after the build (#142). Without the Developer ID or the profiles, the filter is left out and nothing else changes.macOS only activates a system extension from a notarized app, and the helper daemon and Sparkle need one too, so try them with
make install, which notarizes. -
The Developer ID (issue #119):
-
Notarizing, with
Scripts/notarize.sh <app or dmg>: submits to Apple, waits, and staples the ticket. It uses the API key inNOTARY_KEY,NOTARY_KEY_IDandNOTARY_ISSUERif set, or else the keychain profilenetworkmonitor, saved once withxcrun notarytool store-credentials networkmonitor --key … --key-id … --issuer ….NOTARIZE=1 make dmgnotarizes the app before packaging it, then the disk image.
Scripts/make-dmg.sh packages the app with dmgbuild (Scripts/dmg-settings.py): an
LZFSE-compressed image with the app, an Applications link and a light background. The
background is light on purpose: Finder draws icon labels black over a background picture
even in dark mode. The file name has no version, so
releases/latest/download/NetworkMonitor.dmg always works; the website links to it.
-
Scripts/install.shbuilds, copies to/Applications, strips the quarantine flag, and opens the app, which registers itself to start at login.--no-loginskips that. -
Scripts/uninstall.shquits the app and anynettopit left, puts back what Hotspot mode changed, removes the helper (withsudo, so it asks for your password), withdraws the login item and the helper daemon, and removes the app. Unlike the in-app Uninstall, it keeps your usage data unless you pass--purge. -
Scripts/install-helper.shinstalls the helper for development, through the samehelper-setup.shthe app uses. -
Scripts/smoke-test.shlaunches the built app and fails if it isn't still running five seconds later. Deliberately shallow: a crash on launch is the one bug an auto-updater would ship to everyone. -
Scripts/appcast.shandScripts/ed25519-sign.swiftsign a release's disk image with the update key and write its appcast (#132).Scripts/geodata.shandGeoDataBuilder packageconvert and sign a month of country data (#121).
.github/workflows/ci.yml runs on every push to main and every pull request. Both jobs
must be green before a merge.
-
build-and-test: build,make test(live tests skipped),make app, checks on the bundle (signature, plist, icon), and the smoke test. -
universal:make universal, and checks that both architectures are there.
Every change follows the same path:
- An issue that says what and why. Keep its description current: it's the record of what was decided.
-
A pull request from a branch named after the issue (for example
settings-v2-9), withCloses #Nin its description. CI must be green. Squash-merge. Nothing is pushed tomaindirectly. - A release once it's merged (below), unless the change is to the website or docs only.
- Write the version's What's new in
ReleaseNotes/X.Y.Z.mdand merge it like any change. It's Markdown: short bullets, each with a bold lead and its issue, and no]]>. Sparkle shows it in the update window, carried in the appcast (#152) with the nine versions before it, as HTML fromScripts/release-notes.swift. Each version is a<div data-sparkle-version="BUILD">, and Sparkle hides the installed one and everything older, so a reader several versions behind sees each version they missed (#163). The workflow puts this version's file alone in the release notes, under What's new in X.Y.Z. A tag without it fails in the first seconds. - Tag
mainand push the tag:git tag vX.Y.Z && git push origin vX.Y.Z. - The Release workflow (
.github/workflows/release.yml) runs the tests, builds the universal DMG with the tag's version, checks the version, the helper, the content filter and the signature, signs the update and writes the appcast withSPARKLE_ED_KEY, and publishesNetworkMonitor.dmgandappcast.xmlwith install and update instructions. It signs with the Developer ID in the repository secretsDEVID_P12_BASE64andDEVID_P12_PASSWORD, notarizes with the API key inNOTARY_KEY_BASE64,NOTARY_KEY_IDandNOTARY_ISSUER, and fails without them. It then checks the team (GRQKG7M8CL), the Location entitlement, and thatspctlaccepts both the app and the disk image as Notarized Developer ID. - Install it with
make installfrom the tag. - Update the website (below).
The keys behind these secrets are never committed. The update key's public half is
SUPublicEDKey in bundle.sh, and the data key's is GeoDataUpdater.publicKey; the
Data refresh workflow signs with GEODATA_SIGNING_KEY. The Developer ID certificate is
valid until 28 November 2027. The provisioning profiles name it, so they're renewed with it.
kevinabouhanna.github.io/NetworkMonitor
is a static page on the gh-pages branch, conveniently checked out as a worktree
beside the main checkout. Changes to it get an issue and a pull request against
gh-pages, but no release.
-
Closes #Nonly closes issues for pull requests intomain, so close the issue by hand. - Don't merge with
gh pr merge --delete-branchwhile the branch is checked out in the worktree:ghremoves the worktree along with it. - After each release, update the hero's version, the download line's version and DMG
size (in 1024-based MB), the "What's in version" link, and the About pane capture
(
assets/settings/about.png), which shows the version. -
Every image is a real capture of the app, never a mock-up. The Settings section
shows each pane as a capture in
assets/settings/, with the window's size in points on each<img>and clickable areas placed in the same points.
The wiki is a copy of docs/ and
SECURITY.md, made by Scripts/wiki.py (#177). The Wiki workflow
(.github/workflows/wiki.yml) rebuilds it whenever they change on main, so change the
docs here, never the wiki.
- A page comes and goes with its doc, and the sidebar follows the tables in
docs/README.md. - Links are rewritten for the wiki, and images point at the commit that was copied.
- Only files git tracks are copied, so the private notes git ignores in
docs/stay private. - To rebuild it by hand, run the workflow from the Actions tab, or
python3 Scripts/wiki.py <wiki checkout>and push.
A development build reads a few launch arguments for that run only; nothing persists.
| Argument | Does |
|---|---|
-powerProfile balanced (or performance, saver) |
measures a power profile without unplugging (below) |
-open connections, -open welcome, -open settings -pane history
|
opens a window straight away, for a screenshot or a check |
-geoDataURL http://127.0.0.1:8765/ -geoDataCheckNow YES |
fetches the monthly data from a local package, at once (#121) |
-updateFeedURL http://127.0.0.1:8765/appcast.xml -checkForUpdatesNow YES |
checks a local appcast for an update, at once (#132) |
Measure a power profile without unplugging, using a launch argument that's read only for that run:
open -n build/NetworkMonitor.app --args -powerProfile balanced # or performance, saverOpen a window straight away, for a screenshot or a quick check, the same way:
open -n build/NetworkMonitor.app --args -open connections
open -n build/NetworkMonitor.app --args -open settings -pane history # any paneThen time the CPU of the app and its nettop child with ps -o time= over a couple of
minutes. Run it in the foreground: top -l hangs when run in the background, and its
IDLEW column is a running total, not a rate. Accuracy and battery
has the current figures.
Using NetworkMonitor
- Installing, updating and removing
- Features
- Connections
- Usage History
- Speed test
- Hotspot mode
- Accuracy and battery
- Privacy
- Security policy
Working on NetworkMonitor