New tool: Network Info - #15
Merged
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new “Network Info” helper tool to the MacTools menu-bar suite, providing a snapshot of the active interface/type, local IPv4/IPv6 + subnet/router, DNS servers, and an explicit (opt-in) public IP lookup designed to avoid silent third‑party requests.
Changes:
- Introduces
NetworkInfoKit+NetworkInfoControllerfor snapshot collection, parsing/validation, and public-IP request handling. - Adds a new SwiftUI popover UI + helper app target (
DMonteNetworkInfo) with tray icon state reflecting connectivity. - Wires the tool into the Toolbox catalog, packaging scripts, README, and changelog, plus adds parsing-focused unit tests.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/DMonteCoreTests/NetworkInfoKitTests.swift | Adds fixture-based tests for parsing/validation (DNS, masks, IP validation, endpoint wiring). |
| Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift | New helper app delegate using HelperPanelHost + tray status item and controller monitoring. |
| Sources/DMonteNetworkInfoApp/main.swift | New helper app entry point with single-instance guard and --open relaunch notification. |
| Sources/DMonteCore/ToolboxCatalog.swift | Registers the new Network Info tool in the Toolbox list. |
| Sources/DMonteCore/NetworkInfoView.swift | Implements the Network Info popover UI + settings overlay and click-to-copy rows. |
| Sources/DMonteCore/NetworkInfoSizing.swift | Adds tool-specific sizing and scale computation. |
| Sources/DMonteCore/NetworkInfoKit.swift | Adds snapshot model, resolv.conf parsing, address validation, interface classification, and system reads. |
| Sources/DMonteCore/NetworkInfoController.swift | Adds main-actor controller, path monitoring, snapshot refresh, and opt-in public IP lookup. |
| Sources/DMonteCore/AppPreferences.swift | Registers a default for the opt-in “automatic public IP lookup” preference (off). |
| Scripts/package_app.sh | Adds the Network Info helper to packaging. |
| README.md | Documents the new Network Info tool in the feature list. |
| Packaging/NetworkInfoInfo.plist | Adds helper app Info.plist for packaging/signing. |
| Package.swift | Adds DMonteNetworkInfo executable + target to SwiftPM package. |
| CHANGELOG.md | Adds Unreleased entry describing Network Info and its privacy posture. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
havokentity
added a commit
that referenced
this pull request
Jul 18, 2026
1. --open did nothing on first launch. The distributed notification is only posted by a *second* instance, so the very launch the Toolbox tile triggers never revealed the panel. Show it from applicationDidFinishLaunching when --open is present, matching Maintenance and Window Manager. 2. refresh() cancellation was decorative. The expensive read lived in a nested Task.detached whose handle was discarded, so it ignored the cancel entirely and a burst of NWPathMonitor callbacks could run several snapshot() walks at once. The retained task now *is* the detached read, and each refresh awaits the superseded one before starting — cancelling cannot interrupt a synchronous getifaddrs walk already in progress, so serialising is what actually prevents pile-up. 3. stop() only cancelled refreshTask despite claiming to abandon in-flight work. It now cancels the public-IP lookup (a request outliving stop() keeps talking to a third party after the tool was told to stand down, which is the thing this deliberately user-initiated call exists to avoid) and the copy reset, and clears the state their completions would have cleared. 4/5. Icon-only buttons relied on .help(), which is a tooltip and not a VoiceOver label. Added explicit accessibility labels to Refresh, Settings and the settings-overlay close button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New helper (DMonteNetworkInfo, com.havokentity.mactools.networkinfo) showing the active interface and its type, local IPv4, subnet mask, router, IPv6, and the configured DNS servers. Every value row is a click-to-copy button, since reading an address off a screen to type it somewhere else is the whole reason a person opens a tool like this. Local details come from getifaddrs and SystemConfiguration rather than parsing shelled-out ifconfig/netstat. The primary interface and gateway are read from the dynamic store's global state because that is where macOS already resolves "which of several live interfaces is primary", including while a VPN is up; DNS prefers the store and falls back to /etc/resolv.conf, which some VPN clients never update. IPv6 addresses go through getnameinfo, not inet_ntop, because macOS embeds a link-local address's scope id in the address bytes (the KAME convention) and inet_ntop would print the raw, wrong bytes. An interface usually holds several IPv6 addresses at once, so the global unicast one is preferred over unique-local and link-local instead of showing whichever getifaddrs listed first and flipping between them for no visible reason. An NWPathMonitor refreshes the snapshot on interface, address and reachability changes, so the panel is not stale the moment you switch networks, and the tray glyph tracks the active link. The public IP is the one thing here that talks to anyone but this Mac, so it is fenced off: nothing is sent until the user presses the button, the queried service (api.ipify.org) is named on the face of the button rather than buried in settings, and the automatic-lookup preference is opt-in and defaults to off. The request runs off the main actor on an ephemeral session with a short timeout, every failure reduces to an inline sentence instead of an alert, and the response body is validated as a lone IP literal before display -- a third party can serve a captive portal page from that URL and it must never be shown as the user's address. Switching networks drops a cached public IP so it cannot masquerade as current. No speed test. A throughput test means sustained multi-megabyte transfers from someone else's server, which is a separate decision about data usage and which server to trust, and does not belong in the same change as read-only local info. Parsing and formatting live in NetworkInfoKit as pure functions over strings, covered by 36 fixture-based tests that never open a socket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review of the Network Info tool. Six defects, all in the new code: - resolv.conf with CRLF line endings reported *zero* DNS servers. Swift treats "\r\n" as a single grapheme cluster, so splitting on the literal "\n" never matched a CRLF break and the whole file collapsed into one unparsable line; a carriage return is also absent from CharacterSet.whitespaces, so trimming left it glued to the address. Split on `isNewline` and trim newlines too. - The popover told the user "Nothing is sent until you press the button" even with the automatic lookup switched on, when the request does fire on every network change. That sentence is the basis of consent, so it now tracks the preference. - NWPathMonitor is single-use: stop() cancelled it and start() then re-armed the dead instance, leaving the tool frozen on a stale snapshot with nothing to show anything was wrong. The monitor is now built per start(). - refresh() let reads pile up, so a network transition (which fires several path callbacks) could land a slower earlier read last and overwrite the newer snapshot. Reads now supersede one another, and an unchanged snapshot is no longer republished — that was rewriting the tray icon for nothing. - A public-IP lookup in flight across a network change survived it: the value it returned was obtained against the previous path, and clearing the status line left the button disabled with no explanation. It is now abandoned. - ipv4String rebound any sockaddr to sockaddr_in without checking the family. ifa_netmask comes back AF_UNSPEC for some tunnel interfaces, which rendered as a bogus "0.0.0.0 (/0)" subnet. Guarded. Also registers the public-IP preference default explicitly, as the controller's own doc comment says the integrator should. Tests: testPublicIPRejectsOversizedBody passed against a broken implementation (the padded body was not an IP address anyway, so the size cap could have been set to 8 and every rejection test still passed). Added the missing half — the longest legitimate address must be accepted — plus a multi-address body, and regression coverage for the CRLF and bare-CR resolv.conf cases, verified to fail against the pre-fix parser. 468 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. --open did nothing on first launch. The distributed notification is only posted by a *second* instance, so the very launch the Toolbox tile triggers never revealed the panel. Show it from applicationDidFinishLaunching when --open is present, matching Maintenance and Window Manager. 2. refresh() cancellation was decorative. The expensive read lived in a nested Task.detached whose handle was discarded, so it ignored the cancel entirely and a burst of NWPathMonitor callbacks could run several snapshot() walks at once. The retained task now *is* the detached read, and each refresh awaits the superseded one before starting — cancelling cannot interrupt a synchronous getifaddrs walk already in progress, so serialising is what actually prevents pile-up. 3. stop() only cancelled refreshTask despite claiming to abandon in-flight work. It now cancels the public-IP lookup (a request outliving stop() keeps talking to a third party after the tool was told to stand down, which is the thing this deliberately user-initiated call exists to avoid) and the copy reset, and clears the state their completions would have cleared. 4/5. Icon-only buttons relied on .help(), which is a tooltip and not a VoiceOver label. Added explicit accessibility labels to Refresh, Settings and the settings-overlay close button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
havokentity
force-pushed
the
feat/network-info
branch
from
July 18, 2026 10:59
e9cd9e1 to
588af51
Compare
Launching with `--open` put the panel in the bottom-left of a display that
wasn't even the one with the menu bar. The show was deferred by a single
`DispatchQueue.main.async` on the theory that one runloop turn was enough for
the status item to exist. It is not: measured on a cold start, the button
reports its real size (33 x 29) immediately while its window still sits at the
origin, and only reaches the menu bar about 20 ms later.
t=0.00 windowFrame=(0, 0, 33, 0) anchor=(0, -13, 33, 29)
t=0.02 windowFrame=(-33, 1410, 33, 30) anchor=(-33, 1412, 33, 29)
Anchoring to that first frame positions the panel relative to (0, 0), so it
lands in the corner of whichever display owns that point.
Two changes, both in the shared host so every tool benefits — any helper
launched with `--open` from the Toolbox tile can hit this:
`isAnchorReady` tests position, not size. A status item lives above its
screen's visible frame, in the menu bar strip; until the anchor is up there it
has not been placed. Size alone looks valid far too early.
`showWhenAnchored` polls on a 20 ms timer until that holds, then shows
regardless after two seconds. Re-dispatching with `async` instead would expire
in microseconds, which is the same as not waiting at all. An unanchored panel
is cosmetic; a panel that never appears is a broken tool.
`position(_:size:)` now treats a not-yet-placed anchor as no anchor and
centres, so the corner placement cannot happen even if a caller shows early.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The panel still opened in the top-right corner. Waiting for the item to reach
the menu bar is not enough — it gets there, leaves, and comes back somewhere
else. Measured over a cold --open launch:
t=0.00 (0, -13) not placed
t=0.00 (2536, 1410) placed, top right
t=0.10 (0, -30) back to the origin
t=0.20 (1393, 1484) final
The previous fix showed at the first placed frame, which is that transient
top-right position, and the panel then stayed there while the icon moved on.
Anchoring was never the problem; sampling was.
Poll until the frame repeats unchanged three times over and only then show.
Verified across three cold launches: the panel lands at X=1249 Y=30 every
time, under the icon's settled position, where it previously landed at 2536.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…unch Window Manager opened in the wrong place for exactly the reason Network Info did, so the wait belongs in one place instead of one tool. Extracted the settle check into `StatusItemAnchor`: `placedFrame(of:)` for "is the item in a menu bar yet" and `whenSettled(_:perform:)` for "has it stopped moving". `HelperPanelHost` now delegates to it rather than carrying a private copy, and the two tools that open a panel at launch use it: - Maintenance goes through `panelHost.showWhenAnchored()`. - Window Manager positions its own panel, so it calls `whenSettled` directly around `showPanel()`. Verified: Window Manager lands at X=3519 Y=37 on two consecutive cold launches, under the menu bar rather than at an unsettled position. Duplicate Finder and Grab Text were checked and deliberately left alone. Both match the same `--open` + async shape, but they call `windowHost.show()` with no anchor argument, so they centre by design; only the click path anchors, and by then the item has long since settled. Changing them would have been a fix for a bug they do not have. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PreferencesOverlay centres the settings sheet over the panel. The panel's size runs through NetworkInfoSizing.preferredSize() and so tracks currentScale, but the sheet carried a hard-coded .frame(width: 340, height: 300) in the view. The two therefore only agreed at currentScale == 1; below that the panel shrank and the sheet did not, and because it is centred the excess was clipped symmetrically, taking the first and last character of every row. On this Mac the menu bar is 22pt, giving currentScale 0.846: panel 311 x 440 settings 288 x 254 (was 340 x 300) The old sheet overflowed the 311pt panel by 29pt, 14.5pt clipped per side. settingsSize() now scales the same literals and additionally caps width at preferredSize().width, so the sheet cannot exceed the panel at any scale even if the two base widths are later changed independently. Moving the size out of the SwiftUI modifier and into the Sizing enum is what makes the invariant testable; NetworkInfoSizingTests asserts the sheet never exceeds the panel, that both dimensions track currentScale, and that the scale stays within 0.82...1.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first version of this fix scaled the sheet by `currentScale` alongside the panel. That is right where the sheet is as wide as its panel, but wrong where it is narrower: Scratchpad's 320pt sheet sits in a 380pt panel, so scaling shrank it to 271 inside a 322 panel — 51pt of inset added to a tool that was never clipped. The sheet's contents are laid out with unscaled padding, so squeezing the frame risks clipping inside the sheet rather than outside it. The requirement is only that the sheet never exceeds the panel, so cap it: `min(designSize, panelSize)`. Identical for the four tools whose sheet matches or exceeds its panel, and strictly better for the two where it does not — Scratchpad keeps its designed 320 and Network Info gains 23pt back. The tests that broke were the ones restating the arithmetic; the ones asserting "the sheet fits the panel" passed through the change untouched. Replaced the former with the actual contract: the sheet uses its design size unless the panel is smaller. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The earlier cap-at-panel-width fix was incomplete. PreferencesOverlay wraps the sheet in 18pt of padding on every side, so a sheet sized to the full panel becomes panel+36 once padded and overflows. That over-wide overlay layer then dragged the panel content behind it off both edges — the header's title and gear spilled past the window and the footer pushed below it — reproduced and fixed by eye on the real NSHostingController path. settingsSize now caps at panel minus 36 (2×18), so the padded sheet fits the panel exactly and the content behind it stays put. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md # Package.swift # README.md # Scripts/package_app.sh # Sources/DMonteCore/AppPreferences.swift # Sources/DMonteCore/ToolboxCatalog.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
New menu-bar tool showing the current network at a glance: active interface and type, local IPv4/IPv6, subnet mask, router, DNS servers, and (on request) the public IP. Every value is click-to-copy, and the view refreshes when the network changes.
Privacy
The public IP is never fetched silently. It requires an explicit button press, the queried service is named in the UI, failure is reported honestly, and the call never blocks the main actor — fetching it at launch would leak your IP to a third party every time the tool opened.
No speed test. A throughput test means sustained multi-megabyte transfers from a third-party server; that is a separate decision about data usage and which server to trust, and it does not belong in the same change as read-only local info.
Local details come from system APIs (
getifaddrs, SystemConfiguration) following the C-interop pattern already established inSystemMetricsProvider, not by shelling out.Tests: 428 → 468, all passing. Tests parse fixtures and never touch the network.
Reviewer notes
The adversarial review found DNS parsing returned zero nameservers for any CRLF-terminated
resolv.conf— Swift treats\r\nas a single grapheme cluster, sosplit(separator: "\n")never matched and the whole file collapsed into one unparsable line. Fixed, with a regression test.🤖 Generated with Claude Code