diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe68d4..eaf5193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,18 @@ adheres to [Semantic Versioning](https://semver.org) and the multiple named notes, autosave (debounced while typing, flushed when the panel closes and at quit, one atomically written file per note), a live word/character/line count, copy-all, and an undoable clear. +- **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 c8e9810..e4e8e9a 100644 --- a/Package.swift +++ b/Package.swift @@ -111,6 +111,10 @@ let package = Package( .executable( name: "DMonteScratchpad", targets: ["DMonteScratchpad"] + ), + .executable( + name: "DMonteNetworkInfo", + targets: ["DMonteNetworkInfo"] ) ], dependencies: [ @@ -297,6 +301,13 @@ let package = Package( ], path: "Sources/DMonteScratchpadApp" ), + .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 f84fa3d..524f959 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ The app updates itself automatically via [Sparkle](https://sparkle-project.org); | **Dev Tools** | JSON, Base64, URL, hashing, UUID, timestamp, and case utilities | | **Snippets** | A searchable library of reusable text — click one to paste it into the app you came from | | **Scratchpad** | Always‑there plain‑text notepad with named notes, autosave, and a live word count | +| **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 c68cedc..66aefe7 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -40,6 +40,7 @@ HELPERS=( "DMonteSnippets|DMonte Snippets.app|SnippetsInfo.plist" "DMonteDriveEjector|DMonte Drive Ejector.app|DriveEjectorInfo.plist" "DMonteScratchpad|DMonte Scratchpad.app|ScratchpadInfo.plist" + "DMonteNetworkInfo|DMonte Network Info.app|NetworkInfoInfo.plist" ) stamp_version() { diff --git a/Sources/DMonteCore/AppPreferences.swift b/Sources/DMonteCore/AppPreferences.swift index f4b18bd..8a9a1ec 100644 --- a/Sources/DMonteCore/AppPreferences.swift +++ b/Sources/DMonteCore/AppPreferences.swift @@ -129,7 +129,8 @@ public enum AppDefaults { DefaultsKey.focusTimerLongBreakMinutes: 15, DefaultsKey.focusTimerLongBreakInterval: 4, DefaultsKey.driveEjectorConfirmsEjectAll: true, - DefaultsKey.scratchpadFontSize: 13 + DefaultsKey.scratchpadFontSize: 13, + DefaultsKey.networkInfoFetchesPublicIPAutomatically: false ]) } } diff --git a/Sources/DMonteCore/HelperPanelHost.swift b/Sources/DMonteCore/HelperPanelHost.swift index 7247082..6056397 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, StatusItemAnchor.isPlaced(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,21 @@ public final class HelperPanelHost: NSObject { let viewFrameInWindow = view.convert(view.bounds, to: nil) return window.convertToScreen(viewFrameInWindow) } + + static func isAnchorReady(_ view: NSView?) -> Bool { + StatusItemAnchor.isPlaced(view) + } + + /// `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) { + StatusItemAnchor.whenSettled( + { [weak self] in self?.anchorView() }, + timeout: timeout, + pollInterval: pollInterval + ) { [weak self] in + self?.show() + } + } + } diff --git a/Sources/DMonteCore/NetworkInfoController.swift b/Sources/DMonteCore/NetworkInfoController.swift new file mode 100644 index 0000000..be1648f --- /dev/null +++ b/Sources/DMonteCore/NetworkInfoController.swift @@ -0,0 +1,278 @@ +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? + + /// 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 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 + /// 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 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 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. + let monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] _ in + Task { @MainActor in + self?.handlePathChange() + } + } + pathMonitor = monitor + monitor.start(queue: pathMonitorQueue) + } + + /// 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() { + pathMonitor?.cancel() + 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 + /// it runs detached and only the resulting value crosses back to the main actor. + public func refresh() { + // 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. + 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) + } + } + + /// 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 + + /// 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() + + // 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. + 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..3744897 --- /dev/null +++ b/Sources/DMonteCore/NetworkInfoKit.swift @@ -0,0 +1,425 @@ +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] = [] + + // 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" }) + 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? { + // `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 { + 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..7a1317d --- /dev/null +++ b/Sources/DMonteCore/NetworkInfoSizing.swift @@ -0,0 +1,35 @@ +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()) + } + + /// The settings overlay, which is centred *over* the panel and therefore must never be wider + /// 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 panel = preferredSize() + // 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 { + 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..35c5093 --- /dev/null +++ b/Sources/DMonteCore/NetworkInfoView.swift @@ -0,0 +1,374 @@ +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") + // `.help` is only a tooltip; without this VoiceOver announces the bare glyph. + .accessibilityLabel("Refresh") + + Button { + isShowingSettings = true + } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: s(14), weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Settings") + .accessibilityLabel("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(publicIPDisclosure) + .font(.system(size: s(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + /// 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 { + 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) + .accessibilityLabel("Close Settings") + } + + 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: NetworkInfoSizing.settingsSize().width, height: NetworkInfoSizing.settingsSize().height) + } + + 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/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/DMonteCore/ToolboxCatalog.swift b/Sources/DMonteCore/ToolboxCatalog.swift index 3ae9953..ae79d39 100644 --- a/Sources/DMonteCore/ToolboxCatalog.swift +++ b/Sources/DMonteCore/ToolboxCatalog.swift @@ -68,7 +68,8 @@ public enum ToolboxCatalog { ToolboxTool(id: "micControl", title: "Mic Control", iconName: "mic.slash.fill", tint: .orange, bundleID: prefix + "miccontrol", appName: "DMonte Mic Control.app", executableName: "DMonteMicControl", arguments: ["--open"]), ToolboxTool(id: "snippets", title: "Snippets", iconName: "note.text", tint: .indigo, bundleID: prefix + "snippets", appName: "DMonte Snippets.app", executableName: "DMonteSnippets", arguments: ["--open"]), ToolboxTool(id: "driveEjector", title: "Drive Ejector", iconName: "eject.fill", tint: .teal, bundleID: prefix + "driveejector", appName: "DMonte Drive Ejector.app", executableName: "DMonteDriveEjector", arguments: ["--open"]), - ToolboxTool(id: "scratchpad", title: "Scratchpad", iconName: "square.and.pencil", tint: .yellow, bundleID: prefix + "scratchpad", appName: "DMonte Scratchpad.app", executableName: "DMonteScratchpad", arguments: ["--open"]) + ToolboxTool(id: "scratchpad", title: "Scratchpad", iconName: "square.and.pencil", tint: .yellow, bundleID: prefix + "scratchpad", appName: "DMonte Scratchpad.app", executableName: "DMonteScratchpad", 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/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/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift b/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift new file mode 100644 index 0000000..d8effbb --- /dev/null +++ b/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift @@ -0,0 +1,104 @@ +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() + + // 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. + // + // `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") { + panelHost?.showWhenAnchored() + } + } + + 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/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() } } diff --git a/Tests/DMonteCoreTests/NetworkInfoKitTests.swift b/Tests/DMonteCoreTests/NetworkInfoKitTests.swift new file mode 100644 index 0000000..d328ba1 --- /dev/null +++ b/Tests/DMonteCoreTests/NetworkInfoKitTests.swift @@ -0,0 +1,287 @@ +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"]) + } + + /// 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) + } + + // 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)) + } + + /// 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() { + 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") + } +} diff --git a/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift b/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift new file mode 100644 index 0000000..737d2b9 --- /dev/null +++ b/Tests/DMonteCoreTests/NetworkInfoSizingTests.swift @@ -0,0 +1,43 @@ +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) + } + + /// 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, panel.width - 36), accuracy: 1) + XCTAssertEqual(settings.height, min(300, panel.height - 36), accuracy: 1) + } +}