Skip to content

New tool: Network Info - #15

Merged
havokentity merged 10 commits into
mainfrom
feat/network-info
Jul 21, 2026
Merged

New tool: Network Info#15
havokentity merged 10 commits into
mainfrom
feat/network-info

Conversation

@havokentity

Copy link
Copy Markdown
Owner

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 in SystemMetricsProvider, 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\n as a single grapheme cluster, so split(separator: "\n") never matched and the whole file collapsed into one unparsable line. Fixed, with a regression test.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + NetworkInfoController for 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.

Comment thread Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift
Comment thread Sources/DMonteCore/NetworkInfoController.swift Outdated
Comment thread Sources/DMonteCore/NetworkInfoController.swift
Comment thread Sources/DMonteCore/NetworkInfoView.swift
Comment thread Sources/DMonteCore/NetworkInfoView.swift
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>
havokentity and others added 3 commits July 18, 2026 16:27
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
havokentity changed the base branch from fix/deep-review-2026-07 to main July 18, 2026 11:23
havokentity and others added 7 commits July 18, 2026 18:22
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
@havokentity
havokentity merged commit cb218b4 into main Jul 21, 2026
1 check passed
@havokentity
havokentity deleted the feat/network-info branch July 21, 2026 06:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants