diff --git a/Sources/Spook/App/AppDelegate.swift b/Sources/Spook/App/AppDelegate.swift index f31585e..97dfce5 100644 --- a/Sources/Spook/App/AppDelegate.swift +++ b/Sources/Spook/App/AppDelegate.swift @@ -35,6 +35,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { detailPanel?.onOpenSettings = { [weak self] in self?.openSettings() } + detailPanel?.onVisibilityChanged = { [weak self] visible in + self?.networkMonitor?.isPanelVisible = visible + } + } + + func applicationWillTerminate(_ notification: Notification) { + networkMonitor?.stopMonitoring() + + // ponytail: brief blocking wait so the final history flush lands before the process exits + let semaphore = DispatchSemaphore(value: 0) + Task.detached { + await HistoryStore.shared.flush() + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 2) } private func setupClickOutsideMonitor() { @@ -109,6 +124,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } panel.makeKeyAndOrderFront(nil) + panel.onVisibilityChanged?(true) } } @@ -160,6 +176,13 @@ class DetailPanel: NSPanel { var isPinned: Bool = false private var monitor: NetworkMonitor var onOpenSettings: (() -> Void)? + /// Fires true when shown, false when closed — drives NetworkMonitor.isPanelVisible so lsof only runs while visible. + var onVisibilityChanged: ((Bool) -> Void)? + + override func close() { + super.close() + onVisibilityChanged?(false) + } init(monitor: NetworkMonitor) { self.monitor = monitor diff --git a/Sources/Spook/Models/AppTraffic.swift b/Sources/Spook/Models/AppTraffic.swift index bf461cc..4087c68 100644 --- a/Sources/Spook/Models/AppTraffic.swift +++ b/Sources/Spook/Models/AppTraffic.swift @@ -221,7 +221,9 @@ struct AppTraffic: Identifiable { } struct Connection: Identifiable { - let id = UUID() + // ponytail: stable id from connection tuple instead of UUID() so SwiftUI rows don't + // rebuild (and DNS .task doesn't refire) every tick when the connection is unchanged. + var id: String { "\(remoteAddress):\(remotePort):\(protocolType)" } let remoteAddress: String let remotePort: UInt16 let localPort: UInt16 diff --git a/Sources/Spook/Services/HistoryStore.swift b/Sources/Spook/Services/HistoryStore.swift index b266397..d4675bc 100644 --- a/Sources/Spook/Services/HistoryStore.swift +++ b/Sources/Spook/Services/HistoryStore.swift @@ -7,7 +7,19 @@ actor HistoryStore { private var db: OpaquePointer? private let dbPath: String + // Cached formatter — avoid allocating one per write. ponytail: also avoids repeated Calendar work below where cheap. + private let dayFormatter: DateFormatter + + // In-memory accumulators, flushed to disk periodically (see flush()). + private var pendingDailyTotals: [String: (bytesIn: Int64, bytesOut: Int64)] = [:] + private var pendingHourlySamples: [Int: (bytesIn: Int64, bytesOut: Int64)] = [:] + private var pendingAppStats: [String: (date: String, processName: String, displayName: String, bytesIn: Int64, bytesOut: Int64)] = [:] + init() { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + dayFormatter = formatter + // Store in Application Support let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let spookDir = appSupport.appendingPathComponent("Spook", isDirectory: true) @@ -22,6 +34,15 @@ actor HistoryStore { print("Failed to open database at \(dbPath)") } + // Enable WAL so concurrent readers don't block on the periodic write transaction. + var errMsg: UnsafeMutablePointer? + if sqlite3_exec(db, "PRAGMA journal_mode=WAL;", nil, nil, &errMsg) != SQLITE_OK { + if let errMsg = errMsg { + print("SQL error: \(String(cString: errMsg))") + sqlite3_free(errMsg) + } + } + // Create tables synchronously let createStatements = [ """ @@ -61,57 +82,6 @@ actor HistoryStore { } } - private nonisolated func openDatabaseSync(_ path: String) -> OpaquePointer? { - var database: OpaquePointer? - if sqlite3_open(path, &database) != SQLITE_OK { - print("Failed to open database at \(path)") - return nil - } - return database - } - - private func openDatabase() { - if sqlite3_open(dbPath, &db) != SQLITE_OK { - print("Failed to open database at \(dbPath)") - } - } - - private func createTables() { - // Daily totals table - let createDailyTotals = """ - CREATE TABLE IF NOT EXISTS daily_totals ( - date TEXT PRIMARY KEY, - bytes_in INTEGER DEFAULT 0, - bytes_out INTEGER DEFAULT 0 - ); - """ - - // Per-app daily stats - let createAppStats = """ - CREATE TABLE IF NOT EXISTS app_daily_stats ( - date TEXT, - process_name TEXT, - display_name TEXT, - bytes_in INTEGER DEFAULT 0, - bytes_out INTEGER DEFAULT 0, - PRIMARY KEY (date, process_name) - ); - """ - - // Hourly samples for graphs (last 24 hours) - let createHourlySamples = """ - CREATE TABLE IF NOT EXISTS hourly_samples ( - timestamp INTEGER PRIMARY KEY, - bytes_in INTEGER DEFAULT 0, - bytes_out INTEGER DEFAULT 0 - ); - """ - - execute(createDailyTotals) - execute(createAppStats) - execute(createHourlySamples) - } - private func execute(_ sql: String) { var errMsg: UnsafeMutablePointer? if sqlite3_exec(db, sql, nil, nil, &errMsg) != SQLITE_OK { @@ -122,98 +92,122 @@ actor HistoryStore { } } - // MARK: - Recording Data + // MARK: - Recording Data (in-memory only — see flush()) func recordTotals(bytesIn: Int64, bytesOut: Int64) { let today = dateString(Date()) - - let sql = """ - INSERT INTO daily_totals (date, bytes_in, bytes_out) - VALUES (?, ?, ?) - ON CONFLICT(date) DO UPDATE SET - bytes_in = bytes_in + excluded.bytes_in, - bytes_out = bytes_out + excluded.bytes_out; - """ - - var stmt: OpaquePointer? - if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK { - sqlite3_bind_text(stmt, 1, today, -1, nil) - sqlite3_bind_int64(stmt, 2, bytesIn) - sqlite3_bind_int64(stmt, 3, bytesOut) - sqlite3_step(stmt) - } - sqlite3_finalize(stmt) + let existing = pendingDailyTotals[today] ?? (0, 0) + pendingDailyTotals[today] = (existing.bytesIn + bytesIn, existing.bytesOut + bytesOut) } func recordAppStats(_ apps: [AppTraffic]) { let today = dateString(Date()) - execute("BEGIN TRANSACTION;") - - let sql = """ - INSERT INTO app_daily_stats (date, process_name, display_name, bytes_in, bytes_out) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(date, process_name) DO UPDATE SET - display_name = excluded.display_name, - bytes_in = bytes_in + excluded.bytes_in, - bytes_out = bytes_out + excluded.bytes_out; - """ - - var stmt: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { - execute("ROLLBACK;") - return - } - for app in apps { let deltaIn = app.bytesIn - app.previousBytesIn let deltaOut = app.bytesOut - app.previousBytesOut guard deltaIn > 0 || deltaOut > 0 else { continue } - sqlite3_reset(stmt) - sqlite3_clear_bindings(stmt) - - sqlite3_bind_text(stmt, 1, today, -1, nil) - sqlite3_bind_text(stmt, 2, app.processName, -1, nil) - sqlite3_bind_text(stmt, 3, app.displayName, -1, nil) - sqlite3_bind_int64(stmt, 4, deltaIn) - sqlite3_bind_int64(stmt, 5, deltaOut) - if sqlite3_step(stmt) != SQLITE_DONE { - sqlite3_finalize(stmt) - execute("ROLLBACK;") - return + let key = "\(today)|\(app.processName)" + if let existing = pendingAppStats[key] { + pendingAppStats[key] = (today, app.processName, app.displayName, existing.bytesIn + deltaIn, existing.bytesOut + deltaOut) + } else { + pendingAppStats[key] = (today, app.processName, app.displayName, deltaIn, deltaOut) } } - - sqlite3_finalize(stmt) - execute("COMMIT;") } func recordHourlySample(bytesIn: Int64, bytesOut: Int64) { let hour = hourTimestamp(Date()) + let existing = pendingHourlySamples[hour] ?? (0, 0) + pendingHourlySamples[hour] = (existing.bytesIn + bytesIn, existing.bytesOut + bytesOut) + } - let sql = """ + // MARK: - Flushing + + /// Writes all pending in-memory data to disk in a single transaction and clears the accumulators. + func flush() { + guard !pendingDailyTotals.isEmpty || !pendingHourlySamples.isEmpty || !pendingAppStats.isEmpty else { + return + } + + execute("BEGIN TRANSACTION;") + + let dailyTotalsSql = """ + INSERT INTO daily_totals (date, bytes_in, bytes_out) + VALUES (?, ?, ?) + ON CONFLICT(date) DO UPDATE SET + bytes_in = bytes_in + excluded.bytes_in, + bytes_out = bytes_out + excluded.bytes_out; + """ + var dailyStmt: OpaquePointer? + if sqlite3_prepare_v2(db, dailyTotalsSql, -1, &dailyStmt, nil) == SQLITE_OK { + for (date, totals) in pendingDailyTotals { + sqlite3_reset(dailyStmt) + sqlite3_clear_bindings(dailyStmt) + sqlite3_bind_text(dailyStmt, 1, date, -1, nil) + sqlite3_bind_int64(dailyStmt, 2, totals.bytesIn) + sqlite3_bind_int64(dailyStmt, 3, totals.bytesOut) + sqlite3_step(dailyStmt) + } + } + sqlite3_finalize(dailyStmt) + + let hourlySql = """ INSERT INTO hourly_samples (timestamp, bytes_in, bytes_out) VALUES (?, ?, ?) ON CONFLICT(timestamp) DO UPDATE SET bytes_in = bytes_in + excluded.bytes_in, bytes_out = bytes_out + excluded.bytes_out; """ + var hourlyStmt: OpaquePointer? + if sqlite3_prepare_v2(db, hourlySql, -1, &hourlyStmt, nil) == SQLITE_OK { + for (timestamp, totals) in pendingHourlySamples { + sqlite3_reset(hourlyStmt) + sqlite3_clear_bindings(hourlyStmt) + sqlite3_bind_int64(hourlyStmt, 1, Int64(timestamp)) + sqlite3_bind_int64(hourlyStmt, 2, totals.bytesIn) + sqlite3_bind_int64(hourlyStmt, 3, totals.bytesOut) + sqlite3_step(hourlyStmt) + } + } + sqlite3_finalize(hourlyStmt) - var stmt: OpaquePointer? - if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK { - sqlite3_bind_int64(stmt, 1, Int64(hour)) - sqlite3_bind_int64(stmt, 2, bytesIn) - sqlite3_bind_int64(stmt, 3, bytesOut) - sqlite3_step(stmt) + let appStatsSql = """ + INSERT INTO app_daily_stats (date, process_name, display_name, bytes_in, bytes_out) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(date, process_name) DO UPDATE SET + display_name = excluded.display_name, + bytes_in = bytes_in + excluded.bytes_in, + bytes_out = bytes_out + excluded.bytes_out; + """ + var appStmt: OpaquePointer? + if sqlite3_prepare_v2(db, appStatsSql, -1, &appStmt, nil) == SQLITE_OK { + for (_, entry) in pendingAppStats { + sqlite3_reset(appStmt) + sqlite3_clear_bindings(appStmt) + sqlite3_bind_text(appStmt, 1, entry.date, -1, nil) + sqlite3_bind_text(appStmt, 2, entry.processName, -1, nil) + sqlite3_bind_text(appStmt, 3, entry.displayName, -1, nil) + sqlite3_bind_int64(appStmt, 4, entry.bytesIn) + sqlite3_bind_int64(appStmt, 5, entry.bytesOut) + sqlite3_step(appStmt) + } } - sqlite3_finalize(stmt) + sqlite3_finalize(appStmt) + + execute("COMMIT;") + + pendingDailyTotals.removeAll() + pendingHourlySamples.removeAll() + pendingAppStats.removeAll() } // MARK: - Querying Data func getDailyTotals(for date: Date) -> (bytesIn: Int64, bytesOut: Int64) { + flush() let dateStr = dateString(date) let sql = "SELECT bytes_in, bytes_out FROM daily_totals WHERE date = ?;" @@ -235,6 +229,7 @@ actor HistoryStore { } func getWeeklyTotals() -> (bytesIn: Int64, bytesOut: Int64) { + flush() let weekAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date())! let weekAgoStr = dateString(weekAgo) @@ -257,6 +252,7 @@ actor HistoryStore { } func getHourlySamples(hours: Int = 24) -> [(timestamp: Date, bytesIn: Int64, bytesOut: Int64)] { + flush() let cutoff = hourTimestamp(Date()) - (hours * 3600) let sql = """ @@ -285,6 +281,7 @@ actor HistoryStore { } func getTopApps(for date: Date, limit: Int = 10) -> [(processName: String, displayName: String, bytesIn: Int64, bytesOut: Int64)] { + flush() let dateStr = dateString(date) let sql = """ @@ -318,6 +315,7 @@ actor HistoryStore { // MARK: - Maintenance func pruneOldData(daysToKeep: Int = 30) { + flush() let cutoff = Calendar.current.date(byAdding: .day, value: -daysToKeep, to: Date())! let cutoffStr = dateString(cutoff) @@ -345,6 +343,9 @@ actor HistoryStore { } func clearAllHistory() { + pendingDailyTotals.removeAll() + pendingHourlySamples.removeAll() + pendingAppStats.removeAll() execute("DELETE FROM daily_totals;") execute("DELETE FROM app_daily_stats;") execute("DELETE FROM hourly_samples;") @@ -353,9 +354,7 @@ actor HistoryStore { // MARK: - Helpers private func dateString(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter.string(from: date) + dayFormatter.string(from: date) } private func hourTimestamp(_ date: Date) -> Int { diff --git a/Sources/Spook/Services/NetworkMonitor.swift b/Sources/Spook/Services/NetworkMonitor.swift index 82a6a79..cca5ccd 100644 --- a/Sources/Spook/Services/NetworkMonitor.swift +++ b/Sources/Spook/Services/NetworkMonitor.swift @@ -21,13 +21,32 @@ class NetworkMonitor { var onUpdate: ((Int64, Int64) -> Void)? + /// Whether the detail panel is visible — lsof only runs while true. ponytail: avoids running lsof every second when nobody's looking at connections. + var isPanelVisible = false + private var monitorTask: Task? private var previousBytesIn: Int64 = 0 private var previousBytesOut: Int64 = 0 + private var lastNetstatSampleTime: Date? private var previousAppData: [String: (bytesIn: Int64, bytesOut: Int64)] = [:] + private var connectionsByPid: [pid_t: [Connection]] = [:] + private var flushTickCount = 0 + + // Persistent nettop process state + private var nettopProcess: Process? + private var nettopPipe: Pipe? + private var nettopBuffer = "" + private var lastNettopSampleTime: Date? + private var isMonitoring = false func startMonitoring() async { + isMonitoring = true await readInitialStats() + startNettopStream() + + Task { + await HistoryStore.shared.pruneOldData() + } monitorTask = Task { [weak self] in while !Task.isCancelled { @@ -42,56 +61,62 @@ class NetworkMonitor { } func stopMonitoring() { + isMonitoring = false monitorTask?.cancel() monitorTask = nil + stopNettopStream() } private func readInitialStats() async { - let (stats, perAppData) = await Task.detached(priority: .userInitiated) { [weak self] in - guard let self else { return ((bytesIn: Int64(0), bytesOut: Int64(0)), [AppTraffic]()) } - let s = self.readNetworkStats() - let p = self.readPerAppStats() - return (s, p) + let stats = await Task.detached(priority: .userInitiated) { [weak self] in + self?.readNetworkStats() ?? (bytesIn: 0, bytesOut: 0) }.value previousBytesIn = stats.bytesIn previousBytesOut = stats.bytesOut - - for app in perAppData { - let key = "\(app.processName).\(app.pid)" - previousAppData[key] = (app.bytesIn, app.bytesOut) - } + lastNetstatSampleTime = Date() } private func updateStats() async { - // Run all three system commands concurrently off the main thread - let (stats, perAppData, connectionsByPid) = await Task.detached(priority: .userInitiated) { [weak self] in + // Run lsof only when the panel is visible; netstat always runs. + let shouldReadConnections = isPanelVisible + let (stats, newConnections) = await Task.detached(priority: .userInitiated) { [weak self] in guard let self else { - return ((bytesIn: Int64(0), bytesOut: Int64(0)), [AppTraffic](), [pid_t: [Connection]]()) + return ((bytesIn: Int64(0), bytesOut: Int64(0)), [pid_t: [Connection]]()) } async let s = self.readNetworkStats() - async let p = self.readPerAppStats() - async let c = self.readConnectionDetails() - return await (s, p, c) + async let c: [pid_t: [Connection]] = shouldReadConnections ? self.readConnectionDetails() : [:] + return await (s, c) }.value // --- Everything below runs on @MainActor --- - // Update total stats + if shouldReadConnections { + connectionsByPid = newConnections + } else { + // ponytail: clear connections when the panel is hidden; cache the last result if the reopen delay matters + connectionsByPid = [:] + } + + let now = Date() + let elapsed = lastNetstatSampleTime.map { now.timeIntervalSince($0) } ?? 1.0 + lastNetstatSampleTime = now + let bytesInDelta = stats.bytesIn - previousBytesIn let bytesOutDelta = stats.bytesOut - previousBytesOut - downloadSpeed = max(0, bytesInDelta) - uploadSpeed = max(0, bytesOutDelta) + let safeElapsed = elapsed > 0 ? elapsed : 1.0 + downloadSpeed = Int64(max(0, Double(bytesInDelta)) / safeElapsed) + uploadSpeed = Int64(max(0, Double(bytesOutDelta)) / safeElapsed) - totalBytesIn += downloadSpeed - totalBytesOut += uploadSpeed + totalBytesIn += max(0, bytesInDelta) + totalBytesOut += max(0, bytesOutDelta) previousBytesIn = stats.bytesIn previousBytesOut = stats.bytesOut // Record to in-memory ring buffer for 1-hour graph - recentSamples.append(SpeedSample(timestamp: Date(), bytesIn: downloadSpeed, bytesOut: uploadSpeed)) + recentSamples.append(SpeedSample(timestamp: now, bytesIn: downloadSpeed, bytesOut: uploadSpeed)) if recentSamples.count > Self.maxRecentSamples { recentSamples.removeFirst(recentSamples.count - Self.maxRecentSamples) } @@ -104,7 +129,124 @@ class NetworkMonitor { } } - // Update per-app stats + // Flush history to disk every ~10s + flushTickCount += 1 + if flushTickCount >= 10 { + flushTickCount = 0 + Task { + await HistoryStore.shared.flush() + } + } + + onUpdate?(downloadSpeed, uploadSpeed) + } + + // MARK: - Per-App Stats (persistent nettop stream) + + private func startNettopStream() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/nettop") + process.arguments = ["-P", "-L", "0", "-s", "1", "-x", "-J", "bytes_in,bytes_out"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + nettopBuffer = "" + + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else { return } + Task { @MainActor in + self?.appendNettopChunk(chunk) + } + } + + process.terminationHandler = { [weak self] _ in + Task { @MainActor in + guard let self, self.isMonitoring else { return } + // ponytail: naive restart-after-delay instead of exponential backoff + try? await Task.sleep(for: .seconds(1)) + if self.isMonitoring { + self.startNettopStream() + } + } + } + + do { + try process.run() + nettopProcess = process + nettopPipe = pipe + } catch { + nettopProcess = nil + nettopPipe = nil + } + } + + private func stopNettopStream() { + nettopPipe?.fileHandleForReading.readabilityHandler = nil + nettopProcess?.terminationHandler = nil + if nettopProcess?.isRunning == true { + nettopProcess?.terminate() + } + nettopProcess = nil + nettopPipe = nil + nettopBuffer = "" + } + + private static let nettopHeaderMarker = ",bytes_in,bytes_out," + + /// Accumulate streamed nettop output; each time a new header line arrives, the previously + /// buffered sample (if any) is complete and gets parsed and delivered. + private func appendNettopChunk(_ chunk: String) { + nettopBuffer += chunk + + var lines = nettopBuffer.components(separatedBy: "\n") + // Keep the last (possibly incomplete) line back in the buffer. + let trailing = lines.removeLast() + + var currentSampleLines: [String] = [] + for line in lines { + if line.hasPrefix(",") && line.contains(Self.nettopHeaderMarker) { + // New sample starting — flush the previous one if it has content. + if !currentSampleLines.isEmpty { + handleNettopSample(currentSampleLines) + } + currentSampleLines = [] + } else { + currentSampleLines.append(line) + } + } + + // Re-buffer whatever wasn't flushed yet, plus the trailing partial line. + nettopBuffer = currentSampleLines.joined(separator: "\n") + if !nettopBuffer.isEmpty { + nettopBuffer += "\n" + } + nettopBuffer += trailing + } + + private func handleNettopSample(_ lines: [String]) { + let output = lines.joined(separator: "\n") + let perAppData = parseNettopOutput(output) + + let now = Date() + let elapsed = lastNettopSampleTime.map { now.timeIntervalSince($0) } ?? 1.0 + lastNettopSampleTime = now + + if previousAppData.isEmpty { + // Seed only — no speeds yet. + for app in perAppData { + let key = "\(app.processName).\(app.pid)" + previousAppData[key] = (app.bytesIn, app.bytesOut) + } + return + } + + applyPerAppSample(perAppData, elapsed: elapsed > 0 ? elapsed : 1.0) + } + + private func applyPerAppSample(_ perAppData: [AppTraffic], elapsed: Double) { var updatedApps = perAppData var currentKeys = Set() @@ -113,8 +255,10 @@ class NetworkMonitor { currentKeys.insert(key) if let previous = previousAppData[key] { - updatedApps[i].speedIn = max(0, updatedApps[i].bytesIn - previous.bytesIn) - updatedApps[i].speedOut = max(0, updatedApps[i].bytesOut - previous.bytesOut) + let deltaIn = updatedApps[i].bytesIn - previous.bytesIn + let deltaOut = updatedApps[i].bytesOut - previous.bytesOut + updatedApps[i].speedIn = Int64(max(0, Double(deltaIn)) / elapsed) + updatedApps[i].speedOut = Int64(max(0, Double(deltaOut)) / elapsed) updatedApps[i].previousBytesIn = previous.bytesIn updatedApps[i].previousBytesOut = previous.bytesOut } @@ -126,7 +270,6 @@ class NetworkMonitor { previousAppData.removeValue(forKey: key) } - // Get connection details for active apps appTraffic = updatedApps .filter { $0.bytesIn > 0 || $0.bytesOut > 0 } .map { app in @@ -136,12 +279,9 @@ class NetworkMonitor { } .sorted { $0.totalSpeed > $1.totalSpeed } - // Record per-app stats to history Task { await HistoryStore.shared.recordAppStats(appTraffic) } - - onUpdate?(downloadSpeed, uploadSpeed) } // MARK: - Total Network Stats (netstat) @@ -157,9 +297,9 @@ class NetworkMonitor { do { try task.run() - task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() guard let output = String(data: data, encoding: .utf8) else { return (0, 0) } @@ -201,32 +341,6 @@ class NetworkMonitor { return (totalIn, totalOut) } - // MARK: - Per-App Stats (nettop) - - nonisolated private func readPerAppStats() -> [AppTraffic] { - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/nettop") - task.arguments = ["-P", "-L", "1", "-x", "-J", "bytes_in,bytes_out"] - - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = FileHandle.nullDevice - - do { - try task.run() - task.waitUntilExit() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: data, encoding: .utf8) else { - return [] - } - - return parseNettopOutput(output) - } catch { - return [] - } - } - nonisolated private func parseNettopOutput(_ output: String) -> [AppTraffic] { var apps: [AppTraffic] = [] @@ -291,9 +405,9 @@ class NetworkMonitor { do { try task.run() - task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() guard let output = String(data: data, encoding: .utf8) else { return [:] }