diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7b676529b..1411b5f8f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -185,3 +185,5 @@ endpoint, provider credentials, and the signed update channel. Changes that cross one of these boundaries should document the data flow, minimize retained data, fail closed, and include focused tests for authorization, parsing, and error handling. + +Network Diagnostics belongs to the System suite. The window, menu panel and CLI submit typed operations to the agent; the agent serializes scans, stores a bounded timeline in SQLite and schedules opt-in checks. Shared notification delivery keeps alerts working with the window closed. The agent does not request Wi-Fi or notification permissions. diff --git a/Packages/Edith/Sources/Edith/Core/Navigation/NavigationCatalog.swift b/Packages/Edith/Sources/Edith/Core/Navigation/NavigationCatalog.swift index e7bbcd1db..9f7a6e77f 100644 --- a/Packages/Edith/Sources/Edith/Core/Navigation/NavigationCatalog.swift +++ b/Packages/Edith/Sources/Edith/Core/Navigation/NavigationCatalog.swift @@ -148,6 +148,9 @@ enum NavigationCatalog { SidebarPage( id: "system", title: "System", symbolName: "switch.2", band: .suite(.system), isSuiteLanding: true, expansionKey: SuiteExpansion.key(for: .system)), + SidebarPage( + id: "network", title: "Network", symbolName: "network", + band: .suite(.system), abilityIDs: ["networkDiagnostics"], parentID: "system"), SidebarPage( id: "runningApps", title: "Running apps", symbolName: "cpu", band: .suite(.system), abilityIDs: ["system"], parentID: "system"), @@ -267,7 +270,7 @@ enum MainDestination: String, CaseIterable, Identifiable { case home, machines case agents, dashboard, herdr, quinjet, companion, plugins case appMaintenance - case system, runningApps + case system, runningApps, network case desk case media, music, calendar case data, database, attention, seoAudit diff --git a/Packages/Edith/Sources/Edith/Core/Navigation/PageContent.swift b/Packages/Edith/Sources/Edith/Core/Navigation/PageContent.swift index 9d9a84835..5e05a2726 100644 --- a/Packages/Edith/Sources/Edith/Core/Navigation/PageContent.swift +++ b/Packages/Edith/Sources/Edith/Core/Navigation/PageContent.swift @@ -23,6 +23,7 @@ struct PageContent: View { case .appMaintenance: AppMaintenanceView() case .system: SuiteLandingPage(suite: SuiteRegistry.suite(.system)) case .runningApps: SystemPage() + case .network: NetworkDiagnosticsPage() case .desk: SuiteLandingPage(suite: SuiteRegistry.suite(.desk)) case .media: SuiteLandingPage(suite: SuiteRegistry.suite(.media)) case .music: MusicPage() diff --git a/Packages/Edith/Sources/Edith/Features/NetworkDiagnostics/NetworkDiagnosticsPage.swift b/Packages/Edith/Sources/Edith/Features/NetworkDiagnostics/NetworkDiagnosticsPage.swift new file mode 100644 index 000000000..05c90bd19 --- /dev/null +++ b/Packages/Edith/Sources/Edith/Features/NetworkDiagnostics/NetworkDiagnosticsPage.swift @@ -0,0 +1,409 @@ +import AppKit +import EdithKit +import Observation +import SwiftUI +import UserNotifications + +@MainActor +@Observable +final class NetworkDiagnosticsModel { + var configuration = NetworkDiagnosticsPreferences.configuration() + var latest: NetworkDiagnosticSnapshot? + var timeline: [NetworkDiagnosticSnapshot] = [] + var running = false + var errorMessage: String? + var settingsPresented = false + var serviceText = "" + var exclusionText = "" + private var activeRun: Task? + + init() { + serviceText = configuration.serviceTargets.map { "\($0.host):\($0.port)" } + .joined(separator: ", ") + exclusionText = configuration.exclusions.joined(separator: ", ") + } + + func activate() { + Task { + timeline = + (try? await NetworkDiagnosticsClient.timeline( + limit: configuration.timelineLimit)) ?? [] + } + } + + func deactivate() { + activeRun?.cancel() + activeRun = nil + running = false + } + + func run() { + guard activeRun == nil else { return } + running = true + errorMessage = nil + let config = configuration.normalized + activeRun = Task { + let snapshot: NetworkDiagnosticSnapshot + do { + snapshot = try await NetworkDiagnosticsClient.diagnose(configuration: config) + } catch { + if !(error is CancellationError) { errorMessage = error.localizedDescription } + running = false + activeRun = nil + return + } + guard !Task.isCancelled else { + running = false + activeRun = nil + return + } + await accept(snapshot, configuration: config) + guard !Task.isCancelled else { + running = false + activeRun = nil + return + } + running = false + activeRun = nil + } + } + + func cancel() { + activeRun?.cancel() + } + + func saveSettings() { + configuration.serviceTargets = Self.parseServices(serviceText) + configuration.exclusions = Self.parseExclusions(exclusionText) + configuration = configuration.normalized + NetworkDiagnosticsPreferences.save(configuration) + IPC.post(IPC.Name.settingsChanged) + if configuration.notificationsEnabled { + Task { + _ = try? await UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .sound]) + } + } + settingsPresented = false + } + + private nonisolated static func parseServices(_ text: String) -> [NetworkServiceTarget] { + var targets: [NetworkServiceTarget] = [] + for value in text.split(separator: ",") { + let parts = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = parts.lastIndex(of: ":"), + let port = Int(parts[parts.index(after: separator)...]), + (1...65535).contains(port) + else { continue } + targets.append( + NetworkServiceTarget(host: String(parts[.. [String] { + var exclusions: [String] = [] + for value in text.split(separator: ",") { + exclusions.append(String(value)) + } + return exclusions + } + + func saveBaseline() { + guard let latest else { return } + Task { + do { + try await NetworkDiagnosticsClient.saveBaseline(latest) + self.latest = latest.compared(with: latest) + } catch { errorMessage = error.localizedDescription } + } + } + + func copyReport() { + guard let latest else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString( + NetworkDiagnosticsRedactor.report(latest), forType: .string) + } + + func exportReport() { + guard let latest else { return } + let panel = NSSavePanel() + panel.nameFieldStringValue = "network-diagnostics.txt" + panel.allowedContentTypes = [.plainText] + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + try NetworkDiagnosticsRedactor.report(latest).write( + to: url, atomically: true, encoding: .utf8) + } catch { + errorMessage = error.localizedDescription + } + } + + private func accept( + _ snapshot: NetworkDiagnosticSnapshot, + configuration: NetworkDiagnosticsConfiguration + ) async { + let previous = latest ?? timeline.first + latest = snapshot + do { + timeline = try await NetworkDiagnosticsClient.timeline( + limit: configuration.timelineLimit) + } catch { + errorMessage = error.localizedDescription + } + if configuration.notificationsEnabled, previous?.state != snapshot.state, + snapshot.state == .failed || previous?.state == .failed + { + let content = UNMutableNotificationContent() + content.title = "Network state changed" + content.body = "Diagnostics now report \(snapshot.state.rawValue)." + content.sound = .default + try? await UNUserNotificationCenter.current().add( + UNNotificationRequest( + identifier: "network-diagnostics-state", content: content, trigger: nil)) + } + } +} + +struct NetworkDiagnosticsPage: View { + @State private var model = NetworkDiagnosticsModel() + @Environment(\.colorScheme) private var scheme + @Environment(\.compactLayout) private var compact + + private var dark: Bool { scheme == .dark } + + var body: some View { + VStack(spacing: 0) { + header + ScrollView { + VStack(alignment: .leading, spacing: UIScale.pt(16)) { + if let error = model.errorMessage { statusMessage(error) } + pathSummary + checks + baselineChanges + timeline + } + .pageContent(compact, width: .readable) + } + } + .background(DashSkin.paper(dark)) + .sheet(isPresented: $model.settingsPresented) { settings } + .onAppear { model.activate() } + .onDisappear { model.deactivate() } + } + + private var header: some View { + PageHeader( + "Network Diagnostics", + trailing: { + HStack(spacing: 8) { + Button { + model.settingsPresented = true + } label: { + Label("Targets", systemImage: "slider.horizontal.3") + } + .buttonStyle(.edith(.secondary)) + Button { + model.running ? model.cancel() : model.run() + } label: { + Label( + model.running ? "Cancel" : "Run snapshot", + systemImage: model.running ? "xmark" : "waveform.path.ecg") + } + .buttonStyle(.edith(model.running ? .destructive : .primary)) + } + }) + } + + private var pathSummary: some View { + let snapshot = model.latest ?? model.timeline.first + return SkinCard(title: "Current path", dark: dark) { + if let snapshot { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: compact ? 150 : 190))], spacing: 12 + ) { + metric("Interface", snapshot.path.interfaceName ?? "Unavailable") + metric("Gateway", snapshot.path.gateway ?? "Unavailable") + metric("DNS resolvers", "\(snapshot.path.dnsServers.count)") + metric("Wi-Fi", snapshot.path.wifiName ?? "Metadata unavailable") + metric("Proxy", snapshot.path.proxyHint ?? "None detected") + metric("VPN", snapshot.path.vpnHint ?? "None detected") + } + HStack(spacing: 8) { + Label(snapshot.state.rawValue.capitalized, systemImage: symbol(snapshot.state)) + .foregroundStyle(color(snapshot.state)) + Text("\(Int(snapshot.durationMS)) ms total") + .foregroundStyle(.secondary) + Spacer() + Button("Save healthy baseline") { model.saveBaseline() } + .buttonStyle(.edith(.secondary)) + .disabled(snapshot.state != .healthy) + Button("Copy report") { model.copyReport() } + .buttonStyle(.edith(.secondary)) + Button("Export") { model.exportReport() } + .buttonStyle(.edith(.secondary)) + } + .font(.system(size: 12, weight: .medium)) + .padding(.top, 12) + } else { + ContentUnavailableView( + "Ready to diagnose", systemImage: "network", + description: Text("Local path checks run without changing network settings.") + ) + .frame(maxWidth: .infinity, minHeight: 130) + } + } + } + + private var checks: some View { + SkinCard(title: "Explainable checks", dark: dark) { + if let snapshot = model.latest ?? model.timeline.first { + VStack(spacing: 0) { + ForEach(snapshot.checks) { check in + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbol(check.state)) + .foregroundStyle(color(check.state)).frame(width: 18) + VStack(alignment: .leading, spacing: 3) { + Text(check.title).font(.system(size: 13, weight: .semibold)) + Text(check.summary).font(.system(size: 12)).foregroundStyle( + .secondary) + } + Spacer() + if let duration = check.durationMS { + Text("\(Int(duration)) ms").monospacedDigit() + } + if let loss = check.packetLossPercent { + Text("\(String(format: "%.0f", loss))% loss").monospacedDigit() + } + } + .font(.system(size: 11)) + .padding(.vertical, 9) + if check.id != snapshot.checks.last?.id { Divider().opacity(0.35) } + } + } + } else { + Text("Configure explicit remote targets, or run local-only checks now.") + .foregroundStyle(.secondary).padding(.vertical, 20) + } + } + } + + @ViewBuilder + private var baselineChanges: some View { + if let changes = model.latest?.baselineChanges, !changes.isEmpty { + SkinCard(title: "Changed from baseline", dark: dark) { + VStack(alignment: .leading, spacing: 8) { + ForEach(changes, id: \.self) { change in + Label(change, systemImage: "arrow.triangle.2.circlepath") + } + } + .font(.system(size: 12)) + } + } + } + + private var timeline: some View { + SkinCard(title: "Diagnostic timeline", dark: dark) { + if model.timeline.isEmpty { + Text("Snapshots appear here with bounded retention.") + .foregroundStyle(.secondary).padding(.vertical, 18) + } else { + VStack(spacing: 0) { + ForEach(model.timeline.prefix(12)) { snapshot in + HStack(spacing: 9) { + Image(systemName: symbol(snapshot.state)).foregroundStyle( + color(snapshot.state)) + Text(snapshot.createdAt.formatted(date: .abbreviated, time: .shortened)) + Spacer() + Text(snapshot.state.rawValue.capitalized).foregroundStyle(.secondary) + } + .font(.system(size: 12)) + .padding(.vertical, 7) + } + } + } + } + } + + private var settings: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Network targets").font(.title2.weight(.semibold)) + Text("Only targets entered here are contacted. Empty fields stay local-only.") + .font(.callout).foregroundStyle(.secondary) + Group { + TextField("Reachability host", text: $model.configuration.targetHost) + TextField("DNS lookup name", text: $model.configuration.dnsName) + TextField("HTTP URL", text: $model.configuration.httpTarget) + TextField("HTTPS URL", text: $model.configuration.httpsTarget) + TextField("Services, host:port separated by commas", text: $model.serviceText) + TextField( + "Excluded hosts or suffixes, separated by commas", text: $model.exclusionText) + } + .textFieldStyle(.roundedBorder) + Toggle("Allow public IP lookup", isOn: $model.configuration.publicIPEnabled) + Toggle( + "Enable low-energy scheduled snapshots", + isOn: $model.configuration.scheduledSamplingEnabled) + Toggle( + "Notify on meaningful state changes", + isOn: $model.configuration.notificationsEnabled) + Stepper( + "Sample every \(model.configuration.sampleIntervalMinutes) minutes", + value: $model.configuration.sampleIntervalMinutes, in: 5...1440, step: 5 + ) + .disabled(!model.configuration.scheduledSamplingEnabled) + HStack { + Stepper( + "Timeout \(Int(model.configuration.timeoutSeconds))s", + value: $model.configuration.timeoutSeconds, in: 1...30) + Stepper( + "Retries \(model.configuration.retries)", + value: $model.configuration.retries, in: 0...3) + } + HStack { + Spacer() + Button("Cancel") { model.settingsPresented = false } + .buttonStyle(.edith(.secondary)) + Button("Save") { model.saveSettings() } + .buttonStyle(.edith(.primary)) + } + } + .padding(22) + .frame(width: 560) + } + + private func metric(_ title: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title.uppercased()).font(.system(size: 10, weight: .semibold)).foregroundStyle( + .tertiary) + Text(value).font(.system(size: 13, weight: .medium)).lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func statusMessage(_ text: String) -> some View { + Label(text, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 12)).foregroundStyle(.orange) + .padding(10).frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + } + + private func symbol(_ state: NetworkDiagnosticState) -> String { + switch state { + case .healthy: "checkmark.circle.fill" + case .warning: "exclamationmark.triangle.fill" + case .failed: "xmark.octagon.fill" + case .skipped: "minus.circle.fill" + } + } + + private func color(_ state: NetworkDiagnosticState) -> Color { + switch state { + case .healthy: .green + case .warning: .orange + case .failed: .red + case .skipped: .secondary + } + } +} diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift index bc4877773..8139da3da 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift @@ -1045,6 +1045,7 @@ private struct ExtensionDetailRows: View { case .quinjet: QuinjetRows() case .seoAudit: SEOAuditRows() case .system: SystemRows() + case .networkDiagnostics: NetworkDiagnosticsRows() case .keepAwake: KeepAwakeRows() case .appMaintenance: AppMaintenanceRows() case .homebrew: HomebrewRows() @@ -1078,6 +1079,30 @@ private struct ExtensionDetailRows: View { } } +private struct NetworkDiagnosticsRows: View { + @AppStorage(AppStorageKeys.Tabs.networkDiagnosticsEnabled, store: SharedDefaults.store) private + var enabled = false + + var body: some View { + Group { + Section("Diagnostics") { + LabeledContent("Mode", value: "Read-only") + Text("Run explainable local path checks and configure explicit remote targets.") + .settingsCaption() + Button("Open Network Diagnostics") { SectionWindow.open(.network) } + } + Section("Privacy") { + Text("Public IP lookup and scheduled sampling stay off until you enable them.") + .settingsCaption() + Text("Copied and exported reports redact secrets and network addresses.") + .settingsCaption() + } + } + .disabled(!enabled) + .opacity(enabled ? 1 : 0.5) + } +} + private struct HomebrewRows: View { @AppStorage(AppStorageKeys.Homebrew.enabled, store: SharedDefaults.store) private var enabled = false diff --git a/Packages/Edith/Sources/EdithAgent/AgentJobCatalog.swift b/Packages/Edith/Sources/EdithAgent/AgentJobCatalog.swift index 46091f882..9500ba297 100644 --- a/Packages/Edith/Sources/EdithAgent/AgentJobCatalog.swift +++ b/Packages/Edith/Sources/EdithAgent/AgentJobCatalog.swift @@ -6,13 +6,14 @@ public enum AgentJobCatalog { AgentJobPlan.descriptors } - public static func jobs( + static func jobs( store: AgentStore?, scheduler: JobScheduler? = nil, downloads: DownloadWorker? = nil, - metrics: AgentMachineMetricsService? = nil, attention: AttentionBackgroundService? = nil + metrics: AgentMachineMetricsService? = nil, attention: AttentionBackgroundService? = nil, + network: NetworkDiagnosticsService? = nil ) -> [AgentJob] { let bodies = collectors( store: store, scheduler: scheduler, downloads: downloads, metrics: metrics, - attention: attention) + attention: attention, network: network) return descriptors().map { descriptor in let empty: @Sendable () async throws -> Data? = { nil } let body = bodies[descriptor.id] ?? empty @@ -25,7 +26,8 @@ public enum AgentJobCatalog { static func collectors( store: AgentStore?, scheduler: JobScheduler? = nil, downloads: DownloadWorker? = nil, - metrics: AgentMachineMetricsService? = nil, attention: AttentionBackgroundService? = nil + metrics: AgentMachineMetricsService? = nil, attention: AttentionBackgroundService? = nil, + network: NetworkDiagnosticsService? = nil ) -> [String: @Sendable () async throws -> Data?] { let limits = LimitsCollectorJob() let usage = UsageCollectorJob(store: store) @@ -39,6 +41,12 @@ public enum AgentJobCatalog { return await scheduler.subscriberCount(topic: .sessions) > 0 } return [ + "network.diagnostics": { + guard let network else { + throw AgentError(.unavailable, "Network diagnostic storage is unavailable.") + } + return try await network.scheduled() + }, "usage.refresh": { try await usage.run() }, "usage.limits": { try await limits.run() }, "machines.health": { try await machines.run() }, diff --git a/Packages/Edith/Sources/EdithAgent/AgentMain.swift b/Packages/Edith/Sources/EdithAgent/AgentMain.swift index a545cc3eb..c5fe1c691 100644 --- a/Packages/Edith/Sources/EdithAgent/AgentMain.swift +++ b/Packages/Edith/Sources/EdithAgent/AgentMain.swift @@ -84,11 +84,12 @@ public enum AgentBoot { let startup = Task { guard !Task.isCancelled else { return } await runtime.attach(scheduler: scheduler) + let network = store.map { NetworkDiagnosticsService(store: $0) } let metrics = await AgentMachineMetricsService() await metrics.register(on: runtime) await AgentOperations.register( on: runtime, store: store, scheduler: scheduler, downloads: downloads, - attention: attention) + attention: attention, network: network) do { let tasks = try AgentTaskService( publish: { snapshots in @@ -117,7 +118,7 @@ public enum AgentBoot { } for job in AgentJobCatalog.jobs( store: store, scheduler: scheduler, downloads: downloads, metrics: metrics, - attention: attention) + attention: attention, network: network) { await scheduler.register(job) } diff --git a/Packages/Edith/Sources/EdithAgent/AgentOperations.swift b/Packages/Edith/Sources/EdithAgent/AgentOperations.swift index 78e09cad9..4c852d888 100644 --- a/Packages/Edith/Sources/EdithAgent/AgentOperations.swift +++ b/Packages/Edith/Sources/EdithAgent/AgentOperations.swift @@ -2,10 +2,20 @@ import EdithKit import Foundation public enum AgentOperations { - public static func register( + static func register( on runtime: AgentRuntime, store: AgentStore? = nil, scheduler: JobScheduler? = nil, - downloads: DownloadWorker? = nil, attention: AttentionBackgroundService? = nil + downloads: DownloadWorker? = nil, attention: AttentionBackgroundService? = nil, + network: NetworkDiagnosticsService? = nil ) async { + if let network { + await network.register(on: runtime) + } else { + for operation in NetworkDiagnosticOperation.allCases { + await runtime.register(operation: operation.descriptor.id.rawValue) { _ in + throw AgentError(.unavailable, "Network diagnostic storage is unavailable.") + } + } + } let clipboard = ClipboardService() await clipboard.register(on: runtime) await FaviconService().register(on: runtime) diff --git a/Packages/Edith/Sources/EdithAgent/AgentStore.swift b/Packages/Edith/Sources/EdithAgent/AgentStore.swift index 6ac1d127e..e57096b51 100644 --- a/Packages/Edith/Sources/EdithAgent/AgentStore.swift +++ b/Packages/Edith/Sources/EdithAgent/AgentStore.swift @@ -35,7 +35,7 @@ public enum AgentStoreLayout { } public enum AgentSchema { - public static let version = 4 + public static let version = 5 public static var migrator: DatabaseMigrator { var migrator = DatabaseMigrator() @@ -121,6 +121,13 @@ public enum AgentSchema { table.column("updatedAt", .datetime).notNull() } } + migrator.registerMigration("0005-network-diagnostics") { database in + try database.create(table: "network_diagnostic") { table in + table.primaryKey("id", .text) + table.column("capturedAt", .datetime).notNull().indexed() + table.column("payload", .blob).notNull() + } + } return migrator } } diff --git a/Packages/Edith/Sources/EdithAgent/NetworkDiagnosticsService.swift b/Packages/Edith/Sources/EdithAgent/NetworkDiagnosticsService.swift new file mode 100644 index 000000000..ac7d1a481 --- /dev/null +++ b/Packages/Edith/Sources/EdithAgent/NetworkDiagnosticsService.swift @@ -0,0 +1,136 @@ +import EdithKit +import Foundation +import GRDB + +actor NetworkDiagnosticsService { + private let store: AgentStore + private let engine: NetworkDiagnosticsEngine + private var lastScheduled: Date? + private var lastState: NetworkDiagnosticState? + private var runningID: UUID? + private var running: Task? + + init(store: AgentStore, engine: NetworkDiagnosticsEngine = NetworkDiagnosticsEngine()) { + self.store = store + self.engine = engine + } + + func register(on runtime: AgentRuntime) async { + await runtime.register( + operation: NetworkDiagnosticOperation.diagnose.descriptor.id.rawValue + ) { payload in + try await self.diagnose( + AgentPayload.decode(NetworkDiagnosticRequest.self, from: payload)) + } + await runtime.register( + operation: NetworkDiagnosticOperation.baseline.descriptor.id.rawValue + ) { _ in + try AgentPayload.encode(NetworkDiagnosticsPreferences.baseline()) + } + await runtime.register(operation: NetworkDiagnosticsClient.timelineOperation) { payload in + try await self.timeline(limit: AgentPayload.decode(Int.self, from: payload)) + } + await runtime.register(operation: NetworkDiagnosticsClient.saveBaselineOperation) { + payload in + let snapshot = try AgentPayload.decode(NetworkDiagnosticSnapshot.self, from: payload) + guard snapshot.state == .healthy else { + throw AgentError(.refused, "Only healthy snapshots can be saved as a baseline.") + } + NetworkDiagnosticsPreferences.saveBaseline(snapshot) + return Data() + } + await runtime.register(operation: NetworkDiagnosticsClient.cancelOperation) { payload in + let id = try AgentPayload.decode(UUID.self, from: payload) + await self.cancel(id) + return Data() + } + await runtime.registerShutdown(id: "network.diagnostics") { await self.stop() } + } + + func stop() { running?.cancel() } + + func cancel(_ id: UUID) { + guard runningID == id else { return } + running?.cancel() + } + + func scheduled() async throws -> Data? { + let configuration = NetworkDiagnosticsPreferences.configuration() + guard configuration.scheduledSamplingEnabled else { return nil } + if let lastScheduled, + Date().timeIntervalSince(lastScheduled) + < Double(configuration.sampleIntervalMinutes * 60) + { + return nil + } + lastScheduled = Date() + let data = try await diagnose( + NetworkDiagnosticRequest( + configuration: configuration, keepHistory: true, saveBaseline: false)) + let snapshot = try AgentPayload.decode(NetworkDiagnosticSnapshot.self, from: data) + if configuration.notificationsEnabled, let lastState, lastState != snapshot.state, + snapshot.state == .failed || lastState == .failed + { + try await AgentNotificationService.shared.enqueue( + AgentNotification( + identifier: "network.diagnostics.state", title: "Network state changed", + body: "Diagnostics now report \(snapshot.state.rawValue).")) + } + lastState = snapshot.state + return data + } + + func diagnose(_ request: NetworkDiagnosticRequest) async throws -> Data { + guard running == nil else { + throw AgentError(.unavailable, "A network diagnostic is already running.") + } + let configuration = request.configuration.normalized + let baseline = NetworkDiagnosticsPreferences.baseline() + let task = Task { await engine.diagnose(configuration: configuration, baseline: baseline) } + running = task + runningID = request.id + defer { + running = nil + runningID = nil + } + let snapshot = await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + try Task.checkCancellation() + if task.isCancelled { throw CancellationError() } + let data = try AgentPayload.encode(snapshot) + if request.keepHistory { + try store.write { database in + try database.execute( + sql: + "INSERT INTO network_diagnostic (id, capturedAt, payload) VALUES (?, ?, ?)", + arguments: [snapshot.id.uuidString, Date(), data]) + try database.execute( + sql: + "DELETE FROM network_diagnostic WHERE id NOT IN (SELECT id FROM network_diagnostic ORDER BY capturedAt DESC, rowid DESC LIMIT ?)", + arguments: [configuration.timelineLimit]) + } + } + if request.saveBaseline { + guard snapshot.state == .healthy else { + throw AgentError(.refused, "Only healthy snapshots can be saved as a baseline.") + } + NetworkDiagnosticsPreferences.saveBaseline(snapshot) + } + return data + } + + func timeline(limit: Int) throws -> Data { + let rows = try store.read { database in + try Data.fetchAll( + database, + sql: + "SELECT payload FROM network_diagnostic ORDER BY capturedAt DESC, rowid DESC LIMIT ?", + arguments: [max(1, min(limit, 1000))]) + } + return try AgentPayload.encode( + rows.map { try AgentPayload.decode(NetworkDiagnosticSnapshot.self, from: $0) }) + } +} diff --git a/Packages/Edith/Sources/EdithCLI/CLIEnvironment.swift b/Packages/Edith/Sources/EdithCLI/CLIEnvironment.swift index 0ab8b9fa4..3762ceee0 100644 --- a/Packages/Edith/Sources/EdithCLI/CLIEnvironment.swift +++ b/Packages/Edith/Sources/EdithCLI/CLIEnvironment.swift @@ -19,6 +19,7 @@ public struct CLIRemoteDirectoryTarget: Sendable { } public enum CLIEnvironment { + nonisolated(unsafe) public static var networkClient: AgentClient = .shared nonisolated(unsafe) public static var sharedDefaults: UserDefaults = { guard let suite = ProcessInfo.processInfo.environment["EDITH_TEST_SHARED_DEFAULTS_SUITE"], let defaults = UserDefaults(suiteName: suite) diff --git a/Packages/Edith/Sources/EdithCLI/CommandTree.swift b/Packages/Edith/Sources/EdithCLI/CommandTree.swift index 24a69be77..54aa1e375 100644 --- a/Packages/Edith/Sources/EdithCLI/CommandTree.swift +++ b/Packages/Edith/Sources/EdithCLI/CommandTree.swift @@ -135,6 +135,15 @@ public enum CommandTree { typealias Spec = CommandSpec static let specs: [String: Spec] = [ + "ed network": Spec(options: [ + "--json", "--target", "--dns", "--http", "--https", "--service", "--exclude", + "--public-ip", "--timeout", "--retries", "--count", "--save-baseline", "--no-history", + ]), + "ed network baseline": Spec(options: ["--json"]), + "ed network diagnose": Spec(options: [ + "--json", "--target", "--dns", "--http", "--https", "--service", "--exclude", + "--public-ip", "--timeout", "--retries", "--count", "--save-baseline", "--no-history", + ]), "ed": Spec(options: ["--help", "--version"]), "ed guide": Spec(options: ["--json"], arguments: [.guideTopic]), "ed version": Spec(options: ["--json", "-h", "--help", "--version"]), diff --git a/Packages/Edith/Sources/EdithCLI/Commands/NetworkCommands.swift b/Packages/Edith/Sources/EdithCLI/Commands/NetworkCommands.swift new file mode 100644 index 000000000..ad2591a2b --- /dev/null +++ b/Packages/Edith/Sources/EdithCLI/Commands/NetworkCommands.swift @@ -0,0 +1,137 @@ +import ArgumentParser +import EdithKit +import Foundation + +struct NetworkCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "network", abstract: "Read-only local network diagnostics.", + subcommands: [NetworkDiagnoseCommand.self, NetworkBaselineCommand.self], + defaultSubcommand: NetworkDiagnoseCommand.self) +} + +struct NetworkBaselineCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "baseline", abstract: "Read the saved healthy network baseline.") + + @Flag(name: .long, help: "Emit redacted JSON on stdout.") + var json = false + + func run() async throws { + try await execute { + guard + let snapshot = try await AgentClient.shared.performAsync( + NetworkDiagnosticSnapshot?.self, + operation: NetworkDiagnosticOperation.baseline.descriptor.id) + else { + throw CLIFailure.notFound( + "no network baseline has been saved", + hint: "run `ed network diagnose --save-baseline`") + } + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(snapshot) + CLIOut.out( + NetworkDiagnosticsRedactor.redact(String(decoding: data, as: UTF8.self))) + } else { + CLIOut.out(NetworkDiagnosticsRedactor.report(snapshot)) + } + } + } +} + +struct NetworkDiagnoseCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "diagnose", + abstract: "Run explainable route, DNS, reachability, web, and service checks.", + usage: "ed network diagnose []") + + @Flag(name: .long, help: "Emit a redacted JSON snapshot on stdout.") + var json = false + + @Option(name: .long, help: "Ping this explicit host or address.") + var target: String? + + @Option(name: .long, help: "Resolve this explicit DNS name and time it.") + var dns: String? + + @Option(name: .customLong("http"), help: "Probe this explicit HTTP URL.") + var httpTarget: String? + + @Option(name: .customLong("https"), help: "Probe this explicit HTTPS URL.") + var httpsTarget: String? + + @Option(name: .long, help: "Probe host:port. Repeat for more services.") + var service: [String] = [] + + @Option(name: .long, help: "Skip this host or domain suffix. Repeat as needed.") + var exclude: [String] = [] + + @Flag(name: .long, help: "Perform the normally disabled public IP lookup.") + var publicIP = false + + @Option(name: .long, help: "Timeout for each attempt in seconds.") + var timeout: Double? + + @Option(name: .long, help: "Retry a failed check this many times.") + var retries: Int? + + @Option(name: .long, help: "Packets in each reachability sample.") + var count: Int? + + @Flag(name: .long, help: "Save this snapshot as the healthy baseline.") + var saveBaseline = false + + @Flag(name: .long, help: "Do not retain this snapshot in the bounded timeline.") + var noHistory = false + + func run() async throws { + try await execute { + var configuration = NetworkDiagnosticsPreferences.configuration() + if let target { configuration.targetHost = target } + if let dns { configuration.dnsName = dns } + if let httpTarget { configuration.httpTarget = httpTarget } + if let httpsTarget { configuration.httpsTarget = httpsTarget } + if !service.isEmpty { + configuration.serviceTargets = try service.map(parseService) + } + if !exclude.isEmpty { configuration.exclusions = exclude } + if publicIP { configuration.publicIPEnabled = true } + if let timeout { configuration.timeoutSeconds = timeout } + if let retries { configuration.retries = retries } + if let count { configuration.pingCount = count } + configuration = configuration.normalized + let snapshot = try await NetworkDiagnosticsClient.diagnose( + configuration: configuration, keepHistory: !noHistory, saveBaseline: saveBaseline, + client: CLIEnvironment.networkClient) + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(snapshot) + CLIOut.out( + NetworkDiagnosticsRedactor.redact(String(decoding: data, as: UTF8.self))) + } else { + CLIOut.out(NetworkDiagnosticsRedactor.report(snapshot)) + if saveBaseline { CLIOut.out("Saved as baseline.") } + } + } + } + + private func parseService(_ value: String) throws -> NetworkServiceTarget { + guard let separator = value.lastIndex(of: ":"), + let port = Int(value[value.index(after: separator)...]), + (1...65535).contains(port) + else { + throw CLIFailure.usage( + "invalid service target \(value)", + hint: "use host:port, for example example.com:443") + } + let host = String(value[.. Void + @State private var snapshot: NetworkDiagnosticSnapshot? + @State private var running = false + @State private var errorMessage: String? + @State private var task: Task? + + init(snapshot: NetworkDiagnosticSnapshot? = nil, openWorkspace: @escaping () -> Void) { + self.openWorkspace = openWorkspace + self._snapshot = State(initialValue: snapshot) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Network Diagnostics").font(.system(size: 15, weight: .semibold)) + Text("Read-only, local-first checks").font(.system(size: 11)).foregroundStyle( + .secondary) + } + Spacer() + Button("Open workspace", action: openWorkspace) + .buttonStyle(.edith(.toolbar)) + } + if let snapshot { + HStack(spacing: 9) { + Image(systemName: symbol(snapshot.state)) + .foregroundStyle(color(snapshot.state)) + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.state.rawValue.capitalized) + .font(.system(size: 13, weight: .semibold)) + Text("\(snapshot.checks.count) checks in \(Int(snapshot.durationMS)) ms") + .font(.system(size: 11)).foregroundStyle(.secondary) + } + Spacer() + Text(snapshot.createdAt.formatted(date: .omitted, time: .shortened)) + .font(.system(size: 11)).foregroundStyle(.tertiary) + } + .padding(12) + .background(.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10)) + } else { + Text( + "Run a snapshot to inspect your current route, DNS, gateway, and configured targets." + ) + .font(.system(size: 12)).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 60, alignment: .leading) + } + if let errorMessage { Text(errorMessage).font(.caption).foregroundStyle(.secondary) } + Button { + running ? task?.cancel() : run() + } label: { + Label( + running ? "Cancel" : "Run snapshot", systemImage: running ? "xmark" : "network" + ) + .frame(maxWidth: .infinity) + } + .buttonStyle(.edith(running ? .destructive : .primary)) + } + .onDisappear { + task?.cancel() + task = nil + running = false + } + } + + private func run() { + running = true + let configuration = NetworkDiagnosticsPreferences.configuration() + errorMessage = nil + task = Task { + defer { running = false; task = nil } + do { + let result = try await NetworkDiagnosticsClient.diagnose( + configuration: configuration) + guard !Task.isCancelled else { return } + snapshot = result + } catch is CancellationError { + } catch { errorMessage = error.localizedDescription } + } + } + + private func symbol(_ state: NetworkDiagnosticState) -> String { + switch state { + case .healthy: "checkmark.circle.fill" + case .warning: "exclamationmark.triangle.fill" + case .failed: "xmark.octagon.fill" + case .skipped: "minus.circle.fill" + } + } + + private func color(_ state: NetworkDiagnosticState) -> Color { + switch state { + case .healthy: .green + case .warning: .orange + case .failed: .red + case .skipped: .secondary + } + } +} diff --git a/Packages/Edith/Sources/EdithKit/Core/Agent/AgentJobPlan.swift b/Packages/Edith/Sources/EdithKit/Core/Agent/AgentJobPlan.swift index 9f76c41bf..1b3297fef 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Agent/AgentJobPlan.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Agent/AgentJobPlan.swift @@ -2,6 +2,10 @@ import Foundation public enum AgentJobPlan { public static let descriptors: [AgentJobDescriptor] = [ + AgentJobDescriptor( + id: "network.diagnostics", title: "Network diagnostics", trigger: .timer, + topic: .networkDiagnostics, cadence: .every(ambient: 60), power: .pauseOnLock, + abilityID: "networkDiagnostics"), AgentJobDescriptor( id: "usage.refresh", title: "Usage cost refresh", trigger: .fileSystem, topic: .usage, cadence: .every(ambient: 900), power: .any, abilityID: "usage"), diff --git a/Packages/Edith/Sources/EdithKit/Core/Agent/AgentOperationCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Agent/AgentOperationCatalog.swift index b13d79ecf..01c763007 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Agent/AgentOperationCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Agent/AgentOperationCatalog.swift @@ -9,9 +9,12 @@ public enum AgentOperationCatalog { public static let served: [UserOperationID] = AgentControlOperation.allCases.map { $0.descriptor.id } + usageOperations + + NetworkDiagnosticOperation.allCases.map { $0.descriptor.id } public static let internalOperations: [String] = [ + NetworkDiagnosticsClient.cancelOperation, NetworkDiagnosticsClient.timelineOperation, + NetworkDiagnosticsClient.saveBaselineOperation, AgentFaviconClient.operation, AttentionOperation.hasEvents, AttentionOperation.summary, AttentionOperation.backup, AttentionOperation.restore, AttentionDeliveryClient.operation, AttentionDeliveryClient.statusOperation, diff --git a/Packages/Edith/Sources/EdithKit/Core/Agent/AgentService.swift b/Packages/Edith/Sources/EdithKit/Core/Agent/AgentService.swift index 384066aef..9aeacefe2 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Agent/AgentService.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Agent/AgentService.swift @@ -64,6 +64,7 @@ public enum AgentTopic: String, CaseIterable, Codable, Sendable { case sessions case machines case machineMetrics + case networkDiagnostics case updates case cleaner case downloads @@ -82,6 +83,7 @@ public enum AgentTopic: String, CaseIterable, Codable, Sendable { case .sessions: "Sessions" case .machines: "Machines" case .machineMetrics: "Machine metrics" + case .networkDiagnostics: "Network diagnostics" case .updates: "Updates" case .cleaner: "Cleaner" case .downloads: "Downloads" diff --git a/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift b/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift index c0b0e423a..a286ee844 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift @@ -730,6 +730,9 @@ final class SettingsBackup { AppStorageKeys.Music.downloadKind, AppStorageKeys.Backup.icloud, AppStorageKeys.Music.backup, AppStorageKeys.General.lastPaletteTheme, AppStorageKeys.General.appearance, + AppStorageKeys.Tabs.networkDiagnosticsEnabled, + AppStorageKeys.NetworkDiagnostics.configuration, + AppStorageKeys.NetworkDiagnostics.baseline, AppStorageKeys.Tabs.systemEnabled, AppStorageKeys.General.keepAwakeEnabled, AppStorageKeys.General.preventSleep, AppStorageKeys.Tabs.order, @@ -852,6 +855,9 @@ final class SettingsBackup { AppStorageKeys.Herdr.ghosttyTerminal, AppStorageKeys.Quinjet.terminal, AppStorageKeys.Quinjet.theme, AppStorageKeys.Tabs.systemEnabled, AppStorageKeys.Tabs.calendarEnabled, + AppStorageKeys.Tabs.networkDiagnosticsEnabled, + AppStorageKeys.NetworkDiagnostics.configuration, + AppStorageKeys.NetworkDiagnostics.baseline, AppStorageKeys.Tabs.order, "usageMachines", AppStorageKeys.Machines.notifyDown, diff --git a/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift b/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift index 8a6aeed1c..1332d27ab 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Defaults/AppStorageKeys.swift @@ -217,6 +217,12 @@ public enum AppStorageKeys { public static let shuffling = "musicShuffling" } + public enum NetworkDiagnostics { + public static let baseline = "networkDiagnosticsBaseline" + public static let configuration = "networkDiagnosticsConfiguration" + public static let enabled = "tabNetworkDiagnosticsEnabled" + } + public enum Notch { public static let alertAudio = "notchAlertAudio" public static let alertBattery = "notchAlertBattery" @@ -290,6 +296,7 @@ public enum AppStorageKeys { public static let databaseEnabled = "tabDatabaseEnabled" public static let herdrEnabled = "tabHerdrEnabled" public static let musicEnabled = "tabMusicEnabled" + public static let networkDiagnosticsEnabled = "tabNetworkDiagnosticsEnabled" public static let order = "tabOrder" public static let quinjetEnabled = "tabQuinjetEnabled" public static let seoAuditEnabled = "tabSEOAuditEnabled" diff --git a/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift index 15970623b..c000101c6 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift @@ -49,7 +49,8 @@ public enum ConfigCatalog { "alerts", "budget", "dashboard", "database", - "machines", "herdr", "quinjet", "companion", "finder", "system", "homebrew", "cleaner", + "machines", "herdr", "quinjet", "companion", "finder", "system", "network", "homebrew", + "cleaner", "music", "calendar", "clipboard", "keystrokes", @@ -529,6 +530,10 @@ public enum ConfigCatalog { AppStorageKeys.General.keepAwakeEnabled, .bool, group: "system", summary: "Keep Awake ability: prevent idle sleep independently of System.", fallback: .bool(false)), + SettingDefinition( + AppStorageKeys.Tabs.networkDiagnosticsEnabled, .bool, group: "network", + summary: "Network Diagnostics extension: read-only connectivity checks.", + fallback: .bool(false)), SettingDefinition( AppStorageKeys.General.preventSleep, .bool, group: "system", summary: "Keep the Mac awake (Keep Awake).", fallback: .bool(false)), diff --git a/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift index 2a943a4a3..72a7189ea 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift @@ -154,6 +154,11 @@ public enum UserOperationCatalog { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } + private static let networkRegistrations: [RegisteredUserOperation] = + NetworkDiagnosticOperation.allCases.map { + RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) + } + private static let agentRegistrations: [RegisteredUserOperation] = AgentControlOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) @@ -210,6 +215,7 @@ public enum UserOperationCatalog { public static let registrations = machineRegistrations + applicationRegistrations + featureRegistrations + agentRegistrations + remoteFileRegistrations + remoteActionRegistrations + + networkRegistrations public static let descriptors = registrations.map(\.descriptor) @@ -295,6 +301,29 @@ private func commandLineOnly(_ reason: String) -> UserOperationExposure { .commandLineOnly(reason: reason) } +private extension NetworkDiagnosticOperation { + var interfaceExposure: UserOperationExposure { + switch self { + case .diagnose: + .userInterface([ + UserInterfaceActionPlacement( + surface: "Network Diagnostics workspace", action: "run a snapshot"), + UserInterfaceActionPlacement( + surface: "Menu panel", action: "run a network snapshot"), + UserInterfaceActionPlacement( + surface: "Command Bar", action: "run Network Diagnostics"), + ]) + case .baseline: + .userInterface([ + UserInterfaceActionPlacement( + surface: "Network Diagnostics workspace", action: "compare with baseline"), + UserInterfaceActionPlacement( + surface: "Command Bar", action: "show the network baseline"), + ]) + } + } +} + private extension MachineControlOperation { var interfaceExposure: UserOperationExposure { switch self { diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift index df3ee6525..0a9cdcfc2 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift @@ -138,7 +138,7 @@ public extension ExtensionRegistryEntry { var optionalPermissions: [ExtensionPermission] { switch id { - case "usage", "appMaintenance": [.notifications] + case "usage", "appMaintenance", "networkDiagnostics": [.notifications] case "system": [.accessibility, .inputMonitoring] case "notchShelf": [.bluetooth, .camera, .automation] case "audioMixer": [.applicationAudio] diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift index 85dd61ccc..1715080c1 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionDefaultsMigration.swift @@ -87,6 +87,7 @@ public enum ExtensionDefaultsMigration { AppStorageKeys.Tabs.attentionEnabled: true, AppStorageKeys.Tabs.usageEnabled: true, AppStorageKeys.Tabs.systemEnabled: true, + AppStorageKeys.Tabs.networkDiagnosticsEnabled: false, AppStorageKeys.AppMaintenance.enabled: false, AppStorageKeys.AppMaintenance.updateAutoRefresh: false, AppStorageKeys.AppMaintenance.updateNotifications: true, diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift index 8bb0f668c..2f90d1573 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift @@ -192,6 +192,8 @@ public struct ExtensionLifecycleProbe: Sendable { requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "system": Policy( requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), + "networkDiagnostics": Policy( + requiresHelper: false, requiresMachine: false, toolRule: .all, adapter: true), "appMaintenance": Policy( requiresHelper: false, requiresMachine: false, toolRule: .all, adapter: true), "database": Policy( diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift index cec0e0b1a..5afa416bb 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift @@ -65,11 +65,31 @@ private final class ExtensionAdapterDefaults: @unchecked Sendable { public enum ExtensionLiveAdapters { public static let extensionIDs = [ - "usage", "quinjet", "plugins", "appMaintenance", "homebrew", "cleaner", "system", - "keepAwake", "lidAwake", - "systemStats", "micMute", "clipboard", "emoji", "colorPicker", "keystrokeHighlight", - "focusDim", "presenter", "music", "downloads", "notchShelf", "audioMixer", "calendar", - "attention", "seoAudit", + "usage", + "quinjet", + "plugins", + "appMaintenance", + "homebrew", + "cleaner", + "system", + "networkDiagnostics", + "keepAwake", + "lidAwake", + "systemStats", + "micMute", + "clipboard", + "emoji", + "colorPicker", + "keystrokeHighlight", + "focusDim", + "presenter", + "music", + "downloads", + "notchShelf", + "audioMixer", + "calendar", + "attention", + "seoAudit", ] public static func provider( @@ -104,6 +124,8 @@ public enum ExtensionLiveAdapters { quinjetReadiness(defaults: defaults, executable: executableNamed("quinjet")) case "seoAudit": siteAuditReadiness() case "system": await systemReadiness() + case "networkDiagnostics": + .ready("Read-only network diagnostics are available on this Mac.") case "keepAwake": .ready("Keep Awake is ready to prevent idle sleep without System.") case "appMaintenance": appMaintenanceReadiness() case "homebrew": homebrewReadiness(executable: executableNamed("brew")) diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift index 924abbc58..35b7944e6 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift @@ -77,6 +77,7 @@ public enum ExtensionDetailRoute: String, CaseIterable, Sendable { case quinjet case seoAudit case system + case networkDiagnostics case keepAwake case appMaintenance case homebrew diff --git a/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Models/NetworkDiagnostics.swift b/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Models/NetworkDiagnostics.swift new file mode 100644 index 000000000..48a1a1857 --- /dev/null +++ b/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Models/NetworkDiagnostics.swift @@ -0,0 +1,279 @@ +import EdithCore +import Foundation + +public enum NetworkDiagnosticState: String, Codable, CaseIterable, Sendable { + case healthy + case warning + case failed + case skipped + + public var rank: Int { + switch self { + case .healthy: 0 + case .skipped: 0 + case .warning: 2 + case .failed: 3 + } + } +} + +public enum NetworkDiagnosticOperation: String, CaseIterable, Sendable { + case diagnose + case baseline + + public var descriptor: UserOperationDescriptor { + switch self { + case .diagnose: + UserOperationDescriptor( + id: UserOperationID(rawValue: "network.diagnose"), + summary: "Run a read-only network diagnostic snapshot.", + cli: ["network", "diagnose"], effect: .read) + case .baseline: + UserOperationDescriptor( + id: UserOperationID(rawValue: "network.baseline"), + summary: "Read the saved healthy network baseline.", + cli: ["network", "baseline"], effect: .read) + } + } +} + +public struct NetworkServiceTarget: Codable, Equatable, Hashable, Identifiable, Sendable { + public var id: String { "\(host):\(port)" } + public var host: String + public var port: Int + + public init(host: String, port: Int) { + self.host = host + self.port = port + } +} + +public struct NetworkDiagnosticsConfiguration: Codable, Equatable, Sendable { + public var targetHost: String + public var dnsName: String + public var httpTarget: String + public var httpsTarget: String + public var serviceTargets: [NetworkServiceTarget] + public var exclusions: [String] + public var publicIPEnabled: Bool + public var scheduledSamplingEnabled: Bool + public var notificationsEnabled: Bool + public var sampleIntervalMinutes: Int + public var timeoutSeconds: Double + public var retries: Int + public var pingCount: Int + public var timelineLimit: Int + + public init( + targetHost: String = "", dnsName: String = "", httpTarget: String = "", + httpsTarget: String = "", serviceTargets: [NetworkServiceTarget] = [], + exclusions: [String] = [], publicIPEnabled: Bool = false, + scheduledSamplingEnabled: Bool = false, notificationsEnabled: Bool = false, + sampleIntervalMinutes: Int = 15, timeoutSeconds: Double = 4, retries: Int = 1, + pingCount: Int = 4, timelineLimit: Int = 100 + ) { + self.targetHost = targetHost + self.dnsName = dnsName + self.httpTarget = httpTarget + self.httpsTarget = httpsTarget + self.serviceTargets = serviceTargets + self.exclusions = exclusions + self.publicIPEnabled = publicIPEnabled + self.scheduledSamplingEnabled = scheduledSamplingEnabled + self.notificationsEnabled = notificationsEnabled + self.sampleIntervalMinutes = sampleIntervalMinutes + self.timeoutSeconds = timeoutSeconds + self.retries = retries + self.pingCount = pingCount + self.timelineLimit = timelineLimit + } + + public var normalized: Self { + var value = self + value.targetHost = targetHost.trimmingCharacters(in: .whitespacesAndNewlines) + value.dnsName = dnsName.trimmingCharacters(in: .whitespacesAndNewlines) + value.httpTarget = httpTarget.trimmingCharacters(in: .whitespacesAndNewlines) + value.httpsTarget = httpsTarget.trimmingCharacters(in: .whitespacesAndNewlines) + value.serviceTargets = serviceTargets.filter { + !$0.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (1...65535).contains($0.port) + } + value.exclusions = exclusions.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + }.filter { !$0.isEmpty } + value.sampleIntervalMinutes = min(1440, max(5, sampleIntervalMinutes)) + value.timeoutSeconds = min(30, max(1, timeoutSeconds)) + value.retries = min(3, max(0, retries)) + value.pingCount = min(10, max(1, pingCount)) + value.timelineLimit = min(500, max(10, timelineLimit)) + return value + } + + public func excludes(_ host: String) -> Bool { + let candidate = host.lowercased() + return exclusions.contains { candidate == $0 || candidate.hasSuffix("." + $0) } + } +} + +public struct NetworkDiagnosticCheck: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let title: String + public let state: NetworkDiagnosticState + public let summary: String + public let detail: String + public let durationMS: Double? + public let packetLossPercent: Double? + + public init( + id: String, title: String, state: NetworkDiagnosticState, summary: String, + detail: String = "", durationMS: Double? = nil, packetLossPercent: Double? = nil + ) { + self.id = id + self.title = title + self.state = state + self.summary = summary + self.detail = detail + self.durationMS = durationMS + self.packetLossPercent = packetLossPercent + } +} + +public struct NetworkPathSummary: Codable, Equatable, Sendable { + public let interfaceName: String? + public let localAddress: String? + public let gateway: String? + public let dnsServers: [String] + public let wifiName: String? + public let wifiBSSID: String? + public let wifiRSSI: Int? + public let proxyHint: String? + public let vpnHint: String? + public let publicAddress: String? + + public init( + interfaceName: String? = nil, localAddress: String? = nil, gateway: String? = nil, + dnsServers: [String] = [], wifiName: String? = nil, wifiBSSID: String? = nil, + wifiRSSI: Int? = nil, proxyHint: String? = nil, vpnHint: String? = nil, + publicAddress: String? = nil + ) { + self.interfaceName = interfaceName + self.localAddress = localAddress + self.gateway = gateway + self.dnsServers = dnsServers + self.wifiName = wifiName + self.wifiBSSID = wifiBSSID + self.wifiRSSI = wifiRSSI + self.proxyHint = proxyHint + self.vpnHint = vpnHint + self.publicAddress = publicAddress + } +} + +public struct NetworkDiagnosticSnapshot: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public let createdAt: Date + public let durationMS: Double + public let state: NetworkDiagnosticState + public let path: NetworkPathSummary + public let checks: [NetworkDiagnosticCheck] + public let baselineChanges: [String] + + public init( + id: UUID = UUID(), createdAt: Date = Date(), durationMS: Double, + state: NetworkDiagnosticState, path: NetworkPathSummary, + checks: [NetworkDiagnosticCheck], baselineChanges: [String] = [] + ) { + self.id = id + self.createdAt = createdAt + self.durationMS = durationMS + self.state = state + self.path = path + self.checks = checks + self.baselineChanges = baselineChanges + } + + public func compared(with baseline: Self?) -> Self { + guard let baseline else { return self } + let old = Dictionary(uniqueKeysWithValues: baseline.checks.map { ($0.id, $0) }) + let changes = checks.compactMap { check -> String? in + guard let previous = old[check.id] else { return "\(check.title) is new" } + if check.state != previous.state { + return "\(check.title): \(previous.state.rawValue) to \(check.state.rawValue)" + } + if let now = check.durationMS, let before = previous.durationMS, + now > max(before * 2, before + 50) + { + return "\(check.title): latency increased from \(Int(before)) ms to \(Int(now)) ms" + } + return nil + } + return Self( + id: id, createdAt: createdAt, durationMS: durationMS, state: state, path: path, + checks: checks, baselineChanges: changes) + } +} + +public enum NetworkDiagnosticsRedactor { + public static func redact(_ text: String) -> String { + var value = text + let patterns = [ + #"(?i)(authorization|token|password|secret|api[_-]?key)(\s*[:=]\s*)[^\s&,;]+"#, + #"\b(?:\d{1,3}\.){3}\d{1,3}\b"#, + #"(?" : "
" + value = expression.stringByReplacingMatches( + in: value, range: NSRange(value.startIndex..., in: value), + withTemplate: replacement) + } + guard + let detector = try? NSDataDetector( + types: NSTextCheckingResult.CheckingType.link.rawValue) + else { return value } + let source = value + for match in detector.matches( + in: source, range: NSRange(source.startIndex..., in: source) + ).reversed() { + guard let range = Range(match.range, in: value), let url = match.url, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { continue } + if components.user != nil { components.user = "redacted" } + if components.password != nil { components.password = "redacted" } + if components.query != nil { components.query = "" } + value.replaceSubrange(range, with: components.string ?? "") + } + return value + } + + public static func report(_ snapshot: NetworkDiagnosticSnapshot) -> String { + let formatter = ISO8601DateFormatter() + var lines = [ + "Network Diagnostics", "Captured: \(formatter.string(from: snapshot.createdAt))", + "Overall: \(snapshot.state.rawValue)", + "Interface: \(snapshot.path.interfaceName ?? "unavailable")", + "Local address: \(snapshot.path.localAddress ?? "unavailable")", + "Gateway: \(snapshot.path.gateway ?? "unavailable")", + "DNS: \(snapshot.path.dnsServers.joined(separator: ", "))", + "Wi-Fi: \(snapshot.path.wifiName == nil ? "unavailable" : "")", + "Proxy: \(snapshot.path.proxyHint ?? "none detected")", + "VPN: \(snapshot.path.vpnHint ?? "none detected")", + "Public address: \(snapshot.path.publicAddress ?? "disabled or unavailable")", "", + ] + for check in snapshot.checks { + let timing = check.durationMS.map { " \(Int($0)) ms" } ?? "" + let loss = check.packetLossPercent.map { " loss \(String(format: "%.1f", $0))%" } ?? "" + lines.append( + "[\(check.state.rawValue)] \(check.title): \(check.summary)\(timing)\(loss)") + if !check.detail.isEmpty { lines.append(" \(check.detail)") } + } + if !snapshot.baselineChanges.isEmpty { + lines.append("") + lines.append("Changes from saved baseline:") + lines.append(contentsOf: snapshot.baselineChanges.map { "- \($0)" }) + } + return redact(lines.joined(separator: "\n")) + } +} diff --git a/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Services/NetworkDiagnosticsClient.swift b/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Services/NetworkDiagnosticsClient.swift new file mode 100644 index 000000000..df2269206 --- /dev/null +++ b/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Services/NetworkDiagnosticsClient.swift @@ -0,0 +1,54 @@ +import Foundation + +public struct NetworkDiagnosticRequest: Codable, Sendable { + public let id: UUID + public var configuration: NetworkDiagnosticsConfiguration + public var keepHistory: Bool + public var saveBaseline: Bool + + public init( + configuration: NetworkDiagnosticsConfiguration, keepHistory: Bool, saveBaseline: Bool + ) { + self.id = UUID() + self.configuration = configuration + self.keepHistory = keepHistory + self.saveBaseline = saveBaseline + } +} + +public enum NetworkDiagnosticsClient { + public static let cancelOperation = "network.cancel" + public static let timelineOperation = "network.timeline" + public static let saveBaselineOperation = "network.baseline.save" + + public static func diagnose( + configuration: NetworkDiagnosticsConfiguration, keepHistory: Bool = true, + saveBaseline: Bool = false, client: AgentClient = .shared + ) async throws -> NetworkDiagnosticSnapshot { + let request = NetworkDiagnosticRequest( + configuration: configuration, keepHistory: keepHistory, saveBaseline: saveBaseline) + return try await withTaskCancellationHandler { + try await client.performAsync( + NetworkDiagnosticSnapshot.self, + operation: NetworkDiagnosticOperation.diagnose.descriptor.id, + payload: AgentPayload.encode(request), timeout: 180) + } onCancel: { + Task { + _ = try? await client.performInternalAsync( + cancelOperation, payload: AgentPayload.encode(request.id)) + } + } + } + + public static func timeline(limit: Int = 100) async throws -> [NetworkDiagnosticSnapshot] { + try AgentPayload.decode( + [NetworkDiagnosticSnapshot].self, + from: await AgentClient.shared.performInternalAsync( + timelineOperation, payload: AgentPayload.encode(limit))) + } + + public static func saveBaseline(_ snapshot: NetworkDiagnosticSnapshot) async throws { + _ = try await AgentClient.shared.performInternalAsync( + saveBaselineOperation, payload: AgentPayload.encode(snapshot)) + } +} diff --git a/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Services/NetworkDiagnosticsEngine.swift b/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Services/NetworkDiagnosticsEngine.swift new file mode 100644 index 000000000..1e38900ee --- /dev/null +++ b/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Services/NetworkDiagnosticsEngine.swift @@ -0,0 +1,411 @@ +import CoreWLAN +import Foundation + +public struct NetworkCommandResult: Equatable, Sendable { + public let status: Int32 + public let output: String + public let timedOut: Bool + + public init(status: Int32, output: String, timedOut: Bool = false) { + self.status = status + self.output = output + self.timedOut = timedOut + } +} + +public enum NetworkProcessRunner { + public static func run( + executable: URL, arguments: [String], timeout: Double + ) async -> NetworkCommandResult { + do { + let result = try await CLICommandRunner.run( + CLICommandRequest( + executableURL: executable, arguments: arguments, + environment: ProcessInfo.processInfo.environment, timeout: timeout, + maximumOutputBytes: 128 * 1024, terminatesProcessGroup: true), + onLine: { _ in }) + return NetworkCommandResult( + status: result.terminationStatus, output: result.output) + } catch CLICommandRunnerError.timedOut { + return NetworkCommandResult(status: 124, output: "Timed out", timedOut: true) + } catch is CancellationError { + return NetworkCommandResult(status: 130, output: "Cancelled") + } catch { + return NetworkCommandResult(status: 127, output: error.localizedDescription) + } + } +} + +public struct NetworkDiagnosticsEngine: Sendable { + public typealias CommandExecutor = + @Sendable (URL, [String], Double) async -> NetworkCommandResult + + private let command: CommandExecutor + + public init( + command: @escaping CommandExecutor = { executable, arguments, timeout in + await NetworkProcessRunner.run( + executable: executable, arguments: arguments, timeout: timeout) + } + ) { + self.command = command + } + + public func diagnose( + configuration rawConfiguration: NetworkDiagnosticsConfiguration, + baseline: NetworkDiagnosticSnapshot? = nil + ) async -> NetworkDiagnosticSnapshot { + let started = Date() + let configuration = rawConfiguration.normalized + let route = await command( + URL(fileURLWithPath: "/sbin/route"), ["-n", "get", "default"], + configuration.timeoutSeconds) + let interfaceName = field("interface", in: route.output) + let gateway = field("gateway", in: route.output) + let address = await interfaceAddress( + interfaceName, timeout: configuration.timeoutSeconds) + let dnsResult = await command( + URL(fileURLWithPath: "/usr/sbin/scutil"), ["--dns"], + configuration.timeoutSeconds) + let proxyResult = await command( + URL(fileURLWithPath: "/usr/sbin/scutil"), ["--proxy"], + configuration.timeoutSeconds) + let vpnResult = await command( + URL(fileURLWithPath: "/usr/sbin/scutil"), ["--nc", "list"], + configuration.timeoutSeconds) + let dnsServers = parseDNSServers(dnsResult.output) + let wifi = wifiSummary(interfaceName: interfaceName) + var checks = [ + NetworkDiagnosticCheck( + id: "interface", title: "Current interface", + state: interfaceName == nil ? .failed : .healthy, + summary: interfaceName.map { "Active on \($0)" } ?? "No default interface found", + detail: address ?? ""), + NetworkDiagnosticCheck( + id: "route", title: "Default route", + state: gateway == nil ? .failed : .healthy, + summary: gateway.map { "Gateway \($0)" } ?? "No default gateway found"), + NetworkDiagnosticCheck( + id: "dns-resolvers", title: "DNS resolvers", + state: dnsServers.isEmpty ? .failed : .healthy, + summary: dnsServers.isEmpty + ? "No resolver addresses found" : "\(dnsServers.count) resolver address(es)"), + ] + if Task.isCancelled { return cancelledSnapshot(started: started, checks: checks) } + checks.append( + await dnsCheck(configuration.dnsName, configuration: configuration)) + if let gateway { + checks.append( + await pingCheck( + id: "gateway", title: "Gateway reachability", host: gateway, + configuration: configuration)) + } + if !configuration.targetHost.isEmpty { + checks.append( + configuration.excludes(configuration.targetHost) + ? excludedCheck( + id: "target", title: "Target reachability", + host: configuration.targetHost) + : await pingCheck( + id: "target", title: "Target reachability", + host: configuration.targetHost, configuration: configuration)) + } + let http = await httpCheck( + id: "http", title: "HTTP connectivity", target: configuration.httpTarget, + requiredScheme: "http", configuration: configuration) + if let check = http.check { checks.append(check) } + let https = await httpCheck( + id: "https", title: "HTTPS connectivity", target: configuration.httpsTarget, + requiredScheme: "https", configuration: configuration) + if let check = https.check { checks.append(check) } + if let original = http.originalHost, let final = http.finalHost { + checks.append( + NetworkDiagnosticCheck( + id: "captive-portal", title: "Captive portal hint", + state: original.caseInsensitiveCompare(final) == .orderedSame + ? .healthy : .warning, + summary: original.caseInsensitiveCompare(final) == .orderedSame + ? "No unexpected HTTP redirect detected" + : "HTTP probe was redirected to a different host", + detail: "\(original) to \(final)")) + } + for service in configuration.serviceTargets { + checks.append(await serviceCheck(service, configuration: configuration)) + } + let publicAddress = + configuration.publicIPEnabled + ? await publicAddress(timeout: configuration.timeoutSeconds) : nil + if configuration.publicIPEnabled { + checks.append( + NetworkDiagnosticCheck( + id: "public-ip", title: "Public IP lookup", + state: publicAddress == nil ? .warning : .healthy, + summary: publicAddress == nil + ? "Lookup did not return an address" : "Available", + detail: publicAddress ?? "")) + } + let path = NetworkPathSummary( + interfaceName: interfaceName, localAddress: address, gateway: gateway, + dnsServers: dnsServers, wifiName: wifi.name, wifiBSSID: wifi.bssid, + wifiRSSI: wifi.rssi, proxyHint: proxyHint(proxyResult.output), + vpnHint: vpnHint(vpnResult.output), publicAddress: publicAddress) + let state: NetworkDiagnosticState = + if checks.contains(where: { $0.state == .failed }) { + .failed + } else if checks.contains(where: { $0.state == .warning }) { + .warning + } else if checks.contains(where: { $0.state == .healthy }) { + .healthy + } else { + .skipped + } + return NetworkDiagnosticSnapshot( + durationMS: Date().timeIntervalSince(started) * 1000, state: state, path: path, + checks: checks + ).compared(with: baseline) + } + + private func interfaceAddress(_ name: String?, timeout: Double) async -> String? { + guard let name else { return nil } + let result = await command( + URL(fileURLWithPath: "/sbin/ifconfig"), [name], timeout) + return firstMatch(#"\binet\s+([^\s]+)"#, in: result.output) + } + + private func dnsCheck( + _ name: String, configuration: NetworkDiagnosticsConfiguration + ) async -> NetworkDiagnosticCheck { + guard !name.isEmpty else { + return NetworkDiagnosticCheck( + id: "dns-lookup", title: "DNS lookup timing", state: .skipped, + summary: "Add a DNS name to run this check") + } + guard !configuration.excludes(name) else { + return excludedCheck(id: "dns-lookup", title: "DNS lookup timing", host: name) + } + return await retried(configuration.retries) { + let started = Date() + let result = await command( + URL(fileURLWithPath: "/usr/bin/dscacheutil"), + ["-q", "host", "-a", "name", name], configuration.timeoutSeconds) + let elapsed = Date().timeIntervalSince(started) * 1000 + return NetworkDiagnosticCheck( + id: "dns-lookup", title: "DNS lookup timing", + state: result.status == 0 && result.output.contains("ip_address") + ? .healthy : .failed, + summary: result.status == 0 ? "Resolved \(name)" : "Could not resolve \(name)", + detail: result.timedOut ? "Timed out" : "", durationMS: elapsed) + } + } + + private func pingCheck( + id: String, title: String, host: String, + configuration: NetworkDiagnosticsConfiguration + ) async -> NetworkDiagnosticCheck { + await retried(configuration.retries) { + let result = await command( + URL(fileURLWithPath: "/sbin/ping"), + [ + "-n", "-c", String(configuration.pingCount), "-W", + String(Int(configuration.timeoutSeconds * 1000)), host, + ], configuration.timeoutSeconds * Double(configuration.pingCount) + 1) + let loss = firstMatch(#"([0-9.]+)% packet loss"#, in: result.output).flatMap( + Double.init) + let average = firstMatch( + #"(?:round-trip|round trip).* = [0-9.]+/([0-9.]+)/"#, + in: result.output + ).flatMap(Double.init) + let state: NetworkDiagnosticState = + if result.timedOut || loss == 100 { + .failed + } else if result.status != 0 || (loss ?? 0) > 0 { + .warning + } else { + .healthy + } + return NetworkDiagnosticCheck( + id: id, title: title, state: state, + summary: state == .healthy ? "Reachable" : "Reachability degraded", + detail: result.timedOut ? "Timed out" : host, durationMS: average, + packetLossPercent: loss) + } + } + + private func httpCheck( + id: String, title: String, target: String, requiredScheme: String, + configuration: NetworkDiagnosticsConfiguration + ) async -> (check: NetworkDiagnosticCheck?, originalHost: String?, finalHost: String?) { + guard !target.isEmpty else { return (nil, nil, nil) } + guard let url = URL(string: target), url.scheme?.lowercased() == requiredScheme, + let host = url.host + else { + return ( + NetworkDiagnosticCheck( + id: id, title: title, state: .failed, + summary: "Target must be a valid \(requiredScheme.uppercased()) URL"), + nil, nil + ) + } + guard !configuration.excludes(host) else { + return (excludedCheck(id: id, title: title, host: host), host, nil) + } + var last: NetworkDiagnosticCheck? + var finalHost: String? + for _ in 0...configuration.retries { + let started = Date() + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + request.timeoutInterval = configuration.timeoutSeconds + let sessionConfiguration = URLSessionConfiguration.ephemeral + sessionConfiguration.timeoutIntervalForRequest = configuration.timeoutSeconds + sessionConfiguration.timeoutIntervalForResource = configuration.timeoutSeconds + let session = URLSession(configuration: sessionConfiguration) + do { + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + finalHost = http.url?.host + let state: NetworkDiagnosticState = http.statusCode < 500 ? .healthy : .warning + last = NetworkDiagnosticCheck( + id: id, title: title, state: state, + summary: "HTTP \(http.statusCode)", detail: http.url?.absoluteString ?? target, + durationMS: Date().timeIntervalSince(started) * 1000) + if state == .healthy { break } + } catch { + last = NetworkDiagnosticCheck( + id: id, title: title, state: .failed, + summary: "Connection failed", detail: error.localizedDescription, + durationMS: Date().timeIntervalSince(started) * 1000) + } + session.invalidateAndCancel() + } + return (last, host, finalHost) + } + + private func serviceCheck( + _ service: NetworkServiceTarget, configuration: NetworkDiagnosticsConfiguration + ) async -> NetworkDiagnosticCheck { + let title = "Service \(service.host):\(service.port)" + guard !configuration.excludes(service.host) else { + return excludedCheck(id: "service-\(service.id)", title: title, host: service.host) + } + return await retried(configuration.retries) { + let started = Date() + let result = await command( + URL(fileURLWithPath: "/usr/bin/nc"), + [ + "-G", String(Int(configuration.timeoutSeconds)), "-z", service.host, + String(service.port), + ], configuration.timeoutSeconds + 1) + return NetworkDiagnosticCheck( + id: "service-\(service.id)", title: title, + state: result.status == 0 ? .healthy : .failed, + summary: result.status == 0 ? "Port accepted a connection" : "Port unavailable", + durationMS: Date().timeIntervalSince(started) * 1000) + } + } + + private func publicAddress(timeout: Double) async -> String? { + guard let url = URL(string: "https://api.ipify.org") else { return nil } + var request = URLRequest(url: url) + request.timeoutInterval = timeout + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + guard let (data, response) = try? await session.data(for: request), + (response as? HTTPURLResponse)?.statusCode == 200 + else { return nil } + let value = String(decoding: data.prefix(128), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private func retried( + _ retries: Int, operation: () async -> NetworkDiagnosticCheck + ) async -> NetworkDiagnosticCheck { + var result = await operation() + guard result.state == .failed else { return result } + for _ in 0.. String? { + firstMatch("(?m)^\\s*\(NSRegularExpression.escapedPattern(for: name)):\\s*(\\S+)", in: text) + } + + private func parseDNSServers(_ text: String) -> [String] { + guard + let expression = try? NSRegularExpression( + pattern: #"(?m)^\s*nameserver\[[0-9]+\]\s*:\s*(\S+)"#) + else { return [] } + var seen = Set() + return expression.matches( + in: text, range: NSRange(text.startIndex..., in: text) + ).compactMap { match in + guard let range = Range(match.range(at: 1), in: text) else { return nil } + let value = String(text[range]) + return seen.insert(value).inserted ? value : nil + } + } + + private func proxyHint(_ text: String) -> String? { + let enabled = ["HTTPEnable", "HTTPSEnable", "SOCKSEnable"].filter { + text.range(of: "\($0) : 1") != nil + }.map { $0.replacingOccurrences(of: "Enable", with: "") } + return enabled.isEmpty ? nil : enabled.joined(separator: ", ") + " configured" + } + + private func vpnHint(_ text: String) -> String? { + let lines = text.split(separator: "\n") + let configured = lines.filter { line in + ["(Connected)", "(Disconnected)", "(Connecting)", "(Disconnecting)"].contains { + state in line.contains(state) + } + }.count + let connected = lines.filter { $0.contains("(Connected)") }.count + guard configured > 0 || connected > 0 else { return nil } + return connected > 0 ? "\(connected) connected" : "\(configured) configured" + } + + private func wifiSummary(interfaceName: String?) -> (name: String?, bssid: String?, rssi: Int?) + { + guard let interface = CWWiFiClient.shared().interface(), + interfaceName == nil || interface.interfaceName == interfaceName + else { return (nil, nil, nil) } + return (interface.ssid(), interface.bssid(), interface.rssiValue()) + } + + private func firstMatch(_ pattern: String, in text: String) -> String? { + guard let expression = try? NSRegularExpression(pattern: pattern), + let match = expression.firstMatch( + in: text, range: NSRange(text.startIndex..., in: text)), + match.numberOfRanges > 1, let range = Range(match.range(at: 1), in: text) + else { return nil } + return String(text[range]) + } + + private func excludedCheck(id: String, title: String, host: String) -> NetworkDiagnosticCheck { + NetworkDiagnosticCheck( + id: id, title: title, state: .skipped, + summary: "Excluded by settings", detail: host) + } + + private func cancelledSnapshot( + started: Date, checks: [NetworkDiagnosticCheck] + ) -> NetworkDiagnosticSnapshot { + NetworkDiagnosticSnapshot( + durationMS: Date().timeIntervalSince(started) * 1000, state: .warning, + path: NetworkPathSummary(), + checks: checks + [ + NetworkDiagnosticCheck( + id: "cancelled", title: "Diagnostic run", state: .skipped, + summary: "Cancelled") + ]) + } +} diff --git a/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Services/NetworkDiagnosticsStore.swift b/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Services/NetworkDiagnosticsStore.swift new file mode 100644 index 000000000..b7800e071 --- /dev/null +++ b/Packages/Edith/Sources/EdithKit/Features/NetworkDiagnostics/Services/NetworkDiagnosticsStore.swift @@ -0,0 +1,37 @@ +import Foundation + +public enum NetworkDiagnosticsPreferences { + public static func configuration( + defaults: UserDefaults = SharedDefaults.store + ) -> NetworkDiagnosticsConfiguration { + guard let data = defaults.data(forKey: AppStorageKeys.NetworkDiagnostics.configuration), + let value = try? JSONDecoder().decode(NetworkDiagnosticsConfiguration.self, from: data) + else { return NetworkDiagnosticsConfiguration() } + return value.normalized + } + + public static func save( + _ configuration: NetworkDiagnosticsConfiguration, + defaults: UserDefaults = SharedDefaults.store + ) { + defaults.set( + try? JSONEncoder().encode(configuration.normalized), + forKey: AppStorageKeys.NetworkDiagnostics.configuration) + } + + public static func baseline( + defaults: UserDefaults = SharedDefaults.store + ) -> NetworkDiagnosticSnapshot? { + guard let data = defaults.data(forKey: AppStorageKeys.NetworkDiagnostics.baseline) + else { return nil } + return try? JSONDecoder().decode(NetworkDiagnosticSnapshot.self, from: data) + } + + public static func saveBaseline( + _ snapshot: NetworkDiagnosticSnapshot?, defaults: UserDefaults = SharedDefaults.store + ) { + defaults.set( + snapshot.flatMap { try? JSONEncoder().encode($0) }, + forKey: AppStorageKeys.NetworkDiagnostics.baseline) + } +} diff --git a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift index d8d395bf8..baacf1e87 100644 --- a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift @@ -15,7 +15,7 @@ import Testing ExtensionRegistry.entries.map(\.id) == [ "usage", "herdr", "quinjet", "companion", "plugins", "appMaintenance", "homebrew", "cleaner", - "system", "keepAwake", "lidAwake", "systemStats", "micMute", + "system", "networkDiagnostics", "keepAwake", "lidAwake", "systemStats", "micMute", "clipboard", "emoji", "colorPicker", "keystrokeHighlight", "focusDim", "presenter", "music", "downloads", "notchShelf", "audioMixer", "calendar", "database", "attention", "seoAudit", diff --git a/Packages/Edith/Tests/EdithTests/AgentRuntimeTests.swift b/Packages/Edith/Tests/EdithTests/AgentRuntimeTests.swift index 780395eb0..6cd1e91d8 100644 --- a/Packages/Edith/Tests/EdithTests/AgentRuntimeTests.swift +++ b/Packages/Edith/Tests/EdithTests/AgentRuntimeTests.swift @@ -58,7 +58,9 @@ import Testing UsageCollectionOperation.refresh.descriptor.id, UsageCollectionOperation.limitsRefresh.descriptor.id, ] - #expect(AgentOperationCatalog.served == control + usage) + #expect( + AgentOperationCatalog.served == control + usage + + NetworkDiagnosticOperation.allCases.map { $0.descriptor.id }) #expect(AgentOperationCatalog.serves(AgentControlOperation.jobs.descriptor.id)) #expect(AgentOperationCatalog.serves(UsageCollectionOperation.refresh.descriptor.id)) #expect(!AgentOperationCatalog.servesInternal("usage.refresh")) @@ -75,7 +77,9 @@ import Testing @Test func everyServedOperationIsInTheUserOperationCatalog() { #expect(AgentOperationCatalog.descriptors.count == AgentOperationCatalog.served.count) for descriptor in AgentOperationCatalog.descriptors { - #expect(descriptor.cli.first == "agent" || descriptor.cli.first == "usage") + #expect( + descriptor.cli.first == "agent" || descriptor.cli.first == "usage" + || descriptor.cli.first == "network") } } diff --git a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift index d5d848bf8..4d1594b79 100644 --- a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift @@ -626,6 +626,9 @@ enum JSONContract { mutatesTheMachine: true), JSONCase("ed system stats", ["system", "stats", "--json"]), JSONCase("ed system disks", ["system", "disks", "--json"]), + JSONCase( + "ed network diagnose", ["network", "diagnose", "--json", "--no-history"]), + JSONCase("ed network baseline", ["network", "baseline", "--json"]), JSONCase("ed music status", ["music", "status", "--json"]), JSONCase("ed music players", ["music", "players", "--json"]), JSONCase("ed music play", ["music", "play", "--json"]), diff --git a/Packages/Edith/Tests/EdithTests/CLIHarness.swift b/Packages/Edith/Tests/EdithTests/CLIHarness.swift index bb00ba570..fc6d9bc75 100644 --- a/Packages/Edith/Tests/EdithTests/CLIHarness.swift +++ b/Packages/Edith/Tests/EdithTests/CLIHarness.swift @@ -70,6 +70,8 @@ enum CLIProcessProbeError: Error, Equatable, LocalizedError { } } +private final class CLIProbeBundleMarker: NSObject {} + enum CLIProcessProbe { static let defaultTimeout: TimeInterval = 15 private static let terminationGrace: TimeInterval = 2 @@ -80,7 +82,11 @@ enum CLIProcessProbe { .deletingLastPathComponent() static var binary: URL { - packageRoot.appendingPathComponent(".build/debug/ed") + if let path = ProcessInfo.processInfo.environment["EDITH_TEST_CLI_EXECUTABLE"] { + return URL(fileURLWithPath: path) + } + return Bundle(for: CLIProbeBundleMarker.self).bundleURL + .deletingLastPathComponent().appendingPathComponent("ed") } static func run( diff --git a/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift b/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift index 55dabb8bb..9734bb365 100644 --- a/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift @@ -218,7 +218,7 @@ enum CommandCrawler { "ed machines exec", "ed machines docker shell", "ed machines docker logs", "ed machines docker inspect", "ed machines files", "ed machines docker", "ed config", "ed extensions", - "ed permissions", "ed usage", "ed system", "ed music", "ed calendar", + "ed permissions", "ed usage", "ed system", "ed network", "ed music", "ed calendar", "ed presenter", "ed herdr", "ed herdr bridge", "ed machines", "ed __complete", "ed app", "ed clipboard", "ed color", "ed emoji", diff --git a/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift index 25a5fce67..ddf3c2ab8 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift @@ -53,6 +53,9 @@ import EdithDatabase id: "system", helper: true, machine: false, toolRule: .all, adapter: true, requiredTools: [], optionalTools: []), + MatrixRow( + id: "networkDiagnostics", helper: false, machine: false, toolRule: .all, adapter: true, + requiredTools: [], optionalTools: []), MatrixRow( id: "keepAwake", helper: true, machine: false, toolRule: .all, adapter: true, diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift index 8f9c05ea5..de670abdb 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift @@ -16,6 +16,7 @@ import Testing "homebrewEnabled", "cleanerEnabled", "tabSystemEnabled", + "tabNetworkDiagnosticsEnabled", "keepAwakeEnabled", "lidAwakeEnabled", "menuBarSystemStats", @@ -46,7 +47,7 @@ import Testing ExtensionRegistry.entries.map(\.id) == [ "usage", "herdr", "quinjet", "companion", "plugins", "appMaintenance", "homebrew", "cleaner", - "system", "keepAwake", "lidAwake", "systemStats", "micMute", + "system", "networkDiagnostics", "keepAwake", "lidAwake", "systemStats", "micMute", "clipboard", "emoji", "colorPicker", "keystrokeHighlight", "focusDim", "presenter", "music", "downloads", "notchShelf", "audioMixer", "calendar", "database", "attention", "seoAudit", @@ -201,7 +202,8 @@ import Testing ExtensionRegistry.entries.filter(\.featured).map(\.id)) #expect( featuredIdentifiers == [ - "usage", "herdr", "quinjet", "appMaintenance", "system", "keepAwake", "clipboard", + "usage", "herdr", "quinjet", "appMaintenance", "system", "networkDiagnostics", + "keepAwake", "clipboard", "keystrokeHighlight", "notchShelf", "database", "attention", ]) } @@ -327,6 +329,7 @@ import Testing "usage": [], "herdr": [], "quinjet": [], + "networkDiagnostics": [], "companion": [], "plugins": [], "appMaintenance": [], @@ -356,6 +359,7 @@ import Testing "usage": [.notifications], "herdr": [], "quinjet": [], + "networkDiagnostics": [.notifications], "companion": [], "plugins": [], "appMaintenance": [.notifications], diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift index 41812fc37..e1c8447b0 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift @@ -255,6 +255,10 @@ import Testing ("quinjet", "QuinjetRows", "enabled", "ExtensionsPane.swift"), ("seoAudit", "SEOAuditRows", "enabled", "ExtensionsPane.swift"), ("system", "SystemRows", "enabled", "ExtensionsPane.swift"), + ( + "networkDiagnostics", "NetworkDiagnosticsRows", "enabled", + "ExtensionsPane.swift" + ), ("keepAwake", "KeepAwakeRows", "enabled", "ExtensionsPane.swift"), ("appMaintenance", "AppMaintenanceRows", "enabled", "ExtensionsPane.swift"), ("database", "DatabaseRows", "enabled", "ExtensionsPane.swift"), diff --git a/Packages/Edith/Tests/EdithTests/MainDestinationTests.swift b/Packages/Edith/Tests/EdithTests/MainDestinationTests.swift index 347e7b51f..dffe5f439 100644 --- a/Packages/Edith/Tests/EdithTests/MainDestinationTests.swift +++ b/Packages/Edith/Tests/EdithTests/MainDestinationTests.swift @@ -63,7 +63,7 @@ import Testing .home, .machines, .agents, .dashboard, .herdr, .quinjet, .companion, .plugins, .appMaintenance, - .system, .runningApps, + .system, .network, .runningApps, .desk, .media, .music, .calendar, .data, .database, .attention, .seoAudit, diff --git a/Packages/Edith/Tests/EdithTests/NetworkDiagnosticsRenderTests.swift b/Packages/Edith/Tests/EdithTests/NetworkDiagnosticsRenderTests.swift new file mode 100644 index 000000000..8d9a90932 --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/NetworkDiagnosticsRenderTests.swift @@ -0,0 +1,38 @@ +import AppKit +import SwiftUI +import Testing + +@testable import EdithHelper +@testable import EdithKit + +@Suite @MainActor struct NetworkDiagnosticsRenderTests { + @Test func successfulSnapshotRendersInTheMenuPanel() throws { + let snapshot = NetworkDiagnosticSnapshot( + createdAt: Date(timeIntervalSince1970: 1_783_080_000), durationMS: 124, + state: .healthy, path: NetworkPathSummary(), + checks: [ + NetworkDiagnosticCheck( + id: "route", title: "Route", state: .healthy, summary: "Available"), + NetworkDiagnosticCheck( + id: "dns", title: "DNS", state: .healthy, summary: "Resolved"), + NetworkDiagnosticCheck( + id: "gateway", title: "Gateway", state: .healthy, summary: "Reachable"), + ]) + let hosting = NSHostingView( + rootView: + NetworkDiagnosticsPanel(snapshot: snapshot, openWorkspace: {}) + .padding(20).frame(width: 460, height: 245) + .background(Color(nsColor: .windowBackgroundColor))) + hosting.frame = NSRect(x: 0, y: 0, width: 460, height: 245) + hosting.layoutSubtreeIfNeeded() + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + #expect(bitmap.pixelsWide >= 460) + if let directory = ProcessInfo.processInfo.environment["EDITH_RENDER_DUMP"] { + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write( + to: URL(fileURLWithPath: directory).appendingPathComponent( + "network-diagnostics.png")) + } + } +} diff --git a/Packages/Edith/Tests/EdithTests/NetworkDiagnosticsTests.swift b/Packages/Edith/Tests/EdithTests/NetworkDiagnosticsTests.swift new file mode 100644 index 000000000..3e65b7e12 --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/NetworkDiagnosticsTests.swift @@ -0,0 +1,242 @@ +import Foundation +import GRDB +import Testing + +@testable import EdithKit +@testable import EdithAgent +@testable import EdithCLI + +@Suite struct NetworkDiagnosticsTests { + @Test func configurationClampsAndFiltersUnsafeValues() { + let configuration = NetworkDiagnosticsConfiguration( + targetHost: " example.com ", + serviceTargets: [ + NetworkServiceTarget(host: "example.com", port: 443), + NetworkServiceTarget(host: "", port: 0), + ], exclusions: [" Internal.Example "], sampleIntervalMinutes: 1, + timeoutSeconds: 100, retries: 10, pingCount: 0, timelineLimit: 2 + ).normalized + + #expect(configuration.targetHost == "example.com") + #expect(configuration.serviceTargets.count == 1) + #expect(configuration.exclusions == ["internal.example"]) + #expect(configuration.sampleIntervalMinutes == 5) + #expect(configuration.timeoutSeconds == 30) + #expect(configuration.retries == 3) + #expect(configuration.pingCount == 1) + #expect(configuration.timelineLimit == 10) + #expect(configuration.excludes("api.internal.example")) + } + + @Test func reportsRedactAddressesCredentialsQueriesAndSecrets() { + let snapshot = NetworkDiagnosticSnapshot( + durationMS: 10, state: .healthy, + path: NetworkPathSummary( + interfaceName: "en0", localAddress: "192.168.1.24", gateway: "192.168.1.1", + dnsServers: ["1.1.1.1"], wifiName: "Private Home", wifiBSSID: "aa:bb:cc:dd:ee:ff", + publicAddress: "203.0.113.10"), + checks: [ + NetworkDiagnosticCheck( + id: "web", title: "HTTPS", state: .healthy, summary: "Connected", + detail: "https://user:pass@example.com/path?token=hello api_key=world") + ]) + let report = NetworkDiagnosticsRedactor.report(snapshot) + + #expect(!report.contains("192.168")) + #expect(!report.contains("1.1.1.1")) + #expect(!report.contains("Private Home")) + #expect(!report.contains("pass")) + #expect(!report.contains("hello")) + #expect(!report.contains("world")) + #expect(report.contains("
")) + #expect(report.contains("")) + } + + @Test func redactionPreservesTimestampsAndRedactsCompressedIPv6() { + let text = "captured 2026-08-29T21:41:42Z from 2001:db8::1 and ::1" + let redacted = NetworkDiagnosticsRedactor.redact(text) + + #expect(redacted.contains("2026-08-29T21:41:42Z")) + #expect(!redacted.contains("2001:db8::1")) + #expect(!redacted.contains("::1")) + } + + @Test func baselineComparisonExplainsStateAndLatencyChanges() { + let baseline = snapshot( + check: NetworkDiagnosticCheck( + id: "dns", title: "DNS", state: .healthy, summary: "Resolved", durationMS: 20)) + let current = snapshot( + check: NetworkDiagnosticCheck( + id: "dns", title: "DNS", state: .warning, summary: "Slow", durationMS: 120) + ) + .compared(with: baseline) + + #expect(current.baselineChanges == ["DNS: healthy to warning"]) + } + + @Test func migrationPreservesTheExistingDaemonStore() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("edith.sqlite") + let previous = try DatabaseQueue(path: url.path) + try AgentSchema.migrator.migrate(previous, upTo: "0004-attention-delivery-receipts") + try previous.write { database in + try database.execute(sql: "PRAGMA user_version = 4") + try database.execute( + sql: "INSERT INTO attention_delivery_receipt VALUES ('fixture', 7, ?)", + arguments: [Date()]) + } + try previous.close() + let store = try AgentStore(url: url, build: "network") + defer { try? store.close() } + #expect(store.schemaVersion == AgentSchema.version) + #expect( + try store.read { + try Int.fetchOne( + $0, + sql: + "SELECT lastSequence FROM attention_delivery_receipt WHERE producerID = 'fixture'" + ) + } == 7) + #expect( + try store.read { try Int.fetchOne($0, sql: "SELECT COUNT(*) FROM network_diagnostic") } + == 0) + #expect( + FileManager.default.fileExists( + atPath: AgentStoreLayout.backupURL(root: directory, build: "network").path)) + } + + @Test func timelineRetentionKeepsNewestBoundedSnapshots() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = try AgentStore( + url: directory.appendingPathComponent("edith.sqlite"), build: "test") + defer { try? store.close() } + let engine = NetworkDiagnosticsEngine { _, _, _ in + NetworkCommandResult(status: 0, output: "") + } + let service = NetworkDiagnosticsService(store: store, engine: engine) + var configuration = NetworkDiagnosticsConfiguration() + configuration.timelineLimit = 10 + for _ in 0..<15 { + _ = try await service.diagnose( + NetworkDiagnosticRequest( + configuration: configuration, keepHistory: true, saveBaseline: false)) + } + try store.write { database in + try database.execute( + sql: "UPDATE network_diagnostic SET capturedAt = ?", + arguments: [Date(timeIntervalSince1970: 0)]) + } + let loaded = try AgentPayload.decode( + [NetworkDiagnosticSnapshot].self, from: await service.timeline(limit: 100)) + #expect(loaded.count == 10) + #expect(Set(loaded.map(\.id)).count == 10) + #expect(loaded.first!.createdAt >= loaded.last!.createdAt) + } + + @Test func cancellingTheDaemonJobStopsItsProcessAndSkipsHistory() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = try AgentStore( + url: directory.appendingPathComponent("edith.sqlite"), build: "test") + defer { try? store.close() } + let engine = NetworkDiagnosticsEngine { _, _, _ in + await NetworkProcessRunner.run( + executable: URL(fileURLWithPath: "/bin/sleep"), arguments: ["2"], timeout: 5) + } + let service = NetworkDiagnosticsService(store: store, engine: engine) + let started = Date() + let run = Task { + try await service.diagnose( + NetworkDiagnosticRequest( + configuration: .init(), keepHistory: true, saveBaseline: false)) + } + try await Task.sleep(for: .milliseconds(100)) + run.cancel() + await #expect(throws: CancellationError.self) { try await run.value } + #expect(Date().timeIntervalSince(started) < 1) + let history = try AgentPayload.decode( + [NetworkDiagnosticSnapshot].self, from: await service.timeline(limit: 10)) + #expect(history.isEmpty) + } + + @Test func engineExplainsLocalPathWithoutRemoteTargets() async { + let engine = NetworkDiagnosticsEngine { executable, arguments, _ in + switch (executable.lastPathComponent, arguments) { + case ("route", _): + NetworkCommandResult( + status: 0, + output: "gateway: 192.168.1.1\ninterface: en0\n") + case ("ifconfig", _): + NetworkCommandResult(status: 0, output: "inet 192.168.1.24 netmask 0xffffff00") + case ("scutil", ["--dns"]): + NetworkCommandResult(status: 0, output: "nameserver[0] : 1.1.1.1") + case ("scutil", ["--proxy"]), ("scutil", ["--nc", "list"]): + NetworkCommandResult(status: 0, output: "") + case ("ping", _): + NetworkCommandResult( + status: 0, + output: + "4 packets transmitted, 4 packets received, 0.0% packet loss\nround-trip min/avg/max/stddev = 1.0/2.0/3.0/0.5 ms" + ) + default: + NetworkCommandResult(status: 1, output: "unexpected") + } + } + let result = await engine.diagnose(configuration: NetworkDiagnosticsConfiguration()) + + #expect(result.state == .healthy) + #expect(result.path.interfaceName == "en0") + #expect(result.path.gateway == "192.168.1.1") + #expect(result.path.dnsServers == ["1.1.1.1"]) + #expect(result.checks.first { $0.id == "gateway" }?.packetLossPercent == 0) + #expect(result.checks.first { $0.id == "dns-lookup" }?.state == .skipped) + } + + @Test func processRunnerStopsAtTimeout() async { + let started = Date() + let result = await NetworkProcessRunner.run( + executable: URL(fileURLWithPath: "/bin/sleep"), arguments: ["2"], timeout: 0.05) + + #expect(result.timedOut) + #expect(Date().timeIntervalSince(started) < 1) + } + + @Test func completedFailedDiagnosisReturnsSnapshotAndSuccess() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = try AgentStore( + url: directory.appendingPathComponent("edith.sqlite"), build: "test") + defer { try? store.close() } + let engine = NetworkDiagnosticsEngine { _, _, _ in + NetworkCommandResult(status: 1, output: "connection refused") + } + let service = NetworkDiagnosticsService(store: store, engine: engine) + let runtime = AgentRuntime(build: "test", store: store) + await service.register(on: runtime) + let listener = AgentRuntimeTestListener(runtime: runtime) + defer { listener.stop() } + await CLIProbe.inWorld { _ in + let original = CLIEnvironment.networkClient + CLIEnvironment.networkClient = listener.client() + defer { CLIEnvironment.networkClient = original } + let result = await CLIProbe.capture([ + "network", "diagnose", "--service", "127.0.0.1:1", "--timeout", "0.2", "--retries", + "0", "--count", "1", "--json", "--no-history", + ]) + #expect(result.code == 0) + #expect(result.object?["state"] as? String == "failed") + } + } + + private func snapshot(check: NetworkDiagnosticCheck) -> NetworkDiagnosticSnapshot { + NetworkDiagnosticSnapshot( + durationMS: 1, state: check.state, path: NetworkPathSummary(), checks: [check]) + } +} diff --git a/Packages/Edith/Tests/EdithTests/TabOrderTests.swift b/Packages/Edith/Tests/EdithTests/TabOrderTests.swift index c87ebdf60..b7567b2cb 100644 --- a/Packages/Edith/Tests/EdithTests/TabOrderTests.swift +++ b/Packages/Edith/Tests/EdithTests/TabOrderTests.swift @@ -3,16 +3,20 @@ import Testing @Suite struct TabOrderTests { @Test func appendsTabsMissingFromSavedOrder() { - #expect(orderedTabIDs("usage,music,system") == ["usage", "music", "system", "calendar"]) + #expect( + orderedTabIDs("usage,music,system") + == ["usage", "music", "system", "network", "calendar"]) } @Test func preservesACustomOrder() { let order = "calendar,system,music,usage" - #expect(orderedTabIDs(order) == ["calendar", "system", "music", "usage"]) + #expect(orderedTabIDs(order) == ["calendar", "system", "music", "usage", "network"]) } @Test func dropsUnknownIDs() { - #expect(orderedTabIDs("usage,bogus,music") == ["usage", "music", "system", "calendar"]) + #expect( + orderedTabIDs("usage,bogus,music") + == ["usage", "music", "system", "network", "calendar"]) } @Test func emptyStringYieldsAllTabsInDefaultOrder() { diff --git a/docs/cli/README.md b/docs/cli/README.md index 03b326e08..ad44bb290 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -55,6 +55,7 @@ report still exits 0, so read `verified`, `state.phase`, `state.runtimePhase`, | [`ed permissions`](./permissions/README.md) | Inspecting and requesting Edith's macOS permissions | | [`ed usage`](./usage/README.md) | Agent usage: limits, cost, tokens, projects, sources, and machine attribution | | [`ed system`](./system/README.md) | CPU, memory, load, network and mounted volumes | +| [`ed network`](./network/README.md) | Read-only route, DNS, reachability, web and service diagnostics | | [`ed music`](./music/README.md) | Playback control and the local music library | | [`ed calendar`](./calendar/README.md) | Your agenda | | [`ed presenter`](./presenter/README.md) | Manual presenter mode at runtime | diff --git a/docs/cli/network/README.md b/docs/cli/network/README.md new file mode 100644 index 000000000..839adca05 --- /dev/null +++ b/docs/cli/network/README.md @@ -0,0 +1,35 @@ +# Network Diagnostics + +Network Diagnostics is a read-only troubleshooting workspace for the current +Mac. It inspects the active interface, default route, DNS resolvers, Wi-Fi +metadata available to macOS, proxy and VPN configuration hints, and only the +remote targets you explicitly configure. + +Run a local snapshot: + +```sh +ed network diagnose +ed network diagnose --json +``` + +Add explicit probes when needed: + +```sh +ed network diagnose --target example.com --dns example.com +ed network diagnose --https https://example.com --service example.com:443 +``` + +Read the saved baseline with `ed network baseline`. Save a new one only from a +healthy run with `ed network diagnose --save-baseline`. + +Public IP lookup is off by default. Enable it for one CLI run with `--public-ip`, +or use the workspace setting. Reports redact IP addresses, MAC addresses, URL +credentials, URL queries, and common secret fields before copy or export. + +The extension never changes DNS, routes, proxies, VPNs, Wi-Fi, or network +services. Scheduled sampling is off by default, uses a minimum five-minute +interval, and pauses while the Mac is locked. The background agent owns scans +and bounded SQLite history, so scheduled checks continue after the window +closes. Disable the extension or scheduled sampling to stop future checks. + +[The `ed` command line](../README.md) covers the rest of the reference.