From 3b630eb2b7a8586c00bff792a193030ed6b6ad9a Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 13:48:48 +0530 Subject: [PATCH 1/9] Network Info: add a menu bar tool for the current network 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 --- CHANGELOG.md | 12 + Package.swift | 11 + Packaging/NetworkInfoInfo.plist | 32 ++ README.md | 1 + Scripts/package_app.sh | 1 + .../DMonteCore/NetworkInfoController.swift | 230 ++++++++++ Sources/DMonteCore/NetworkInfoKit.swift | 416 ++++++++++++++++++ Sources/DMonteCore/NetworkInfoSizing.swift | 17 + Sources/DMonteCore/NetworkInfoView.swift | 359 +++++++++++++++ Sources/DMonteCore/ToolboxCatalog.swift | 3 +- .../NetworkInfoAppDelegate.swift | 93 ++++ Sources/DMonteNetworkInfoApp/main.swift | 24 + .../DMonteCoreTests/NetworkInfoKitTests.swift | 253 +++++++++++ 13 files changed, 1451 insertions(+), 1 deletion(-) create mode 100644 Packaging/NetworkInfoInfo.plist create mode 100644 Sources/DMonteCore/NetworkInfoController.swift create mode 100644 Sources/DMonteCore/NetworkInfoKit.swift create mode 100644 Sources/DMonteCore/NetworkInfoSizing.swift create mode 100644 Sources/DMonteCore/NetworkInfoView.swift create mode 100644 Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift create mode 100644 Sources/DMonteNetworkInfoApp/main.swift create mode 100644 Tests/DMonteCoreTests/NetworkInfoKitTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f5cf2f..6576f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ adheres to [Semantic Versioning](https://semver.org) and the and runs the full test suite on a pinned macOS runner, using the same toolchain the release workflow ships with. Previously only version tags ran CI, so a branch could go unverified until release day. +- **Network Info**: a new menu bar tool showing the active interface and its + type, local IPv4, subnet mask, router, IPv6, and the configured DNS servers, + with every value click‑to‑copy. Local details are read from `getifaddrs` and + SystemConfiguration and never leave the Mac; the view refreshes itself when + the network changes rather than only at launch. The public‑IP lookup is a + separate, explicitly user‑initiated button that names the service it queries + (`api.ipify.org`) and can optionally be set to run automatically — it is + never fetched silently at launch, because that would disclose your address to + a third party every time the tool opens. No speed test: a throughput test + means sustained multi‑megabyte transfers from someone else's server, which is + its own decision about data usage and which server to trust, and does not + belong in a read‑only local‑info tool. ## [0.13.0] — 2026-07-12 diff --git a/Package.swift b/Package.swift index e40120a..731bf59 100644 --- a/Package.swift +++ b/Package.swift @@ -95,6 +95,10 @@ let package = Package( .executable( name: "DMonteWindowManager", targets: ["DMonteWindowManager"] + ), + .executable( + name: "DMonteNetworkInfo", + targets: ["DMonteNetworkInfo"] ) ], dependencies: [ @@ -253,6 +257,13 @@ let package = Package( ], path: "Sources/DMonteWindowManagerApp" ), + .executableTarget( + name: "DMonteNetworkInfo", + dependencies: [ + "DMonteCore" + ], + path: "Sources/DMonteNetworkInfoApp" + ), .testTarget( name: "DMonteCoreTests", dependencies: ["DMonteCore"], diff --git a/Packaging/NetworkInfoInfo.plist b/Packaging/NetworkInfoInfo.plist new file mode 100644 index 0000000..b820c2b --- /dev/null +++ b/Packaging/NetworkInfoInfo.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + DMonteNetworkInfo + CFBundleIdentifier + com.havokentity.mactools.networkinfo + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDisplayName + DMonte Network Info + CFBundleName + DMonte Network Info + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.13.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSMultipleInstancesProhibited + + LSUIElement + + NSHumanReadableCopyright + Copyright © 2026 Yahushad Monte + + diff --git a/README.md b/README.md index 925c765..1030096 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The app updates itself automatically via [Sparkle](https://sparkle-project.org); | **Keep Awake** | Prevent sleep, optionally for a set duration | | **Maintenance** | Handy Finder/system toggles and cache/index refreshes | | **Dev Tools** | JSON, Base64, URL, hashing, UUID, timestamp, and case utilities | +| **Network Info** | Interface, local IPv4/IPv6, subnet, router, and DNS at a glance — click any value to copy; public IP only on request | ### Permissions diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 9b0f96b..dff80bd 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -36,6 +36,7 @@ HELPERS=( "DMonteGrabText|DMonte Grab Text.app|GrabTextInfo.plist" "DMonteFocusTimer|DMonte Focus Timer.app|FocusTimerInfo.plist" "DMonteWindowManager|DMonte Window Manager.app|WindowManagerInfo.plist" + "DMonteNetworkInfo|DMonte Network Info.app|NetworkInfoInfo.plist" ) stamp_version() { diff --git a/Sources/DMonteCore/NetworkInfoController.swift b/Sources/DMonteCore/NetworkInfoController.swift new file mode 100644 index 0000000..0aaee01 --- /dev/null +++ b/Sources/DMonteCore/NetworkInfoController.swift @@ -0,0 +1,230 @@ +import AppKit +import Foundation +import Network + +public extension DefaultsKey { + /// Whether the public-IP lookup may run on its own — on open and after each network change — + /// instead of only when the user presses the button. Opt-in: the integrator should register a + /// default of `false`, because every automatic lookup discloses the user's address to a third + /// party that they never asked to contact. + static let networkInfoFetchesPublicIPAutomatically = "tool.networkInfo.fetchesPublicIPAutomatically" +} + +/// Owns the current `NetworkSnapshot`, the path monitor that refreshes it when the network +/// changes, and the opt-in public-IP lookup. All published state is mutated on the main actor; +/// the two slow operations (the `getifaddrs`/SystemConfiguration read and the HTTPS request) are +/// hopped off it explicitly. +@MainActor +public final class NetworkInfoController: ObservableObject { + /// Local network facts. Starts empty and is filled by the first `refresh()`, so the popover + /// can be constructed before any system read has happened. + @Published public private(set) var snapshot = NetworkSnapshot() + + /// Public address as last reported by `NetworkInfoKit.publicIPServiceHost`, or `nil` if it has + /// never been fetched successfully in this session. Deliberately not persisted — a stale + /// public IP shown as current is worse than showing nothing. + @Published public private(set) var publicIP: String? + + /// Inline status for the public-IP row: in-progress text, or an honest failure sentence. `nil` + /// once an address is showing and nothing needs saying. + @Published public private(set) var publicIPStatus: String? + + @Published public private(set) var isFetchingPublicIP = false + + /// See `DefaultsKey.networkInfoFetchesPublicIPAutomatically`. + @Published public private(set) var fetchesPublicIPAutomatically: Bool + + /// Set for a few seconds after a successful copy so the view can flash a checkmark on the row + /// that was copied. Holds the row's label, not its value, so two rows sharing a value (an + /// IPv4 router equal to a DNS server, which is the common home-router case) don't both flash. + @Published public private(set) var copiedRowID: String? + + private let pathMonitor = NWPathMonitor() + private let pathMonitorQueue = DispatchQueue(label: "com.havokentity.mactools.networkinfo.path") + private var isMonitoring = false + + private var copiedResetTask: Task? + private var publicIPTask: Task? + + /// Ephemeral so the public-IP request leaves no cookie, credential or cache trace on disk, and + /// short-timeout so a captive portal that black-holes the connection surfaces as a failure + /// message within a few seconds instead of spinning indefinitely. `nonisolated` so the request + /// below can run off the main actor. + private nonisolated static let publicIPSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 8 + configuration.timeoutIntervalForResource = 12 + configuration.waitsForConnectivity = false + configuration.httpShouldSetCookies = false + configuration.httpCookieAcceptPolicy = .never + return URLSession(configuration: configuration) + }() + + public init() { + fetchesPublicIPAutomatically = AppDefaults.shared.bool(forKey: DefaultsKey.networkInfoFetchesPublicIPAutomatically) + } + + deinit { + // `deinit` is nonisolated; cancel the monitor directly so its dispatch source is torn down + // even on a path that never reaches `applicationWillTerminate`. `cancel()` on an + // already-cancelled or never-started monitor is a no-op. + pathMonitor.cancel() + } + + // MARK: - Public API + + /// Starts watching for network changes and takes the first reading. Safe to call more than + /// once; the monitor is only started on the first call. + public func start() { + refresh() + + guard !isMonitoring else { return } + isMonitoring = true + + // `NWPathMonitor` fires for interface, address and reachability changes alike, which is + // exactly the set of events that can invalidate the snapshot — polling on a timer would + // either lag behind a Wi‑Fi switch or burn wakeups doing nothing. + pathMonitor.pathUpdateHandler = { [weak self] _ in + Task { @MainActor in + self?.handlePathChange() + } + } + pathMonitor.start(queue: pathMonitorQueue) + } + + /// Stops the path monitor. Called from `applicationWillTerminate` so the monitor's queue is + /// torn down before the process exits rather than during deallocation. + public func stop() { + guard isMonitoring else { return } + isMonitoring = false + pathMonitor.cancel() + } + + /// Re-reads the local network facts. The read touches `getifaddrs` and SystemConfiguration, so + /// it runs detached and only the resulting value crosses back to the main actor. + public func refresh() { + Task { [weak self] in + let reading = await Task.detached(priority: .userInitiated) { + NetworkInfoKit.snapshot() + }.value + + guard let self else { return } + self.snapshot = reading + } + } + + /// Asks `NetworkInfoKit.publicIPServiceHost` for this Mac's public address. Never called + /// implicitly at launch — only from the button, or from `handlePathChange()` when the user has + /// explicitly opted in. + public func fetchPublicIP() { + guard !isFetchingPublicIP else { return } + + isFetchingPublicIP = true + publicIPStatus = "Asking \(NetworkInfoKit.publicIPServiceHost)…" + + publicIPTask?.cancel() + publicIPTask = Task { [weak self] in + let outcome = await Self.requestPublicIP() + + guard let self, !Task.isCancelled else { return } + self.isFetchingPublicIP = false + + switch outcome { + case .success(let address): + self.publicIP = address + self.publicIPStatus = nil + case .failure(let message): + // Keep any previously fetched address on screen rather than blanking it: a failed + // refresh does not mean the last good answer became wrong. + self.publicIPStatus = message + } + } + } + + /// Turns the automatic lookup on or off. Turning it on fetches immediately, because the user + /// just consented and would otherwise stare at an empty row until the next network change. + public func setFetchesPublicIPAutomatically(_ newValue: Bool) { + guard newValue != fetchesPublicIPAutomatically else { return } + fetchesPublicIPAutomatically = newValue + AppDefaults.shared.set(newValue, forKey: DefaultsKey.networkInfoFetchesPublicIPAutomatically) + + if newValue { + fetchPublicIP() + } + } + + /// Copies `value` to the general pasteboard and flashes `rowID` for a moment. + public func copy(_ value: String, rowID: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(value, forType: .string) + + copiedRowID = rowID + copiedResetTask?.cancel() + copiedResetTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(1.6)) + guard let self, !Task.isCancelled else { return } + self.copiedRowID = nil + } + } + + /// Clears the public address without touching the preference — used when the user wants the + /// value off the screen (e.g. before sharing a screenshot). + public func clearPublicIP() { + publicIPTask?.cancel() + publicIPTask = nil + isFetchingPublicIP = false + publicIP = nil + publicIPStatus = nil + } + + // MARK: - Private + + private func handlePathChange() { + refresh() + + guard fetchesPublicIPAutomatically else { + // The cached address almost certainly belongs to the previous network, so drop it + // rather than let it masquerade as current until the user presses refresh. + publicIP = nil + publicIPStatus = nil + return + } + + fetchPublicIP() + } + + private enum PublicIPOutcome: Sendable { + case success(String) + case failure(String) + } + + /// Performs the lookup off the main actor and reduces every possible outcome to one of two + /// display-ready cases — no error ever escapes to become an alert. Explicitly `nonisolated`: + /// the main actor must stay free while the request is in flight. + private nonisolated static func requestPublicIP() async -> PublicIPOutcome { + let host = NetworkInfoKit.publicIPServiceHost + + do { + var request = URLRequest(url: NetworkInfoKit.publicIPEndpoint) + request.setValue("text/plain", forHTTPHeaderField: "Accept") + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + let (data, response) = try await publicIPSession.data(for: request) + + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + return .failure("\(host) replied \(http.statusCode).") + } + + guard data.count <= NetworkInfoKit.maximumPublicIPResponseBytes, + let body = String(data: data, encoding: .utf8), + let address = NetworkInfoKit.publicIPAddress(fromResponseBody: body) else { + return .failure("\(host) didn’t return an IP address.") + } + + return .success(address) + } catch { + return .failure("Couldn’t reach \(host).") + } + } +} diff --git a/Sources/DMonteCore/NetworkInfoKit.swift b/Sources/DMonteCore/NetworkInfoKit.swift new file mode 100644 index 0000000..d65bcb8 --- /dev/null +++ b/Sources/DMonteCore/NetworkInfoKit.swift @@ -0,0 +1,416 @@ +import Darwin +import Foundation +import SystemConfiguration + +/// How the primary network service reaches the outside world. Drives the row label and glyph, and +/// is derived from SystemConfiguration's interface type rather than the BSD name, because `enN` +/// covers both Wi-Fi and Ethernet on Apple silicon and guessing from the name gets it wrong. +public enum NetworkLinkKind: String, Sendable, CaseIterable { + case wiFi + case ethernet + case cellular + case bluetooth + case vpn + case loopback + case other + + /// Sentence-case name shown in the "Type" row. + public var displayName: String { + switch self { + case .wiFi: return "Wi‑Fi" + case .ethernet: return "Ethernet" + case .cellular: return "Cellular" + case .bluetooth: return "Bluetooth" + case .vpn: return "VPN" + case .loopback: return "Loopback" + case .other: return "Other" + } + } + + /// SF Symbol for the header glyph. + public var symbolName: String { + switch self { + case .wiFi: return "wifi" + case .ethernet: return "cable.connector" + case .cellular: return "antenna.radiowaves.left.and.right" + case .bluetooth: return "dot.radiowaves.right" + case .vpn: return "lock.shield" + case .loopback: return "arrow.triangle.2.circlepath" + case .other: return "network" + } + } +} + +/// Everything the tool knows about the current network, captured in one read so the UI never +/// shows an IPv4 from one interface next to a router from another. Value type so a background +/// read can hand it to the main actor. +public struct NetworkSnapshot: Sendable, Equatable { + /// BSD name of the primary interface, e.g. `en0`. `nil` when there is no primary service, + /// which is how "offline" is represented — there is no separate `isOnline` flag to fall out + /// of sync with the addresses. + public var interfaceBSDName: String? + + /// SystemConfiguration's localized name, e.g. "Wi‑Fi" or "USB 10/100/1000 LAN". + public var interfaceDisplayName: String? + + public var kind: NetworkLinkKind + public var ipv4: String? + public var subnetMask: String? + + /// CIDR prefix length matching `subnetMask`, or `nil` if the mask was non-contiguous. + public var subnetPrefixLength: Int? + + /// Best routable IPv6 on the primary interface; see `preferredIPv6(from:)` for the ordering. + public var ipv6: String? + + public var router: String? + public var dnsServers: [String] + + public init( + interfaceBSDName: String? = nil, + interfaceDisplayName: String? = nil, + kind: NetworkLinkKind = .other, + ipv4: String? = nil, + subnetMask: String? = nil, + subnetPrefixLength: Int? = nil, + ipv6: String? = nil, + router: String? = nil, + dnsServers: [String] = [] + ) { + self.interfaceBSDName = interfaceBSDName + self.interfaceDisplayName = interfaceDisplayName + self.kind = kind + self.ipv4 = ipv4 + self.subnetMask = subnetMask + self.subnetPrefixLength = subnetPrefixLength + self.ipv6 = ipv6 + self.router = router + self.dnsServers = dnsServers + } + + /// True once the Mac has a primary service with at least one address on it. A primary + /// interface with no address yet (mid-DHCP) is not "connected" for display purposes. + public var hasConnection: Bool { + interfaceBSDName != nil && (ipv4 != nil || ipv6 != nil) + } + + /// "255.255.255.0 (/24)" — the prefix length is appended only when the mask was contiguous, + /// since a non-contiguous mask has no meaningful CIDR form. + public var subnetDescription: String? { + guard let subnetMask else { return nil } + guard let subnetPrefixLength else { return subnetMask } + return "\(subnetMask) (/\(subnetPrefixLength))" + } +} + +/// Read-only network facts plus the parsing that turns raw system output into display strings. +/// +/// Everything here is synchronous and free of AppKit so a caller can hop the whole snapshot off +/// the main actor with `Task.detached`. The parsing half (`dnsServers(fromResolvConf:)`, +/// `prefixLength(forIPv4Mask:)`, `publicIPAddress(fromResponseBody:)`, …) takes strings rather +/// than touching the system, which is what makes it testable in a headless suite. +/// +/// Nothing in this type performs a network request. The public-IP lookup is deliberately left to +/// the controller, and only its *endpoint* and *response validation* live here — see +/// `publicIPEndpoint`. +public enum NetworkInfoKit { + + // MARK: - Public IP endpoint + + /// Hostname shown in the UI so the user knows exactly who is being asked before they press + /// the button. Kept as the single source of truth for both the label and the URL below. + public static let publicIPServiceHost = "api.ipify.org" + + /// Plain-text endpoint that answers with nothing but the caller's address — no JSON, no + /// tracking pixels, no redirect chain — which keeps the validation below trivially strict. + public static let publicIPEndpoint = URL(string: "https://\(publicIPServiceHost)")! + + /// Longest a legitimate response can be. The maximum textual IPv6 length is 45 characters + /// (an IPv4-mapped address with a zone id); anything longer is a redirect page or an error + /// body, not an address, and is rejected without being shown. + public static let maximumPublicIPResponseBytes = 64 + + /// Validates the body of a `publicIPEndpoint` response. Returns the address only if the whole + /// body — after trimming — is a single well-formed IP literal. A third-party endpoint can + /// return anything at all (a captive-portal login page, an HTML error, a tracking blob), so + /// the body is never surfaced to the user unvalidated. + public static func publicIPAddress(fromResponseBody body: String) -> String? { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.utf8.count <= maximumPublicIPResponseBytes else { + return nil + } + return isValidIPAddress(trimmed) ? trimmed : nil + } + + // MARK: - Address validation + + /// True when `text` parses as either an IPv4 or an IPv6 literal. Uses `inet_pton` rather than + /// a regular expression so the accepted grammar is exactly the system's. + public static func isValidIPAddress(_ text: String) -> Bool { + isValidIPv4(text) || isValidIPv6(text) + } + + public static func isValidIPv4(_ text: String) -> Bool { + var buffer = in_addr() + return inet_pton(AF_INET, text, &buffer) == 1 + } + + public static func isValidIPv6(_ text: String) -> Bool { + // A zone id ("fe80::1%en0") is valid in presentation form but `inet_pton` rejects it, so + // it is stripped before parsing and the address half is what gets validated. + let literal = text.split(separator: "%", maxSplits: 1).first.map(String.init) ?? text + var buffer = in6_addr() + return inet_pton(AF_INET6, literal, &buffer) == 1 + } + + // MARK: - Subnet masks + + /// CIDR prefix length for a dotted-quad mask, or `nil` when the mask is malformed or its bits + /// are non-contiguous (e.g. `255.0.255.0`). A non-contiguous mask is legal to configure but + /// has no CIDR equivalent, so callers show the dotted form alone rather than inventing one. + public static func prefixLength(forIPv4Mask mask: String) -> Int? { + var buffer = in_addr() + guard inet_pton(AF_INET, mask, &buffer) == 1 else { return nil } + + let bits = UInt32(bigEndian: buffer.s_addr) + guard bits != 0 else { return 0 } + + // A contiguous mask is a run of high bits followed by zeros, so its complement is a run of + // low bits — and a low-bit run is exactly the set of values where `n & (n + 1)` is zero. + // The all-zero mask is excluded above, so the complement can never overflow here. + let inverted = ~bits + guard inverted & (inverted &+ 1) == 0 else { return nil } + + return bits.nonzeroBitCount + } + + // MARK: - IPv6 selection + + /// Picks the address most useful to show: a global unicast address first, then a unique-local + /// one, and a link-local `fe80::` only as a last resort. An interface commonly holds several + /// IPv6 addresses at once (SLAAC, privacy extension, link-local) and showing whichever + /// `getifaddrs` happened to list first would flip between them for no visible reason. + public static func preferredIPv6(from candidates: [String]) -> String? { + let usable = candidates.filter { !isLoopbackIPv6($0) } + return usable.first { !isLinkLocalIPv6($0) && !isUniqueLocalIPv6($0) } + ?? usable.first { !isLinkLocalIPv6($0) } + ?? usable.first + } + + public static func isLinkLocalIPv6(_ text: String) -> Bool { + text.lowercased().hasPrefix("fe80:") + } + + /// `fc00::/7` — the IPv6 equivalent of RFC 1918 space, so the first hex digit pair is fc or fd. + public static func isUniqueLocalIPv6(_ text: String) -> Bool { + let lowered = text.lowercased() + return lowered.hasPrefix("fc") || lowered.hasPrefix("fd") + } + + public static func isLoopbackIPv6(_ text: String) -> Bool { + let literal = text.split(separator: "%", maxSplits: 1).first.map(String.init) ?? text + return literal == "::1" + } + + // MARK: - resolv.conf + + /// Extracts `nameserver` entries from the contents of a `resolv.conf`-formatted file, in file + /// order and de-duplicated. Comment lines (`#` or `;`), `search`/`domain`/`options` lines and + /// anything that is not a valid IP literal are dropped, so a malformed file degrades to fewer + /// servers rather than to garbage in the UI. + public static func dnsServers(fromResolvConf text: String) -> [String] { + var seen = Set() + var servers: [String] = [] + + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard !line.hasPrefix("#"), !line.hasPrefix(";") else { continue } + + let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) + guard fields.count >= 2, fields[0] == "nameserver" else { continue } + + let address = String(fields[1]) + guard isValidIPAddress(address), seen.insert(address).inserted else { continue } + servers.append(address) + } + + return servers + } + + // MARK: - Interface classification + + /// Maps a SystemConfiguration interface type (`kSCNetworkInterfaceType…`) to a display kind. + /// Takes the raw string rather than the CF constant so it can be exercised without a live + /// network configuration. + public static func kind(forInterfaceType type: String?) -> NetworkLinkKind { + switch type { + case "IEEE80211": return .wiFi + case "Ethernet", "Bridge": return .ethernet + case "WWAN": return .cellular + case "Bluetooth": return .bluetooth + case "PPP", "IPSec", "VPN", "L2TP": return .vpn + case "Loopback": return .loopback + default: return .other + } + } + + /// Fallback classification for interfaces SystemConfiguration does not enumerate — chiefly the + /// `utunN` tunnels a VPN client creates on the fly. `enN` deliberately maps to `.other` + /// because the name alone cannot distinguish Wi‑Fi from Ethernet. + public static func kind(forBSDName name: String) -> NetworkLinkKind { + if name.hasPrefix("lo") { return .loopback } + if name.hasPrefix("utun") || name.hasPrefix("ipsec") || name.hasPrefix("ppp") { return .vpn } + return .other + } + + // MARK: - System reads + + /// One consistent read of the primary service and its addresses. Synchronous and safe to run + /// off the main actor; returns an empty snapshot rather than failing when there is no network. + public static func snapshot() -> NetworkSnapshot { + guard let primary = primaryService() else { + // Still report DNS: a Mac with no primary service can have resolvers configured, and + // showing them beats a screen of dashes while diagnosing exactly that situation. + return NetworkSnapshot(dnsServers: systemDNSServers()) + } + + let addresses = self.addresses(forInterface: primary.interfaceName) + let description = interfaceDescription(forBSDName: primary.interfaceName) + + return NetworkSnapshot( + interfaceBSDName: primary.interfaceName, + interfaceDisplayName: description.displayName, + kind: description.kind, + ipv4: addresses.ipv4, + subnetMask: addresses.subnetMask, + subnetPrefixLength: addresses.subnetMask.flatMap(prefixLength(forIPv4Mask:)), + ipv6: preferredIPv6(from: addresses.ipv6Candidates), + router: primary.router, + dnsServers: systemDNSServers() + ) + } + + /// The interface carrying default traffic, plus its gateway. Read from the dynamic store's + /// global state rather than the routing table because that is where macOS already resolves + /// "which of several live interfaces is primary" — including while a VPN is up. + static func primaryService() -> (interfaceName: String, router: String?)? { + guard let store = SCDynamicStoreCreate(nil, "com.havokentity.mactools.networkinfo" as CFString, nil, nil) else { + return nil + } + + let ipv4 = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv4" as CFString) as? [String: Any] + let ipv6 = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv6" as CFString) as? [String: Any] + + // IPv6-only networks have no global IPv4 dictionary at all, so fall back to the IPv6 one + // before concluding the Mac is offline. + guard let global = ipv4 ?? ipv6, + let interfaceName = global["PrimaryInterface"] as? String else { + return nil + } + + let router = (ipv4?["Router"] as? String) ?? (ipv6?["Router"] as? String) + return (interfaceName, router) + } + + /// Resolvers from the dynamic store, falling back to `/etc/resolv.conf`. The store is + /// preferred because it reflects per-scope resolvers immediately, whereas `resolv.conf` is a + /// legacy mirror that a few configurations (some VPN clients) never update. + static func systemDNSServers() -> [String] { + if let store = SCDynamicStoreCreate(nil, "com.havokentity.mactools.networkinfo.dns" as CFString, nil, nil), + let dns = SCDynamicStoreCopyValue(store, "State:/Network/Global/DNS" as CFString) as? [String: Any], + let servers = dns["ServerAddresses"] as? [String] { + let valid = servers.filter(isValidIPAddress) + if !valid.isEmpty { return valid } + } + + guard let text = try? String(contentsOfFile: "/etc/resolv.conf", encoding: .utf8) else { + return [] + } + return dnsServers(fromResolvConf: text) + } + + /// Localized name and kind for a BSD interface name, falling back to a name-based guess for + /// tunnels SystemConfiguration does not list. + static func interfaceDescription(forBSDName bsdName: String) -> (displayName: String?, kind: NetworkLinkKind) { + if let interfaces = SCNetworkInterfaceCopyAll() as? [SCNetworkInterface] { + for interface in interfaces where SCNetworkInterfaceGetBSDName(interface) as String? == bsdName { + return ( + SCNetworkInterfaceGetLocalizedDisplayName(interface) as String?, + kind(forInterfaceType: SCNetworkInterfaceGetInterfaceType(interface) as String?) + ) + } + } + + return (nil, kind(forBSDName: bsdName)) + } + + /// Addresses configured on one interface, read straight from `getifaddrs`. + struct InterfaceAddresses { + var ipv4: String? + var subnetMask: String? + var ipv6Candidates: [String] = [] + } + + static func addresses(forInterface name: String) -> InterfaceAddresses { + var result = InterfaceAddresses() + + var list: UnsafeMutablePointer? + guard getifaddrs(&list) == 0, let first = list else { + return result + } + + defer { + freeifaddrs(list) + } + + for pointer in sequence(first: first, next: { $0.pointee.ifa_next }) { + let entry = pointer.pointee + guard String(cString: entry.ifa_name) == name, let address = entry.ifa_addr else { + continue + } + + switch Int32(address.pointee.sa_family) { + case AF_INET: + // Aliases mean an interface can hold several IPv4 addresses; the first one is the + // primary and is what every other tool (ifconfig, Network settings) shows first. + guard result.ipv4 == nil else { continue } + result.ipv4 = ipv4String(from: address) + result.subnetMask = entry.ifa_netmask.flatMap(ipv4String(from:)) + + case AF_INET6: + if let text = ipv6String(from: address) { + result.ipv6Candidates.append(text) + } + + default: + continue + } + } + + return result + } + + // MARK: - sockaddr formatting + + private static func ipv4String(from address: UnsafeMutablePointer) -> String? { + var raw = address.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr } + var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + guard inet_ntop(AF_INET, &raw, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { + return nil + } + return String(cString: buffer) + } + + /// `getnameinfo` rather than `inet_ntop` because macOS stores a link-local address's scope id + /// inside the address bytes (the KAME convention); `getnameinfo` un-embeds it and appends the + /// `%en0` zone, whereas `inet_ntop` would print the raw, wrong bytes. + private static func ipv6String(from address: UnsafeMutablePointer) -> String? { + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let length = socklen_t(MemoryLayout.size) + + guard getnameinfo(address, length, &buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) == 0 else { + return nil + } + return String(cString: buffer) + } +} diff --git a/Sources/DMonteCore/NetworkInfoSizing.swift b/Sources/DMonteCore/NetworkInfoSizing.swift new file mode 100644 index 0000000..e2125a6 --- /dev/null +++ b/Sources/DMonteCore/NetworkInfoSizing.swift @@ -0,0 +1,17 @@ +import AppKit + +public enum NetworkInfoSizing { + public static func preferredSize() -> NSSize { + let scale = currentScale + // Wider than the 320-point tools because a full IPv6 literal is 39 characters and + // truncating the address the user came here to copy would defeat the tool. + return NSSize(width: (368 * scale).rounded(), height: (520 * scale).rounded()) + } + + static var currentScale: CGFloat { + let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let screenScale = visibleFrame.height / 950 + let menuBarScale = NSStatusBar.system.thickness / 26 + return min(1.0, max(0.82, min(screenScale, menuBarScale))) + } +} diff --git a/Sources/DMonteCore/NetworkInfoView.swift b/Sources/DMonteCore/NetworkInfoView.swift new file mode 100644 index 0000000..8aa2d04 --- /dev/null +++ b/Sources/DMonteCore/NetworkInfoView.swift @@ -0,0 +1,359 @@ +import AppKit +import SwiftUI + +/// The floating Network Info popover: the primary interface and its kind, the local addresses +/// (IPv4, subnet, router, IPv6), the configured DNS resolvers, and an explicitly user-initiated +/// public-IP lookup. Every value row is click-to-copy. Content is scaled to match the +/// menu-bar/display scale so it fits the scaled panel (same approach as the other tools). +public struct NetworkInfoPopoverView: View { + @ObservedObject var controller: NetworkInfoController + var onQuit: () -> Void + + @State private var isShowingSettings = false + private let scale = NetworkInfoSizing.currentScale + + public init(controller: NetworkInfoController, onQuit: @escaping () -> Void) { + self.controller = controller + self.onQuit = onQuit + } + + private func s(_ value: CGFloat) -> CGFloat { value * scale } + + private var accent: Color { .teal } + + public var body: some View { + ZStack { + VStack(spacing: 0) { + header + Divider().opacity(0.6) + + ScrollView { + VStack(spacing: s(10)) { + connectionSection + addressSection + dnsSection + publicIPSection + } + .padding(.horizontal, s(16)) + .padding(.vertical, s(12)) + } + + Spacer(minLength: 0) + footer + } + + if isShowingSettings { + PreferencesOverlay(cornerRadius: 18) { + NetworkInfoSettingsView( + controller: controller, + onQuit: onQuit, + onClose: { isShowingSettings = false } + ) + } + } + } + .frame(width: NetworkInfoSizing.preferredSize().width, height: NetworkInfoSizing.preferredSize().height) + .frostedPanel(cornerRadius: 18) + .onAppear { + controller.start() + } + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: s(8)) { + Image(systemName: controller.snapshot.hasConnection ? controller.snapshot.kind.symbolName : "wifi.slash") + .font(.system(size: s(15), weight: .semibold)) + .foregroundStyle(controller.snapshot.hasConnection ? accent : Color.secondary) + + Text("Network Info") + .font(.system(size: s(15), weight: .bold)) + .foregroundStyle(.primary.opacity(0.9)) + + Spacer() + + Button { + controller.refresh() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: s(13), weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Re-read the local network details") + + Button { + isShowingSettings = true + } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: s(14), weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Settings") + } + .padding(.horizontal, s(16)) + .padding(.top, s(14)) + .padding(.bottom, s(10)) + } + + // MARK: - Connection + + private var connectionSection: some View { + section(title: "CONNECTION") { + if controller.snapshot.hasConnection { + valueRow(label: "Interface", value: interfaceValue) + valueRow(label: "Type", value: controller.snapshot.kind.displayName) + } else { + emptyRow("No active network connection") + } + } + } + + /// "Wi‑Fi (en0)" when SystemConfiguration names the interface, otherwise the bare BSD name — + /// tunnels created by VPN clients are unnamed and would render as an empty parenthesis. + private var interfaceValue: String { + let bsdName = controller.snapshot.interfaceBSDName ?? "—" + guard let displayName = controller.snapshot.interfaceDisplayName, !displayName.isEmpty else { + return bsdName + } + return "\(displayName) (\(bsdName))" + } + + // MARK: - Addresses + + private var addressSection: some View { + section(title: "ADDRESSES") { + valueRow(label: "IPv4", value: controller.snapshot.ipv4) + valueRow(label: "Subnet", value: controller.snapshot.subnetDescription) + valueRow(label: "Router", value: controller.snapshot.router) + valueRow(label: "IPv6", value: controller.snapshot.ipv6) + } + } + + // MARK: - DNS + + private var dnsSection: some View { + section(title: "DNS SERVERS") { + if controller.snapshot.dnsServers.isEmpty { + emptyRow("None configured") + } else { + // Indexed so two interfaces handing out the same resolver still get distinct rows. + ForEach(Array(controller.snapshot.dnsServers.enumerated()), id: \.offset) { index, server in + valueRow(label: "DNS \(index + 1)", value: server) + } + } + } + } + + // MARK: - Public IP + + private var publicIPSection: some View { + section(title: "PUBLIC IP") { + if let publicIP = controller.publicIP { + valueRow(label: "Public", value: publicIP) + } + + if let status = controller.publicIPStatus { + Text(status) + .font(.system(size: s(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Button { + controller.fetchPublicIP() + } label: { + HStack(spacing: s(7)) { + Image(systemName: "globe") + .font(.system(size: s(12), weight: .semibold)) + Text(controller.publicIP == nil ? "Look Up Public IP" : "Check Again") + .font(.system(size: s(12), weight: .semibold)) + } + .foregroundStyle(controller.isFetchingPublicIP ? Color.secondary : Color.primary) + .frame(maxWidth: .infinity) + .frame(height: s(32)) + .background( + RoundedRectangle(cornerRadius: s(8), style: .continuous) + .fill(Color.secondary.opacity(0.14)) + ) + .contentShape(RoundedRectangle(cornerRadius: s(8), style: .continuous)) + } + .buttonStyle(.plain) + .disabled(controller.isFetchingPublicIP) + + // Naming the service on the face of the button, not buried in settings, is the point: + // this is the only control in the tool that talks to anyone but the local machine. + Text("Sends one request to \(NetworkInfoKit.publicIPServiceHost), which replies with the address your traffic appears to come from. Nothing is sent until you press the button.") + .font(.system(size: s(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Text(controller.snapshot.hasConnection ? "Updates when the network changes" : "Waiting for a connection") + .font(.system(size: s(11), weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + Button { + onQuit() + } label: { + Label("Quit", systemImage: "power") + .font(.system(size: s(12), weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .padding(.horizontal, s(16)) + .padding(.top, s(8)) + .padding(.bottom, s(14)) + } + + // MARK: - Building blocks + + private func section(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: s(6)) { + Text(title) + .font(.system(size: s(9), weight: .bold)) + .foregroundStyle(.secondary) + + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// A copyable value row. The whole row is the button rather than just a trailing icon, so a + /// long address stays easy to hit; the icon flips to a checkmark for a moment after a copy. + private func valueRow(label: String, value: String?) -> some View { + let resolved = value ?? "—" + let isCopyable = value != nil + let isCopied = controller.copiedRowID == label + + return Button { + guard let value else { return } + controller.copy(value, rowID: label) + } label: { + HStack(spacing: s(8)) { + Text(label) + .font(.system(size: s(9), weight: .bold)) + .foregroundStyle(Color.secondary) + .frame(width: s(52), alignment: .leading) + + Text(resolved) + .font(.system(size: s(11.5), design: .monospaced)) + .foregroundStyle(isCopyable ? Color.primary.opacity(0.9) : Color.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: isCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: s(11), weight: .semibold)) + .foregroundStyle(isCopied ? Color.green : Color.secondary) + .opacity(isCopyable ? 1 : 0) + } + .padding(.horizontal, s(10)) + .frame(height: s(28)) + .background( + RoundedRectangle(cornerRadius: s(7), style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + .contentShape(RoundedRectangle(cornerRadius: s(7), style: .continuous)) + } + .buttonStyle(.plain) + .disabled(!isCopyable) + .help(isCopyable ? "Copy \(label)" : "Not available on this network") + } + + private func emptyRow(_ text: String) -> some View { + Text(text) + .font(.system(size: s(11.5))) + .foregroundStyle(.secondary) + .padding(.horizontal, s(10)) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: s(28)) + .background( + RoundedRectangle(cornerRadius: s(7), style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + } +} + +// MARK: - Settings + +private struct NetworkInfoSettingsView: View { + @ObservedObject var controller: NetworkInfoController + var onQuit: () -> Void + var onClose: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Network Info Settings") + .font(.system(size: 16, weight: .bold)) + Spacer() + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + + settingRow(title: "Look up public IP automatically") { + GreenSwitch(isOn: Binding( + get: { controller.fetchesPublicIPAutomatically }, + set: { controller.setFetchesPublicIPAutomatically($0) } + )) + } + + Text("Off by default. When on, \(NetworkInfoKit.publicIPServiceHost) is queried whenever the network changes, which tells that service your address each time. Everything else this tool shows is read from your Mac and never leaves it.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if controller.publicIP != nil { + Button { + controller.clearPublicIP() + } label: { + Label("Clear Public IP", systemImage: "eye.slash") + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + Divider() + + Button(role: .destructive) { + onClose() + onQuit() + } label: { + Label("Quit Network Info", systemImage: "power") + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + } + .padding(20) + .frame(width: 340, height: 300) + } + + private func settingRow(title: String, @ViewBuilder trailing: () -> Trailing) -> some View { + HStack { + Text(title) + .font(.system(size: 13, weight: .semibold)) + Spacer() + trailing() + } + } +} diff --git a/Sources/DMonteCore/ToolboxCatalog.swift b/Sources/DMonteCore/ToolboxCatalog.swift index 8d1039b..5401610 100644 --- a/Sources/DMonteCore/ToolboxCatalog.swift +++ b/Sources/DMonteCore/ToolboxCatalog.swift @@ -64,7 +64,8 @@ public enum ToolboxCatalog { ToolboxTool(id: "colorPicker", title: "Color Picker", iconName: "eyedropper.halffull", tint: .mint, bundleID: prefix + "colorpicker", appName: "DMonte Color Picker.app", executableName: "DMonteColorPicker", arguments: ["--open"]), ToolboxTool(id: "grabText", title: "Grab Text", iconName: "text.viewfinder", tint: contrastGreen, bundleID: prefix + "grabtext", appName: "DMonte Grab Text.app", executableName: "DMonteGrabText", arguments: ["--open"]), ToolboxTool(id: "focusTimer", title: "Focus Timer", iconName: "timer", tint: .red, bundleID: prefix + "focustimer", appName: "DMonte Focus Timer.app", executableName: "DMonteFocusTimer", arguments: ["--open"]), - ToolboxTool(id: "windowManager", title: "Window Manager", iconName: "macwindow.on.rectangle", tint: .blue, bundleID: prefix + "windowmanager", appName: "DMonte Window Manager.app", executableName: "DMonteWindowManager", arguments: ["--open"]) + ToolboxTool(id: "windowManager", title: "Window Manager", iconName: "macwindow.on.rectangle", tint: .blue, bundleID: prefix + "windowmanager", appName: "DMonte Window Manager.app", executableName: "DMonteWindowManager", arguments: ["--open"]), + ToolboxTool(id: "networkInfo", title: "Network Info", iconName: "network", tint: .teal, bundleID: prefix + "networkinfo", appName: "DMonte Network Info.app", executableName: "DMonteNetworkInfo", arguments: ["--open"]) ] } diff --git a/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift b/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift new file mode 100644 index 0000000..8b552fb --- /dev/null +++ b/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift @@ -0,0 +1,93 @@ +import AppKit +import Combine +import DMonteCore +import SwiftUI + +/// Distributed notification used to reveal this helper's popover when the Toolbox (or a second +/// launch with `--open`) asks for it. +enum NetworkInfoNotifications { + static let showWindow = Notification.Name("com.havokentity.mactools.networkinfo.showWindow") +} + +@MainActor +final class NetworkInfoAppDelegate: NSObject, NSApplicationDelegate { + private let controller = NetworkInfoController() + + private var statusItem: HelperStatusItem? + private var panelHost: HelperPanelHost? + private var cancellables: Set = [] + + func applicationDidFinishLaunching(_ notification: Notification) { + AppDefaults.registerDefaults() + + let host = HelperPanelHost( + configuration: HelperPanelHost.Configuration( + sizing: .preferred({ NetworkInfoSizing.preferredSize() }) + ), + content: .viewController({ [controller, weak self] in + NSHostingController( + rootView: NetworkInfoPopoverView(controller: controller, onQuit: { self?.quit() }) + ) + }), + anchorView: { [weak self] in self?.statusItem?.button } + ) + panelHost = host + host.configure() + + statusItem = HelperStatusItem( + image: Self.statusIcon(kind: controller.snapshot.kind, isConnected: controller.snapshot.hasConnection), + toolTip: "Network Info", + primaryAction: { [weak self] in self?.panelHost?.toggle() }, + quitAction: { [weak self] in self?.quit() } + ) + + host.observeShowNotification(named: NetworkInfoNotifications.showWindow) + observeControllerState() + + // Start monitoring here rather than only from the popover's `onAppear`, so the tray glyph + // is right before the user ever opens the panel. + controller.start() + } + + func applicationWillTerminate(_ notification: Notification) { + panelHost?.stopObservingShowNotifications() + panelHost?.removeOutsideClickMonitor() + cancellables.removeAll() + controller.stop() + panelHost?.dismissForTermination() + statusItem?.remove() + } + + /// The tray glyph mirrors the active link: the Wi‑Fi/Ethernet symbol while connected, a + /// slashed Wi‑Fi when there is no primary service, so the state is legible without opening the + /// panel. Forced to template so AppKit tints it adaptive white and gives it the native + /// rollover highlight. + private static func statusIcon(kind: NetworkLinkKind, isConnected: Bool) -> NSImage { + let name = isConnected ? kind.symbolName : "wifi.slash" + let image = NSImage(systemSymbolName: name, accessibilityDescription: "Network Info") ?? NSImage() + image.isTemplate = true + return image + } + + private func updateStatusIcon() { + statusItem?.button?.image = Self.statusIcon( + kind: controller.snapshot.kind, + isConnected: controller.snapshot.hasConnection + ) + } + + /// Keep the tray glyph in sync with the current link. + private func observeControllerState() { + controller.$snapshot + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.updateStatusIcon() + } + .store(in: &cancellables) + } + + private func quit() { + panelHost?.close() + NSApp.terminate(nil) + } +} diff --git a/Sources/DMonteNetworkInfoApp/main.swift b/Sources/DMonteNetworkInfoApp/main.swift new file mode 100644 index 0000000..ff00b9c --- /dev/null +++ b/Sources/DMonteNetworkInfoApp/main.swift @@ -0,0 +1,24 @@ +import AppKit +import DMonteCore + +let singleInstanceGuard = SingleInstanceGuard(identifier: "com.havokentity.mactools.networkinfo") + +guard singleInstanceGuard.isPrimary else { + if CommandLine.arguments.contains("--open") { + DistributedNotificationCenter.default().postNotificationName( + NetworkInfoNotifications.showWindow, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + + exit(EXIT_SUCCESS) +} + +let app = NSApplication.shared +let delegate = NetworkInfoAppDelegate() + +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/Tests/DMonteCoreTests/NetworkInfoKitTests.swift b/Tests/DMonteCoreTests/NetworkInfoKitTests.swift new file mode 100644 index 0000000..948a4a4 --- /dev/null +++ b/Tests/DMonteCoreTests/NetworkInfoKitTests.swift @@ -0,0 +1,253 @@ +import XCTest +@testable import DMonteCore + +/// Exercises the parsing half of `NetworkInfoKit` against fixture strings. Nothing here opens a +/// socket, reads `/etc/resolv.conf`, or touches SystemConfiguration — the system-reading half is +/// deliberately kept out of the suite because it would depend on whatever network the test +/// machine happens to be on. +final class NetworkInfoKitTests: XCTestCase { + + // MARK: - Address validation + + func testValidIPv4Addresses() { + XCTAssertTrue(NetworkInfoKit.isValidIPv4("192.168.1.1")) + XCTAssertTrue(NetworkInfoKit.isValidIPv4("0.0.0.0")) + XCTAssertTrue(NetworkInfoKit.isValidIPv4("255.255.255.255")) + } + + func testInvalidIPv4Addresses() { + XCTAssertFalse(NetworkInfoKit.isValidIPv4("256.1.1.1")) + XCTAssertFalse(NetworkInfoKit.isValidIPv4("192.168.1")) + XCTAssertFalse(NetworkInfoKit.isValidIPv4("")) + XCTAssertFalse(NetworkInfoKit.isValidIPv4("not an address")) + } + + func testValidIPv6Addresses() { + XCTAssertTrue(NetworkInfoKit.isValidIPv6("::1")) + XCTAssertTrue(NetworkInfoKit.isValidIPv6("2001:db8::1")) + XCTAssertTrue(NetworkInfoKit.isValidIPv6("fe80:0000:0000:0000:0aaa:bbff:fecc:ddee")) + } + + /// A zone id is part of the presentation form macOS hands back for link-local addresses, so it + /// must validate even though `inet_pton` alone rejects it. + func testIPv6WithZoneIdentifierIsValid() { + XCTAssertTrue(NetworkInfoKit.isValidIPv6("fe80::1%en0")) + XCTAssertTrue(NetworkInfoKit.isValidIPAddress("fe80::aaaa:bbbb:cccc:dddd%en1")) + } + + func testInvalidIPv6Addresses() { + XCTAssertFalse(NetworkInfoKit.isValidIPv6("2001:db8:::1")) + XCTAssertFalse(NetworkInfoKit.isValidIPv6("gggg::1")) + XCTAssertFalse(NetworkInfoKit.isValidIPv6("")) + } + + // MARK: - Subnet masks + + func testPrefixLengthForCommonMasks() { + XCTAssertEqual(NetworkInfoKit.prefixLength(forIPv4Mask: "255.255.255.0"), 24) + XCTAssertEqual(NetworkInfoKit.prefixLength(forIPv4Mask: "255.255.0.0"), 16) + XCTAssertEqual(NetworkInfoKit.prefixLength(forIPv4Mask: "255.0.0.0"), 8) + XCTAssertEqual(NetworkInfoKit.prefixLength(forIPv4Mask: "255.255.255.252"), 30) + } + + func testPrefixLengthAtBothExtremes() { + XCTAssertEqual(NetworkInfoKit.prefixLength(forIPv4Mask: "0.0.0.0"), 0) + XCTAssertEqual(NetworkInfoKit.prefixLength(forIPv4Mask: "255.255.255.255"), 32) + } + + /// A mask with a gap in its bit run is configurable but has no CIDR form; reporting one would + /// be a lie, so the Kit returns nil and the view falls back to the dotted mask alone. + func testPrefixLengthRejectsNonContiguousMask() { + XCTAssertNil(NetworkInfoKit.prefixLength(forIPv4Mask: "255.0.255.0")) + XCTAssertNil(NetworkInfoKit.prefixLength(forIPv4Mask: "255.255.255.1")) + XCTAssertNil(NetworkInfoKit.prefixLength(forIPv4Mask: "0.0.0.255")) + } + + func testPrefixLengthRejectsMalformedMask() { + XCTAssertNil(NetworkInfoKit.prefixLength(forIPv4Mask: "not a mask")) + XCTAssertNil(NetworkInfoKit.prefixLength(forIPv4Mask: "")) + XCTAssertNil(NetworkInfoKit.prefixLength(forIPv4Mask: "::1")) + } + + // MARK: - IPv6 preference + + func testPreferredIPv6PrefersGlobalUnicast() { + let candidates = ["fe80::1%en0", "fd00::5", "2001:db8::42"] + XCTAssertEqual(NetworkInfoKit.preferredIPv6(from: candidates), "2001:db8::42") + } + + func testPreferredIPv6FallsBackToUniqueLocalBeforeLinkLocal() { + let candidates = ["fe80::1%en0", "fd00::5"] + XCTAssertEqual(NetworkInfoKit.preferredIPv6(from: candidates), "fd00::5") + } + + func testPreferredIPv6FallsBackToLinkLocalWhenItIsAllThereIs() { + XCTAssertEqual(NetworkInfoKit.preferredIPv6(from: ["fe80::1%en0"]), "fe80::1%en0") + } + + func testPreferredIPv6IgnoresLoopback() { + XCTAssertNil(NetworkInfoKit.preferredIPv6(from: ["::1"])) + XCTAssertEqual(NetworkInfoKit.preferredIPv6(from: ["::1", "2001:db8::9"]), "2001:db8::9") + } + + func testPreferredIPv6WithNoCandidates() { + XCTAssertNil(NetworkInfoKit.preferredIPv6(from: [])) + } + + // MARK: - resolv.conf parsing + + func testResolvConfParsesNameservers() { + let fixture = """ + # + # macOS Notice + # + nameserver 192.168.1.1 + nameserver 8.8.8.8 + """ + + XCTAssertEqual(NetworkInfoKit.dnsServers(fromResolvConf: fixture), ["192.168.1.1", "8.8.8.8"]) + } + + func testResolvConfIgnoresNonNameserverDirectives() { + let fixture = """ + search lan example.com + domain lan + options ndots:1 + nameserver 1.1.1.1 + """ + + XCTAssertEqual(NetworkInfoKit.dnsServers(fromResolvConf: fixture), ["1.1.1.1"]) + } + + func testResolvConfHandlesTabsAndExtraSpacing() { + let fixture = "nameserver\t10.0.0.1\n nameserver 10.0.0.2 \n" + XCTAssertEqual(NetworkInfoKit.dnsServers(fromResolvConf: fixture), ["10.0.0.1", "10.0.0.2"]) + } + + func testResolvConfDropsDuplicatesPreservingOrder() { + let fixture = "nameserver 9.9.9.9\nnameserver 1.1.1.1\nnameserver 9.9.9.9\n" + XCTAssertEqual(NetworkInfoKit.dnsServers(fromResolvConf: fixture), ["9.9.9.9", "1.1.1.1"]) + } + + func testResolvConfDropsMalformedEntries() { + let fixture = """ + nameserver + nameserver localhost + nameserver 999.999.999.999 + nameserver 2606:4700:4700::1111 + """ + + XCTAssertEqual(NetworkInfoKit.dnsServers(fromResolvConf: fixture), ["2606:4700:4700::1111"]) + } + + func testResolvConfIgnoresCommentedNameservers() { + let fixture = "# nameserver 8.8.8.8\n; nameserver 8.8.4.4\nnameserver 192.168.0.1\n" + XCTAssertEqual(NetworkInfoKit.dnsServers(fromResolvConf: fixture), ["192.168.0.1"]) + } + + func testResolvConfWithNoNameservers() { + XCTAssertTrue(NetworkInfoKit.dnsServers(fromResolvConf: "search lan\n").isEmpty) + XCTAssertTrue(NetworkInfoKit.dnsServers(fromResolvConf: "").isEmpty) + } + + // MARK: - Public IP response validation + + func testPublicIPAcceptsBareAddress() { + XCTAssertEqual(NetworkInfoKit.publicIPAddress(fromResponseBody: "203.0.113.7"), "203.0.113.7") + } + + func testPublicIPTrimsSurroundingWhitespace() { + XCTAssertEqual(NetworkInfoKit.publicIPAddress(fromResponseBody: " 203.0.113.7\n"), "203.0.113.7") + XCTAssertEqual(NetworkInfoKit.publicIPAddress(fromResponseBody: "2001:db8::1\r\n"), "2001:db8::1") + } + + /// The endpoint is a third party: a captive portal or an outage can put an HTML page behind + /// the same URL, and that must never be shown to the user as their address. + func testPublicIPRejectsNonAddressBodies() { + XCTAssertNil(NetworkInfoKit.publicIPAddress(fromResponseBody: "Login")) + XCTAssertNil(NetworkInfoKit.publicIPAddress(fromResponseBody: "Service temporarily unavailable")) + XCTAssertNil(NetworkInfoKit.publicIPAddress(fromResponseBody: "{\"ip\":\"203.0.113.7\"}")) + } + + func testPublicIPRejectsEmptyBody() { + XCTAssertNil(NetworkInfoKit.publicIPAddress(fromResponseBody: "")) + XCTAssertNil(NetworkInfoKit.publicIPAddress(fromResponseBody: " \n ")) + } + + func testPublicIPRejectsOversizedBody() { + let padded = String(repeating: "8", count: NetworkInfoKit.maximumPublicIPResponseBytes + 1) + XCTAssertNil(NetworkInfoKit.publicIPAddress(fromResponseBody: padded)) + } + + func testPublicIPRejectsAddressWithTrailingContent() { + XCTAssertNil(NetworkInfoKit.publicIPAddress(fromResponseBody: "203.0.113.7 and more")) + } + + // MARK: - Interface classification + + func testKindForSystemConfigurationInterfaceTypes() { + XCTAssertEqual(NetworkInfoKit.kind(forInterfaceType: "IEEE80211"), .wiFi) + XCTAssertEqual(NetworkInfoKit.kind(forInterfaceType: "Ethernet"), .ethernet) + XCTAssertEqual(NetworkInfoKit.kind(forInterfaceType: "Bridge"), .ethernet) + XCTAssertEqual(NetworkInfoKit.kind(forInterfaceType: "WWAN"), .cellular) + XCTAssertEqual(NetworkInfoKit.kind(forInterfaceType: "Bluetooth"), .bluetooth) + XCTAssertEqual(NetworkInfoKit.kind(forInterfaceType: "PPP"), .vpn) + XCTAssertEqual(NetworkInfoKit.kind(forInterfaceType: "IPSec"), .vpn) + } + + func testKindForUnknownOrMissingInterfaceType() { + XCTAssertEqual(NetworkInfoKit.kind(forInterfaceType: "SomethingNew"), .other) + XCTAssertEqual(NetworkInfoKit.kind(forInterfaceType: nil), .other) + } + + /// `enN` must NOT be guessed as Ethernet — on Apple silicon it is the Wi‑Fi interface too, and + /// the name-based path exists only for tunnels SystemConfiguration does not enumerate. + func testKindForBSDNameFallback() { + XCTAssertEqual(NetworkInfoKit.kind(forBSDName: "utun4"), .vpn) + XCTAssertEqual(NetworkInfoKit.kind(forBSDName: "ipsec0"), .vpn) + XCTAssertEqual(NetworkInfoKit.kind(forBSDName: "lo0"), .loopback) + XCTAssertEqual(NetworkInfoKit.kind(forBSDName: "en0"), .other) + } + + func testEveryLinkKindHasDisplayNameAndSymbol() { + for kind in NetworkLinkKind.allCases { + XCTAssertFalse(kind.displayName.isEmpty, "\(kind) has no display name") + XCTAssertFalse(kind.symbolName.isEmpty, "\(kind) has no SF Symbol") + } + } + + // MARK: - Snapshot presentation + + func testSnapshotSubnetDescriptionIncludesPrefixLength() { + let snapshot = NetworkSnapshot(subnetMask: "255.255.255.0", subnetPrefixLength: 24) + XCTAssertEqual(snapshot.subnetDescription, "255.255.255.0 (/24)") + } + + func testSnapshotSubnetDescriptionOmitsPrefixWhenNonContiguous() { + let snapshot = NetworkSnapshot(subnetMask: "255.0.255.0", subnetPrefixLength: nil) + XCTAssertEqual(snapshot.subnetDescription, "255.0.255.0") + } + + func testSnapshotSubnetDescriptionIsNilWithoutMask() { + XCTAssertNil(NetworkSnapshot().subnetDescription) + } + + func testSnapshotHasConnectionRequiresAnInterfaceAndAnAddress() { + XCTAssertFalse(NetworkSnapshot().hasConnection) + // Mid-DHCP: the interface is primary but has no address yet. + XCTAssertFalse(NetworkSnapshot(interfaceBSDName: "en0").hasConnection) + XCTAssertFalse(NetworkSnapshot(ipv4: "10.0.0.2").hasConnection) + XCTAssertTrue(NetworkSnapshot(interfaceBSDName: "en0", ipv4: "10.0.0.2").hasConnection) + // IPv6-only networks are a connection too. + XCTAssertTrue(NetworkSnapshot(interfaceBSDName: "en0", ipv6: "2001:db8::1").hasConnection) + } + + // MARK: - Endpoint wiring + + /// The label the UI shows and the URL actually contacted must not drift apart — the whole + /// consent story rests on the user being told the right hostname. + func testPublicIPEndpointMatchesAdvertisedHost() { + XCTAssertEqual(NetworkInfoKit.publicIPEndpoint.host, NetworkInfoKit.publicIPServiceHost) + XCTAssertEqual(NetworkInfoKit.publicIPEndpoint.scheme, "https") + } +} From 378bbeba7f1589e5a6a409ae86f096a166c7c8a4 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 13:57:44 +0530 Subject: [PATCH 2/9] review: fix Network Info CRLF DNS parsing, monitor restart, stale lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/DMonteCore/AppPreferences.swift | 3 +- .../DMonteCore/NetworkInfoController.swift | 53 +++++++++++++------ Sources/DMonteCore/NetworkInfoKit.swift | 13 ++++- Sources/DMonteCore/NetworkInfoView.swift | 13 ++++- .../DMonteCoreTests/NetworkInfoKitTests.swift | 34 ++++++++++++ 5 files changed, 97 insertions(+), 19 deletions(-) diff --git a/Sources/DMonteCore/AppPreferences.swift b/Sources/DMonteCore/AppPreferences.swift index b746647..ddc2499 100644 --- a/Sources/DMonteCore/AppPreferences.swift +++ b/Sources/DMonteCore/AppPreferences.swift @@ -114,7 +114,8 @@ public enum AppDefaults { DefaultsKey.focusTimerFocusMinutes: 25, DefaultsKey.focusTimerShortBreakMinutes: 5, DefaultsKey.focusTimerLongBreakMinutes: 15, - DefaultsKey.focusTimerLongBreakInterval: 4 + DefaultsKey.focusTimerLongBreakInterval: 4, + DefaultsKey.networkInfoFetchesPublicIPAutomatically: false ]) } } diff --git a/Sources/DMonteCore/NetworkInfoController.swift b/Sources/DMonteCore/NetworkInfoController.swift index 0aaee01..064a841 100644 --- a/Sources/DMonteCore/NetworkInfoController.swift +++ b/Sources/DMonteCore/NetworkInfoController.swift @@ -39,12 +39,16 @@ public final class NetworkInfoController: ObservableObject { /// IPv4 router equal to a DNS server, which is the common home-router case) don't both flash. @Published public private(set) var copiedRowID: String? - private let pathMonitor = NWPathMonitor() + /// Recreated by each `start()` rather than held for the process lifetime: `NWPathMonitor` is + /// single-use, and calling `start(queue:)` on one that has already been cancelled silently + /// never delivers an update — a stop/start cycle would leave the tool frozen on a stale + /// snapshot with no sign anything was wrong. + private var pathMonitor: NWPathMonitor? private let pathMonitorQueue = DispatchQueue(label: "com.havokentity.mactools.networkinfo.path") - private var isMonitoring = false private var copiedResetTask: Task? private var publicIPTask: Task? + private var refreshTask: Task? /// Ephemeral so the public-IP request leaves no cookie, credential or cache trace on disk, and /// short-timeout so a captive portal that black-holes the connection surfaces as a failure @@ -67,8 +71,8 @@ public final class NetworkInfoController: ObservableObject { deinit { // `deinit` is nonisolated; cancel the monitor directly so its dispatch source is torn down // even on a path that never reaches `applicationWillTerminate`. `cancel()` on an - // already-cancelled or never-started monitor is a no-op. - pathMonitor.cancel() + // already-cancelled monitor is a no-op. + pathMonitor?.cancel() } // MARK: - Public API @@ -78,37 +82,48 @@ public final class NetworkInfoController: ObservableObject { public func start() { refresh() - guard !isMonitoring else { return } - isMonitoring = true + guard pathMonitor == nil else { return } // `NWPathMonitor` fires for interface, address and reachability changes alike, which is // exactly the set of events that can invalidate the snapshot — polling on a timer would // either lag behind a Wi‑Fi switch or burn wakeups doing nothing. - pathMonitor.pathUpdateHandler = { [weak self] _ in + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] _ in Task { @MainActor in self?.handlePathChange() } } - pathMonitor.start(queue: pathMonitorQueue) + pathMonitor = monitor + monitor.start(queue: pathMonitorQueue) } - /// Stops the path monitor. Called from `applicationWillTerminate` so the monitor's queue is - /// torn down before the process exits rather than during deallocation. + /// Stops the path monitor and abandons any read still in flight. Called from + /// `applicationWillTerminate` so the monitor's queue is torn down before the process exits + /// rather than during deallocation. A later `start()` builds a fresh monitor. public func stop() { - guard isMonitoring else { return } - isMonitoring = false - pathMonitor.cancel() + pathMonitor?.cancel() + pathMonitor = nil + refreshTask?.cancel() + refreshTask = nil } /// Re-reads the local network facts. The read touches `getifaddrs` and SystemConfiguration, so /// it runs detached and only the resulting value crosses back to the main actor. public func refresh() { - Task { [weak self] in + // A network transition makes `NWPathMonitor` fire several times in quick succession, so + // supersede the previous read instead of letting reads pile up: without this they race, + // and a slower earlier read can land last and overwrite the newer snapshot with stale data. + refreshTask?.cancel() + refreshTask = Task { [weak self] in let reading = await Task.detached(priority: .userInitiated) { NetworkInfoKit.snapshot() }.value - guard let self else { return } + guard let self, !Task.isCancelled else { return } + + // Republishing an identical snapshot would redraw the popover and rewrite the tray + // icon for nothing on every path callback. + guard reading != self.snapshot else { return } self.snapshot = reading } } @@ -183,6 +198,14 @@ public final class NetworkInfoController: ObservableObject { private func handlePathChange() { refresh() + // Any lookup already in flight was issued against the previous path, so its answer is not + // trustworthy as the new network's address — abandon it before deciding what comes next. + // Cancelling also clears `isFetchingPublicIP`, which would otherwise leave the button + // disabled with no status text once the branch below wipes the "Asking…" line. + publicIPTask?.cancel() + publicIPTask = nil + isFetchingPublicIP = false + guard fetchesPublicIPAutomatically else { // The cached address almost certainly belongs to the previous network, so drop it // rather than let it masquerade as current until the user presses refresh. diff --git a/Sources/DMonteCore/NetworkInfoKit.swift b/Sources/DMonteCore/NetworkInfoKit.swift index d65bcb8..3744897 100644 --- a/Sources/DMonteCore/NetworkInfoKit.swift +++ b/Sources/DMonteCore/NetworkInfoKit.swift @@ -222,8 +222,12 @@ public enum NetworkInfoKit { var seen = Set() var servers: [String] = [] - for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { - let line = rawLine.trimmingCharacters(in: .whitespaces) + // Split on `isNewline` rather than the literal "\n": Swift treats CRLF as a *single* + // grapheme cluster, so splitting on "\n" does not match it at all and a CRLF-terminated + // file collapses into one giant line that parses as zero nameservers. Trimming uses + // `.whitespacesAndNewlines` for the same reason — `.whitespaces` excludes carriage return. + for rawLine in text.split(whereSeparator: \.isNewline) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) guard !line.hasPrefix("#"), !line.hasPrefix(";") else { continue } let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) @@ -393,6 +397,11 @@ public enum NetworkInfoKit { // MARK: - sockaddr formatting private static func ipv4String(from address: UnsafeMutablePointer) -> String? { + // `ifa_netmask` is not guaranteed to carry a family: the kernel hands back an unspecified + // (AF_UNSPEC) sockaddr for some tunnel interfaces, and rebinding that to `sockaddr_in` + // would read a zero address and render a bogus "0.0.0.0 (/0)" subnet. + guard Int32(address.pointee.sa_family) == AF_INET else { return nil } + var raw = address.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr } var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) guard inet_ntop(AF_INET, &raw, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { diff --git a/Sources/DMonteCore/NetworkInfoView.swift b/Sources/DMonteCore/NetworkInfoView.swift index 8aa2d04..7dd7e2b 100644 --- a/Sources/DMonteCore/NetworkInfoView.swift +++ b/Sources/DMonteCore/NetworkInfoView.swift @@ -186,7 +186,7 @@ public struct NetworkInfoPopoverView: View { // Naming the service on the face of the button, not buried in settings, is the point: // this is the only control in the tool that talks to anyone but the local machine. - Text("Sends one request to \(NetworkInfoKit.publicIPServiceHost), which replies with the address your traffic appears to come from. Nothing is sent until you press the button.") + Text(publicIPDisclosure) .font(.system(size: s(11))) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -194,6 +194,17 @@ public struct NetworkInfoPopoverView: View { } } + /// The second half of this sentence has to track the preference: claiming "nothing is sent + /// until you press the button" while the automatic lookup is switched on would be a plain + /// falsehood, and this disclosure is the whole basis on which the user consents. + private var publicIPDisclosure: String { + let opening = "Sends one request to \(NetworkInfoKit.publicIPServiceHost), which replies with the address your traffic appears to come from." + guard controller.fetchesPublicIPAutomatically else { + return "\(opening) Nothing is sent until you press the button." + } + return "\(opening) Automatic lookup is on, so this also runs whenever the network changes." + } + // MARK: - Footer private var footer: some View { diff --git a/Tests/DMonteCoreTests/NetworkInfoKitTests.swift b/Tests/DMonteCoreTests/NetworkInfoKitTests.swift index 948a4a4..d328ba1 100644 --- a/Tests/DMonteCoreTests/NetworkInfoKitTests.swift +++ b/Tests/DMonteCoreTests/NetworkInfoKitTests.swift @@ -145,6 +145,24 @@ final class NetworkInfoKitTests: XCTestCase { XCTAssertEqual(NetworkInfoKit.dnsServers(fromResolvConf: fixture), ["192.168.0.1"]) } + /// Regression: Swift treats CRLF as a *single* grapheme cluster, so splitting the file on the + /// literal "\n" never matches a CRLF break — the whole file collapsed into one line and the + /// tool reported no DNS servers at all. A carriage return is also absent from + /// `CharacterSet.whitespaces`, so trimming has to use `.whitespacesAndNewlines`. + func testResolvConfHandlesCarriageReturnLineEndings() { + let crlf = "nameserver 192.168.1.1\r\nnameserver 8.8.8.8\r\n" + XCTAssertEqual(NetworkInfoKit.dnsServers(fromResolvConf: crlf), ["192.168.1.1", "8.8.8.8"]) + + let bareCR = "nameserver 10.0.0.1\rnameserver 10.0.0.2\r" + XCTAssertEqual(NetworkInfoKit.dnsServers(fromResolvConf: bareCR), ["10.0.0.1", "10.0.0.2"]) + } + + /// A comment marker must still be honoured when the line ends in CRLF. + func testResolvConfIgnoresCommentsWithCarriageReturns() { + let fixture = "# nameserver 8.8.8.8\r\nnameserver 192.168.0.1\r\n" + XCTAssertEqual(NetworkInfoKit.dnsServers(fromResolvConf: fixture), ["192.168.0.1"]) + } + func testResolvConfWithNoNameservers() { XCTAssertTrue(NetworkInfoKit.dnsServers(fromResolvConf: "search lan\n").isEmpty) XCTAssertTrue(NetworkInfoKit.dnsServers(fromResolvConf: "").isEmpty) @@ -179,10 +197,26 @@ final class NetworkInfoKitTests: XCTestCase { XCTAssertNil(NetworkInfoKit.publicIPAddress(fromResponseBody: padded)) } + /// The other half of the byte cap: it must sit *above* the longest address a correct endpoint + /// can legitimately return (45 characters), or a real answer would be thrown away as oversized. + /// Without this, `maximumPublicIPResponseBytes` could be lowered to 8 and every rejection test + /// above would still pass. + func testPublicIPAcceptsLongestLegitimateAddress() { + let longest = "ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255" + XCTAssertEqual(longest.utf8.count, 45) + XCTAssertEqual(NetworkInfoKit.publicIPAddress(fromResponseBody: longest), longest) + } + func testPublicIPRejectsAddressWithTrailingContent() { XCTAssertNil(NetworkInfoKit.publicIPAddress(fromResponseBody: "203.0.113.7 and more")) } + /// Trimming only strips the ends, so a body holding two addresses must be refused outright + /// rather than silently reported as whichever one happened to survive. + func testPublicIPRejectsBodyWithMultipleAddresses() { + XCTAssertNil(NetworkInfoKit.publicIPAddress(fromResponseBody: "203.0.113.7\n198.51.100.4")) + } + // MARK: - Interface classification func testKindForSystemConfigurationInterfaceTypes() { From 588af51e349408d9bb9d27f02fad5a7fc92b1fd5 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 16:04:26 +0530 Subject: [PATCH 3/9] review: address Copilot inline comments on PR #15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../DMonteCore/NetworkInfoController.swift | 51 ++++++++++++++----- Sources/DMonteCore/NetworkInfoView.swift | 4 ++ .../NetworkInfoAppDelegate.swift | 10 ++++ 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/Sources/DMonteCore/NetworkInfoController.swift b/Sources/DMonteCore/NetworkInfoController.swift index 064a841..be1648f 100644 --- a/Sources/DMonteCore/NetworkInfoController.swift +++ b/Sources/DMonteCore/NetworkInfoController.swift @@ -97,7 +97,7 @@ public final class NetworkInfoController: ObservableObject { monitor.start(queue: pathMonitorQueue) } - /// Stops the path monitor and abandons any read still in flight. Called from + /// Stops the path monitor and abandons every task still in flight. Called from /// `applicationWillTerminate` so the monitor's queue is torn down before the process exits /// rather than during deallocation. A later `start()` builds a fresh monitor. public func stop() { @@ -105,6 +105,19 @@ public final class NetworkInfoController: ObservableObject { pathMonitor = nil refreshTask?.cancel() refreshTask = nil + + // A public-IP request that outlived `stop()` would keep talking to a third party after the + // tool was told to stand down — the one thing this lookup is deliberately user-initiated to + // avoid. Cancelling propagates into `URLSession`, and the state its completion would have + // cleared has to be cleared here because that completion now never runs. + publicIPTask?.cancel() + publicIPTask = nil + isFetchingPublicIP = false + publicIPStatus = nil + + copiedResetTask?.cancel() + copiedResetTask = nil + copiedRowID = nil } /// Re-reads the local network facts. The read touches `getifaddrs` and SystemConfiguration, so @@ -113,18 +126,23 @@ public final class NetworkInfoController: ObservableObject { // A network transition makes `NWPathMonitor` fire several times in quick succession, so // supersede the previous read instead of letting reads pile up: without this they race, // and a slower earlier read can land last and overwrite the newer snapshot with stale data. - refreshTask?.cancel() - refreshTask = Task { [weak self] in - let reading = await Task.detached(priority: .userInitiated) { - NetworkInfoKit.snapshot() - }.value - - guard let self, !Task.isCancelled else { return } - - // Republishing an identical snapshot would redraw the popover and rewrite the tray - // icon for nothing on every path callback. - guard reading != self.snapshot else { return } - self.snapshot = reading + let previous = refreshTask + previous?.cancel() + + // The read itself is the detached task, not a nested one whose handle is dropped: an + // unretained child ignores this cancellation entirely, so the supersede above would be + // decorative and every callback in a burst would still reach `snapshot()`. + refreshTask = Task.detached(priority: .userInitiated) { [weak self] in + // Cancelling cannot interrupt a synchronous `getifaddrs`/SystemConfiguration walk that + // has already begun, so waiting the previous read out is what actually keeps the burst + // serial. Superseded reads bail at the check below without touching the system. + await previous?.value + + guard !Task.isCancelled else { return } + let reading = NetworkInfoKit.snapshot() + guard !Task.isCancelled, let self else { return } + + await self.publish(reading) } } @@ -195,6 +213,13 @@ public final class NetworkInfoController: ObservableObject { // MARK: - Private + /// Lands a completed read back on the main actor. Republishing an identical snapshot would + /// redraw the popover and rewrite the tray icon for nothing on every path callback. + private func publish(_ reading: NetworkSnapshot) { + guard reading != snapshot else { return } + snapshot = reading + } + private func handlePathChange() { refresh() diff --git a/Sources/DMonteCore/NetworkInfoView.swift b/Sources/DMonteCore/NetworkInfoView.swift index 7dd7e2b..805f7ad 100644 --- a/Sources/DMonteCore/NetworkInfoView.swift +++ b/Sources/DMonteCore/NetworkInfoView.swift @@ -82,6 +82,8 @@ public struct NetworkInfoPopoverView: View { } .buttonStyle(.plain) .help("Re-read the local network details") + // `.help` is only a tooltip; without this VoiceOver announces the bare glyph. + .accessibilityLabel("Refresh") Button { isShowingSettings = true @@ -92,6 +94,7 @@ public struct NetworkInfoPopoverView: View { } .buttonStyle(.plain) .help("Settings") + .accessibilityLabel("Settings") } .padding(.horizontal, s(16)) .padding(.top, s(14)) @@ -320,6 +323,7 @@ private struct NetworkInfoSettingsView: View { .frame(width: 24, height: 24) } .buttonStyle(.plain) + .accessibilityLabel("Close Settings") } settingRow(title: "Look up public IP automatically") { diff --git a/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift b/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift index 8b552fb..ae00969 100644 --- a/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift +++ b/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift @@ -47,6 +47,16 @@ final class NetworkInfoAppDelegate: NSObject, NSApplicationDelegate { // Start monitoring here rather than only from the popover's `onAppear`, so the tray glyph // is right before the user ever opens the panel. controller.start() + + // The distributed notification above only covers a *second* launch. On the first one this + // process is the primary instance, so nothing posts it and the Toolbox tile would appear + // to do nothing — reveal the panel here instead. Deferred a runloop turn so the status + // item has a button to anchor to. + if CommandLine.arguments.contains("--open") { + DispatchQueue.main.async { [weak self] in + self?.panelHost?.show() + } + } } func applicationWillTerminate(_ notification: Notification) { From 1b8f4118fbfe55b7b50399642a504532047d327a Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 18:22:17 +0530 Subject: [PATCH 4/9] Network Info: open the panel under the status item, not in a corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/DMonteCore/HelperPanelHost.swift | 45 ++++++++++++++++++- .../NetworkInfoAppDelegate.swift | 11 ++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/Sources/DMonteCore/HelperPanelHost.swift b/Sources/DMonteCore/HelperPanelHost.swift index 7247082..a8f275d 100644 --- a/Sources/DMonteCore/HelperPanelHost.swift +++ b/Sources/DMonteCore/HelperPanelHost.swift @@ -457,7 +457,12 @@ public final class HelperPanelHost: NSObject { switch configuration.positioning { case .anchoredFrameOrCentered(let gap): let frame: NSRect - if let anchor, let window = anchor.window, let screen = window.screen ?? NSScreen.main { + // `isAnchorReady` and not merely `anchor != nil`: a status button that exists but has + // not been laid out reports a zero-sized frame at the screen origin, and anchoring to + // that puts the panel in a corner of the wrong display. Centred is the honest answer + // until the real frame is available. + if let anchor, Self.isAnchorReady(anchor), let window = anchor.window, + let screen = window.screen ?? NSScreen.main { frame = HelperPanelPlacement.anchoredFrame( for: size, anchorFrame: Self.anchorFrameOnScreen(for: anchor, in: window), @@ -530,4 +535,42 @@ public final class HelperPanelHost: NSObject { let viewFrameInWindow = view.convert(view.bounds, to: nil) return window.convertToScreen(viewFrameInWindow) } + + /// Whether the anchor has been placed in the menu bar yet. + /// + /// Size alone is not the signal. A status item's button reports its real size (29 × 22) + /// immediately while its window still sits at the screen origin, so a frame that looks + /// perfectly valid can describe a point nowhere near the menu bar. Anchoring to it puts the + /// panel relative to (0, 0) — the bottom-left corner of whichever display owns that point, + /// which on a multi-display Mac is not even the display with the menu bar. + /// + /// The real test is position: 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. + static func isAnchorReady(_ view: NSView?) -> Bool { + guard let view, let window = view.window else { return false } + let frame = anchorFrameOnScreen(for: view, in: window) + guard frame.width > 0, frame.height > 0 else { return false } + + let screen = window.screen + ?? NSScreen.screens.first(where: { $0.frame.contains(CGPoint(x: frame.midX, y: frame.midY)) }) + ?? NSScreen.main + guard let screen else { return false } + return frame.midY > screen.visibleFrame.maxY + } + + /// `show()`, but only once the anchor is actually in the menu bar. + /// + /// Polls on a short timer rather than re-dispatching immediately: twenty `async` hops elapse + /// in microseconds and would expire long before the status bar has placed anything, which is + /// the same as not waiting at all. Gives up after `timeout` and shows regardless — an + /// unanchored panel is a cosmetic problem, a panel that never appears is a broken tool. + public func showWhenAnchored(timeout: TimeInterval = 2, pollInterval: TimeInterval = 0.02) { + guard !Self.isAnchorReady(anchorView()), timeout > 0 else { + show() + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + pollInterval) { [weak self] in + self?.showWhenAnchored(timeout: timeout - pollInterval, pollInterval: pollInterval) + } + } } diff --git a/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift b/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift index ae00969..d8effbb 100644 --- a/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift +++ b/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift @@ -50,12 +50,13 @@ final class NetworkInfoAppDelegate: NSObject, NSApplicationDelegate { // The distributed notification above only covers a *second* launch. On the first one this // process is the primary instance, so nothing posts it and the Toolbox tile would appear - // to do nothing — reveal the panel here instead. Deferred a runloop turn so the status - // item has a button to anchor to. + // to do nothing — reveal the panel here instead. + // + // `showWhenAnchored` rather than a one-turn `async`: the status item's button has a window + // before the status bar has sized it, so showing too early anchors the panel to a + // zero-sized rect at the screen origin and it opens in the corner of another display. if CommandLine.arguments.contains("--open") { - DispatchQueue.main.async { [weak self] in - self?.panelHost?.show() - } + panelHost?.showWhenAnchored() } } From 6df3d878723285ef2fe73b261dba79039b3b767c Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 21:18:26 +0530 Subject: [PATCH 5/9] Network Info: wait for the status item to stop moving, not just appear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/DMonteCore/HelperPanelHost.swift | 102 ++++++++++++++++++----- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/Sources/DMonteCore/HelperPanelHost.swift b/Sources/DMonteCore/HelperPanelHost.swift index a8f275d..d5032e7 100644 --- a/Sources/DMonteCore/HelperPanelHost.swift +++ b/Sources/DMonteCore/HelperPanelHost.swift @@ -536,41 +536,97 @@ public final class HelperPanelHost: NSObject { return window.convertToScreen(viewFrameInWindow) } - /// Whether the anchor has been placed in the menu bar yet. - /// - /// Size alone is not the signal. A status item's button reports its real size (29 × 22) - /// immediately while its window still sits at the screen origin, so a frame that looks - /// perfectly valid can describe a point nowhere near the menu bar. Anchoring to it puts the - /// panel relative to (0, 0) — the bottom-left corner of whichever display owns that point, - /// which on a multi-display Mac is not even the display with the menu bar. + /// How many consecutive identical samples count as "the status item has stopped moving". + private static let requiredStableAnchorSamples = 3 + + /// The anchor's screen frame if it is currently sitting in a menu bar, otherwise `nil`. /// - /// The real test is position: 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. - static func isAnchorReady(_ view: NSView?) -> Bool { - guard let view, let window = view.window else { return false } + /// Size alone is not the signal: a status item's button reports its real size immediately + /// while its window still sits at the screen origin, so a frame that looks perfectly valid + /// can describe a point nowhere near the menu bar. Position is the test — a status item + /// lives *above* its screen's visible frame, in the menu bar strip. + static func placedAnchorFrame(_ view: NSView?) -> NSRect? { + guard let view, let window = view.window else { return nil } let frame = anchorFrameOnScreen(for: view, in: window) - guard frame.width > 0, frame.height > 0 else { return false } + guard frame.width > 0, frame.height > 0 else { return nil } let screen = window.screen - ?? NSScreen.screens.first(where: { $0.frame.contains(CGPoint(x: frame.midX, y: frame.midY)) }) + ?? NSScreen.screens.first(where: { $0.frame.intersects(frame) }) ?? NSScreen.main - guard let screen else { return false } - return frame.midY > screen.visibleFrame.maxY + guard let screen else { return nil } + return frame.midY > screen.visibleFrame.maxY ? frame : nil + } + + static func isAnchorReady(_ view: NSView?) -> Bool { + placedAnchorFrame(view) != nil } - /// `show()`, but only once the anchor is actually in the menu bar. + /// `show()`, but only once the status item has stopped moving. /// - /// Polls on a short timer rather than re-dispatching immediately: twenty `async` hops elapse - /// in microseconds and would expire long before the status bar has placed anything, which is - /// the same as not waiting at all. Gives up after `timeout` and shows regardless — an - /// unanchored panel is a cosmetic problem, a panel that never appears is a broken tool. - public func showWhenAnchored(timeout: TimeInterval = 2, pollInterval: TimeInterval = 0.02) { - guard !Self.isAnchorReady(anchorView()), timeout > 0 else { + /// "Is it in the menu bar yet" is not enough. Measured on a cold `--open` launch, the item + /// takes three distinct positions before it is done: + /// + /// 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 + /// + /// Showing at the first placed frame anchors the panel to the top-right corner and leaves it + /// there while the icon moves elsewhere. So poll until the frame repeats unchanged a few + /// times over, and only then show. + /// + /// The poll is a timer rather than a re-dispatch: `async` hops elapse in microseconds and + /// would expire long before the status bar has finished, which is the same as not waiting. + /// After `timeout` it shows regardless — an unanchored panel is a cosmetic problem, a panel + /// that never appears is a broken tool. + public func showWhenAnchored(timeout: TimeInterval = 2, pollInterval: TimeInterval = 0.05) { + showWhenAnchorSettles( + remaining: timeout, + pollInterval: pollInterval, + lastFrame: nil, + stableSamples: 0 + ) + } + + private func showWhenAnchorSettles( + remaining: TimeInterval, + pollInterval: TimeInterval, + lastFrame: NSRect?, + stableSamples: Int + ) { + let frame = Self.placedAnchorFrame(anchorView()) + + if let frame, frame == lastFrame { + let samples = stableSamples + 1 + if samples >= Self.requiredStableAnchorSamples { + show() + return + } + scheduleAnchorSettleCheck(remaining: remaining, pollInterval: pollInterval, lastFrame: frame, stableSamples: samples) + return + } + + guard remaining > 0 else { show() return } + // Frame changed (or is not placed yet): restart the stability count from this sample. + scheduleAnchorSettleCheck(remaining: remaining, pollInterval: pollInterval, lastFrame: frame, stableSamples: 0) + } + + private func scheduleAnchorSettleCheck( + remaining: TimeInterval, + pollInterval: TimeInterval, + lastFrame: NSRect?, + stableSamples: Int + ) { DispatchQueue.main.asyncAfter(deadline: .now() + pollInterval) { [weak self] in - self?.showWhenAnchored(timeout: timeout - pollInterval, pollInterval: pollInterval) + self?.showWhenAnchorSettles( + remaining: remaining - pollInterval, + pollInterval: pollInterval, + lastFrame: lastFrame, + stableSamples: stableSamples + ) } } } From b372b81f2a3e0b4b8c24ec7a5bf35cd96ba60077 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 22:13:29 +0530 Subject: [PATCH 6/9] Panel anchoring: apply the settle wait to every tool that opens at launch 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 --- Sources/DMonteCore/HelperPanelHost.swift | 97 ++------------- Sources/DMonteCore/StatusItemAnchor.swift | 114 ++++++++++++++++++ .../MaintenanceAppDelegate.swift | 6 +- .../WindowManagerAppDelegate.swift | 4 +- 4 files changed, 130 insertions(+), 91 deletions(-) create mode 100644 Sources/DMonteCore/StatusItemAnchor.swift diff --git a/Sources/DMonteCore/HelperPanelHost.swift b/Sources/DMonteCore/HelperPanelHost.swift index d5032e7..6056397 100644 --- a/Sources/DMonteCore/HelperPanelHost.swift +++ b/Sources/DMonteCore/HelperPanelHost.swift @@ -461,7 +461,7 @@ public final class HelperPanelHost: NSObject { // not been laid out reports a zero-sized frame at the screen origin, and anchoring to // that puts the panel in a corner of the wrong display. Centred is the honest answer // until the real frame is available. - if let anchor, Self.isAnchorReady(anchor), let window = anchor.window, + if let anchor, StatusItemAnchor.isPlaced(anchor), let window = anchor.window, let screen = window.screen ?? NSScreen.main { frame = HelperPanelPlacement.anchoredFrame( for: size, @@ -536,97 +536,20 @@ public final class HelperPanelHost: NSObject { return window.convertToScreen(viewFrameInWindow) } - /// How many consecutive identical samples count as "the status item has stopped moving". - private static let requiredStableAnchorSamples = 3 - - /// The anchor's screen frame if it is currently sitting in a menu bar, otherwise `nil`. - /// - /// Size alone is not the signal: a status item's button reports its real size immediately - /// while its window still sits at the screen origin, so a frame that looks perfectly valid - /// can describe a point nowhere near the menu bar. Position is the test — a status item - /// lives *above* its screen's visible frame, in the menu bar strip. - static func placedAnchorFrame(_ view: NSView?) -> NSRect? { - guard let view, let window = view.window else { return nil } - let frame = anchorFrameOnScreen(for: view, in: window) - guard frame.width > 0, frame.height > 0 else { return nil } - - let screen = window.screen - ?? NSScreen.screens.first(where: { $0.frame.intersects(frame) }) - ?? NSScreen.main - guard let screen else { return nil } - return frame.midY > screen.visibleFrame.maxY ? frame : nil - } - static func isAnchorReady(_ view: NSView?) -> Bool { - placedAnchorFrame(view) != nil + StatusItemAnchor.isPlaced(view) } - /// `show()`, but only once the status item has stopped moving. - /// - /// "Is it in the menu bar yet" is not enough. Measured on a cold `--open` launch, the item - /// takes three distinct positions before it is done: - /// - /// 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 - /// - /// Showing at the first placed frame anchors the panel to the top-right corner and leaves it - /// there while the icon moves elsewhere. So poll until the frame repeats unchanged a few - /// times over, and only then show. - /// - /// The poll is a timer rather than a re-dispatch: `async` hops elapse in microseconds and - /// would expire long before the status bar has finished, which is the same as not waiting. - /// After `timeout` it shows regardless — an unanchored panel is a cosmetic problem, a panel - /// that never appears is a broken tool. + /// `show()`, but only once the status item has stopped moving. See `StatusItemAnchor` for why + /// neither "it exists" nor "it is in the menu bar" is a sufficient signal on its own. public func showWhenAnchored(timeout: TimeInterval = 2, pollInterval: TimeInterval = 0.05) { - showWhenAnchorSettles( - remaining: timeout, - pollInterval: pollInterval, - lastFrame: nil, - stableSamples: 0 - ) - } - - private func showWhenAnchorSettles( - remaining: TimeInterval, - pollInterval: TimeInterval, - lastFrame: NSRect?, - stableSamples: Int - ) { - let frame = Self.placedAnchorFrame(anchorView()) - - if let frame, frame == lastFrame { - let samples = stableSamples + 1 - if samples >= Self.requiredStableAnchorSamples { - show() - return - } - scheduleAnchorSettleCheck(remaining: remaining, pollInterval: pollInterval, lastFrame: frame, stableSamples: samples) - return - } - - guard remaining > 0 else { - show() - return + StatusItemAnchor.whenSettled( + { [weak self] in self?.anchorView() }, + timeout: timeout, + pollInterval: pollInterval + ) { [weak self] in + self?.show() } - // Frame changed (or is not placed yet): restart the stability count from this sample. - scheduleAnchorSettleCheck(remaining: remaining, pollInterval: pollInterval, lastFrame: frame, stableSamples: 0) } - private func scheduleAnchorSettleCheck( - remaining: TimeInterval, - pollInterval: TimeInterval, - lastFrame: NSRect?, - stableSamples: Int - ) { - DispatchQueue.main.asyncAfter(deadline: .now() + pollInterval) { [weak self] in - self?.showWhenAnchorSettles( - remaining: remaining - pollInterval, - pollInterval: pollInterval, - lastFrame: lastFrame, - stableSamples: stableSamples - ) - } - } } diff --git a/Sources/DMonteCore/StatusItemAnchor.swift b/Sources/DMonteCore/StatusItemAnchor.swift new file mode 100644 index 0000000..64bf53c --- /dev/null +++ b/Sources/DMonteCore/StatusItemAnchor.swift @@ -0,0 +1,114 @@ +import AppKit + +/// Knows when a status item has actually landed in the menu bar, so a panel anchored to it opens +/// in the right place. +/// +/// Every helper that reveals its UI at launch (`--open` from the Toolbox tile) has to wait for +/// its status item before positioning against it, and each one that tried got it wrong in the +/// same way: a single `DispatchQueue.main.async`, on the assumption that one runloop turn is +/// enough. It is not, and the failure is worse than a race — the button reports a *plausible* +/// frame long before it is placed, so the panel is positioned against garbage rather than +/// falling back to something sensible. +/// +/// Measured on a cold launch, the item takes three positions before settling: +/// +/// t=0.00 (0, -13) not placed — real size, still at the origin +/// t=0.00 (2536, 1410) placed, top right +/// t=0.10 (0, -30) back to the origin +/// t=0.20 (1393, 1484) final +/// +/// So neither "is it non-zero" nor "is it in the menu bar" is sufficient. The only reliable +/// signal is that the frame has stopped changing. +@MainActor +public enum StatusItemAnchor { + + /// Consecutive identical samples that count as "it has stopped moving". + private static let requiredStableSamples = 3 + + /// The anchor's frame in screen coordinates if it is currently sitting in a menu bar, + /// otherwise `nil`. + /// + /// Position, not size, is the test: a status item lives *above* its screen's visible frame, + /// in the menu bar strip. A button reports its true size while its window is still at the + /// origin, and anchoring to that puts the panel in the corner of whichever display owns + /// (0, 0) — on a multi-display Mac, usually not even the display with the menu bar. + public static func placedFrame(of view: NSView?) -> NSRect? { + guard let view, let window = view.window else { return nil } + let frameInWindow = view.convert(view.bounds, to: nil) + let frame = window.convertToScreen(frameInWindow) + guard frame.width > 0, frame.height > 0 else { return nil } + + let screen = window.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(frame) }) + ?? NSScreen.main + guard let screen else { return nil } + return frame.midY > screen.visibleFrame.maxY ? frame : nil + } + + /// Whether the anchor is currently placed in a menu bar. + public static func isPlaced(_ view: NSView?) -> Bool { + placedFrame(of: view) != nil + } + + /// Runs `body` once the status item's frame has repeated unchanged, or after `timeout` + /// regardless. + /// + /// The anchor is re-read on every poll rather than captured, because the button may not exist + /// yet when this is called. Polling on a timer rather than re-dispatching matters: `async` + /// hops elapse in microseconds and would all be spent before the status bar has done + /// anything, which is the same as not waiting at all. + /// + /// Showing late is a cosmetic problem; never showing is a broken tool, so the timeout always + /// runs `body`. + public static func whenSettled( + _ anchor: @escaping @MainActor () -> NSView?, + timeout: TimeInterval = 2, + pollInterval: TimeInterval = 0.05, + perform body: @escaping @MainActor () -> Void + ) { + poll(anchor, remaining: timeout, pollInterval: pollInterval, lastFrame: nil, stableSamples: 0, body: body) + } + + private static func poll( + _ anchor: @escaping @MainActor () -> NSView?, + remaining: TimeInterval, + pollInterval: TimeInterval, + lastFrame: NSRect?, + stableSamples: Int, + body: @escaping @MainActor () -> Void + ) { + let frame = placedFrame(of: anchor()) + + if let frame, frame == lastFrame { + let samples = stableSamples + 1 + if samples >= requiredStableSamples { + body() + return + } + schedule(anchor, remaining: remaining, pollInterval: pollInterval, lastFrame: frame, stableSamples: samples, body: body) + return + } + + guard remaining > 0 else { + body() + return + } + // Moved, or not placed yet: restart the stability count from this sample. + schedule(anchor, remaining: remaining, pollInterval: pollInterval, lastFrame: frame, stableSamples: 0, body: body) + } + + private static func schedule( + _ anchor: @escaping @MainActor () -> NSView?, + remaining: TimeInterval, + pollInterval: TimeInterval, + lastFrame: NSRect?, + stableSamples: Int, + body: @escaping @MainActor () -> Void + ) { + DispatchQueue.main.asyncAfter(deadline: .now() + pollInterval) { + MainActor.assumeIsolated { + poll(anchor, remaining: remaining - pollInterval, pollInterval: pollInterval, lastFrame: lastFrame, stableSamples: stableSamples, body: body) + } + } + } +} diff --git a/Sources/DMonteMaintenanceApp/MaintenanceAppDelegate.swift b/Sources/DMonteMaintenanceApp/MaintenanceAppDelegate.swift index 5ff99b2..d126ce8 100644 --- a/Sources/DMonteMaintenanceApp/MaintenanceAppDelegate.swift +++ b/Sources/DMonteMaintenanceApp/MaintenanceAppDelegate.swift @@ -63,9 +63,9 @@ final class MaintenanceAppDelegate: NSObject, NSApplicationDelegate { // If launched with --open, reveal the popover immediately. if CommandLine.arguments.contains("--open") { - DispatchQueue.main.async { [weak self] in - self?.panelHost?.show() - } + // Not a bare `async`: the status item takes several frames to settle, and showing + // against an unsettled one opens the panel in a corner. See `StatusItemAnchor`. + panelHost?.showWhenAnchored() } } diff --git a/Sources/DMonteWindowManagerApp/WindowManagerAppDelegate.swift b/Sources/DMonteWindowManagerApp/WindowManagerAppDelegate.swift index 0457419..ff4b88d 100644 --- a/Sources/DMonteWindowManagerApp/WindowManagerAppDelegate.swift +++ b/Sources/DMonteWindowManagerApp/WindowManagerAppDelegate.swift @@ -40,7 +40,9 @@ final class WindowManagerAppDelegate: NSObject, NSApplicationDelegate { configureShowNotification() if CommandLine.arguments.contains("--open") { - DispatchQueue.main.async { [weak self] in + // This tool positions its own panel, so it waits on the shared settle check + // directly rather than through HelperPanelHost. See `StatusItemAnchor`. + StatusItemAnchor.whenSettled({ [weak self] in self?.statusItem?.button }) { [weak self] in self?.showPanel() } } From 7b3e754c261d38eb64adfbb4ef3f6b6472a3c465 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 00:13:49 +0530 Subject: [PATCH 7/9] NetworkInfo: scale the settings sheet with the panel 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 --- Sources/DMonteCore/NetworkInfoSizing.swift | 13 ++++++ Sources/DMonteCore/NetworkInfoView.swift | 2 +- .../NetworkInfoSizingTests.swift | 40 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 Tests/DMonteCoreTests/NetworkInfoSizingTests.swift diff --git a/Sources/DMonteCore/NetworkInfoSizing.swift b/Sources/DMonteCore/NetworkInfoSizing.swift index e2125a6..9136f92 100644 --- a/Sources/DMonteCore/NetworkInfoSizing.swift +++ b/Sources/DMonteCore/NetworkInfoSizing.swift @@ -8,6 +8,19 @@ public enum NetworkInfoSizing { return NSSize(width: (368 * scale).rounded(), height: (520 * scale).rounded()) } + /// The settings overlay, which is centred *over* the panel and therefore must never be wider + /// than it. A hard-coded width looks right only at `currentScale == 1`; on a Mac whose menu + /// bar is thinner than 26pt the panel shrinks and the sheet does not, so it overflows equally + /// on both sides and its first and last characters are clipped. Width is capped at the panel + /// so that cannot happen at any scale. + public static func settingsSize() -> NSSize { + let scale = currentScale + return NSSize( + width: min((340 * scale).rounded(), preferredSize().width), + height: (300 * scale).rounded() + ) + } + static var currentScale: CGFloat { let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) let screenScale = visibleFrame.height / 950 diff --git a/Sources/DMonteCore/NetworkInfoView.swift b/Sources/DMonteCore/NetworkInfoView.swift index 805f7ad..35c5093 100644 --- a/Sources/DMonteCore/NetworkInfoView.swift +++ b/Sources/DMonteCore/NetworkInfoView.swift @@ -360,7 +360,7 @@ private struct NetworkInfoSettingsView: View { Spacer() } .padding(20) - .frame(width: 340, height: 300) + .frame(width: NetworkInfoSizing.settingsSize().width, height: NetworkInfoSizing.settingsSize().height) } private func settingRow(title: String, @ViewBuilder trailing: () -> Trailing) -> some View { diff --git a/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift b/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift new file mode 100644 index 0000000..82b9a79 --- /dev/null +++ b/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import DMonteCore + +final class NetworkInfoSizingTests: XCTestCase { + func testCurrentScaleWithinExpectedRange() { + let scale = NetworkInfoSizing.currentScale + XCTAssertGreaterThanOrEqual(scale, 0.82) + XCTAssertLessThanOrEqual(scale, 1.0) + } + + func testPreferredSizeHasPositiveDimensions() { + let size = NetworkInfoSizing.preferredSize() + XCTAssertGreaterThan(size.width, 0) + XCTAssertGreaterThan(size.height, 0) + } + + func testSettingsSizeHasPositiveDimensions() { + let size = NetworkInfoSizing.settingsSize() + XCTAssertGreaterThan(size.width, 0) + XCTAssertGreaterThan(size.height, 0) + } + + /// The overlay is centred over the panel, so anything wider is clipped symmetrically at both + /// edges. This is the invariant the whole change exists to guarantee. + func testSettingsFitsWithinPanel() { + let panel = NetworkInfoSizing.preferredSize() + let settings = NetworkInfoSizing.settingsSize() + XCTAssertLessThanOrEqual(settings.width, panel.width) + XCTAssertLessThanOrEqual(settings.height, panel.height) + } + + /// Both sizes derive from the same scale, so the sheet must shrink with the panel rather than + /// staying pinned at its full-size literal. + func testSettingsScalesWithPanel() { + let scale = NetworkInfoSizing.currentScale + let settings = NetworkInfoSizing.settingsSize() + XCTAssertEqual(settings.width, min((340 * scale).rounded(), NetworkInfoSizing.preferredSize().width)) + XCTAssertEqual(settings.height, (300 * scale).rounded()) + } +} From 93713dd3dad00651e02a315ad4cea2c4cfe15aed Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 00:17:55 +0530 Subject: [PATCH 8/9] Settings sheet: cap at the panel instead of scaling with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/DMonteCore/NetworkInfoSizing.swift | 19 ++++++++++--------- .../NetworkInfoSizingTests.swift | 15 +++++++++------ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/Sources/DMonteCore/NetworkInfoSizing.swift b/Sources/DMonteCore/NetworkInfoSizing.swift index 9136f92..50625c4 100644 --- a/Sources/DMonteCore/NetworkInfoSizing.swift +++ b/Sources/DMonteCore/NetworkInfoSizing.swift @@ -9,16 +9,17 @@ public enum NetworkInfoSizing { } /// The settings overlay, which is centred *over* the panel and therefore must never be wider - /// than it. A hard-coded width looks right only at `currentScale == 1`; on a Mac whose menu - /// bar is thinner than 26pt the panel shrinks and the sheet does not, so it overflows equally - /// on both sides and its first and last characters are clipped. Width is capped at the panel - /// so that cannot happen at any scale. + /// than it. A hard-coded size looks right only at `currentScale == 1`; on a Mac whose menu bar + /// is thinner than 26pt the panel shrinks and the sheet does not, so it overflows equally on + /// both sides and its first and last characters are clipped. + /// + /// Capped at the panel rather than scaled with it. The sheet's contents are laid out at this + /// size with unscaled padding, so shrinking it further than the panel demands would squeeze + /// them for no reason — and on the tools whose sheet is already narrower than their panel, + /// scaling would inset it noticeably while fixing nothing. public static func settingsSize() -> NSSize { - let scale = currentScale - return NSSize( - width: min((340 * scale).rounded(), preferredSize().width), - height: (300 * scale).rounded() - ) + let panel = preferredSize() + return NSSize(width: min(340, panel.width), height: min(300, panel.height)) } static var currentScale: CGFloat { diff --git a/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift b/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift index 82b9a79..1e98a90 100644 --- a/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift +++ b/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift @@ -29,12 +29,15 @@ final class NetworkInfoSizingTests: XCTestCase { XCTAssertLessThanOrEqual(settings.height, panel.height) } - /// Both sizes derive from the same scale, so the sheet must shrink with the panel rather than - /// staying pinned at its full-size literal. - func testSettingsScalesWithPanel() { - let scale = NetworkInfoSizing.currentScale + /// The sheet should be as large as it was designed to be, shrinking only as far as the panel + /// forces. Asserting the contract rather than restating the arithmetic: when this rule changed + /// from "scale with the panel" to "cap at the panel", the tests that restated the formula + /// failed while the ones asserting the relationship kept passing. + func testSettingsUsesItsDesignSizeUnlessThePanelIsSmaller() { + let panel = NetworkInfoSizing.preferredSize() let settings = NetworkInfoSizing.settingsSize() - XCTAssertEqual(settings.width, min((340 * scale).rounded(), NetworkInfoSizing.preferredSize().width)) - XCTAssertEqual(settings.height, (300 * scale).rounded()) + + XCTAssertEqual(settings.width, min(340, panel.width), accuracy: 1) + XCTAssertEqual(settings.height, min(300, panel.height), accuracy: 1) } } From f3153870b9841aad5f08c240af3a607f3007f963 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 02:39:32 +0530 Subject: [PATCH 9/9] Settings sheet: leave room for the overlay's 18pt padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/DMonteCore/NetworkInfoSizing.swift | 6 +++++- Tests/DMonteCoreTests/NetworkInfoSizingTests.swift | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/DMonteCore/NetworkInfoSizing.swift b/Sources/DMonteCore/NetworkInfoSizing.swift index 50625c4..7a1317d 100644 --- a/Sources/DMonteCore/NetworkInfoSizing.swift +++ b/Sources/DMonteCore/NetworkInfoSizing.swift @@ -19,7 +19,11 @@ public enum NetworkInfoSizing { /// scaling would inset it noticeably while fixing nothing. public static func settingsSize() -> NSSize { let panel = preferredSize() - return NSSize(width: min(340, panel.width), height: min(300, panel.height)) + // Leave room for the 18pt padding PreferencesOverlay adds around the sheet on every + // side: a sheet sized to the full panel becomes panel+36 once padded and spills + // the panel, dragging the content behind it off both edges. + let overlayChrome: CGFloat = 36 + return NSSize(width: min(340, panel.width - overlayChrome), height: min(300, panel.height - overlayChrome)) } static var currentScale: CGFloat { diff --git a/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift b/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift index 1e98a90..737d2b9 100644 --- a/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift +++ b/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift @@ -37,7 +37,7 @@ final class NetworkInfoSizingTests: XCTestCase { let panel = NetworkInfoSizing.preferredSize() let settings = NetworkInfoSizing.settingsSize() - XCTAssertEqual(settings.width, min(340, panel.width), accuracy: 1) - XCTAssertEqual(settings.height, min(300, panel.height), accuracy: 1) + XCTAssertEqual(settings.width, min(340, panel.width - 36), accuracy: 1) + XCTAssertEqual(settings.height, min(300, panel.height - 36), accuracy: 1) } }