diff --git a/TCPViewer.xcodeproj/project.pbxproj b/TCPViewer.xcodeproj/project.pbxproj index eabc5c3..ceb284a 100644 --- a/TCPViewer.xcodeproj/project.pbxproj +++ b/TCPViewer.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ BA4D26BD2F9A2F770070BE17 /* PcapPlusPlusCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BA4D26B42F9A2F760070BE17 /* PcapPlusPlusCore.framework */; }; + BA9F00012FBF00000070BE17 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = BA9F00032FBF00000070BE17 /* ZIPFoundation */; }; BA5E00012FA800000070BE17 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = BA5E00032FA800000070BE17 /* Sentry */; }; BAPM00012F9D00000070BE17 /* PcapPlusPlusCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BA4D26B42F9A2F760070BE17 /* PcapPlusPlusCore.framework */; }; BAPM00022F9D00000070BE17 /* HexFiend.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BA7A10022F9B00000070BE17 /* HexFiend.framework */; }; @@ -183,6 +184,7 @@ BAPM00022F9D00000070BE17 /* HexFiend.framework in Frameworks */, D0C6D1832B761CD2FBBD11A7 /* Sparkle in Frameworks */, BA5E00012FA800000070BE17 /* Sentry in Frameworks */, + BA9F00012FBF00000070BE17 /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -350,6 +352,7 @@ packageProductDependencies = ( 24AFD2DE681080973988FA47 /* Sparkle */, BA5E00032FA800000070BE17 /* Sentry */, + BA9F00032FBF00000070BE17 /* ZIPFoundation */, ); productName = TCPViewer; productReference = BAAE975D2F9B815F00C52A7C /* TCP Viewer.app */; @@ -438,6 +441,7 @@ packageReferences = ( 47F2E4C3668133A54F097208 /* XCRemoteSwiftPackageReference "Sparkle" */, BA5E00022FA800000070BE17 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, + BA9F00022FBF00000070BE17 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); preferredProjectObjectVersion = 77; productRefGroup = BA4D26862F9A2F490070BE17 /* Products */; @@ -1295,6 +1299,14 @@ minimumVersion = 9.14.0; }; }; + BA9F00022FBF00000070BE17 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZIPFoundation.git"; + requirement = { + kind = exactVersion; + version = 0.9.20; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1308,6 +1320,11 @@ package = BA5E00022FA800000070BE17 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; productName = Sentry; }; + BA9F00032FBF00000070BE17 /* ZIPFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = BA9F00022FBF00000070BE17 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; + productName = ZIPFoundation; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = BA4D267D2F9A2F490070BE17 /* Project object */; diff --git a/TCPViewer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TCPViewer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ae78199..da47895 100644 --- a/TCPViewer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TCPViewer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "45ccdc06172994e37b559028dd59b7094c4ab11c0d183fbfa94841c2b22d6b94", + "originHash" : "12417a22fbefd317a8960932e9f6094065b0c451031d01adb99a8c5a252832f8", "pins" : [ { "identity" : "sentry-cocoa", @@ -18,6 +18,15 @@ "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", "version" : "2.9.1" } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } } ], "version" : 3 diff --git a/TCPViewer/App/AppDelegate.swift b/TCPViewer/App/AppDelegate.swift index ebe491d..a287198 100644 --- a/TCPViewer/App/AppDelegate.swift +++ b/TCPViewer/App/AppDelegate.swift @@ -151,6 +151,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { return } + let sessionURLs = supportedURLs.filter(TCPViewerCaptureFileImportPolicy.isSessionFileURL) + guard sessionURLs.isEmpty || (sessionURLs.count == 1 && supportedURLs.count == 1) else { + presentInvalidSessionOpenSelectionAlert() + completion?(false) + return + } + do { let windowController = try frontmostOrNewTCPViewerWindowController() focusWindowController(windowController) @@ -163,6 +170,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + private func presentInvalidSessionOpenSelectionAlert() { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Open One Session" + alert.informativeText = "TCPViewer sessions replace the current document and cannot be merged with other capture files." + alert.runModal() + } + private func frontmostOrNewTCPViewerWindowController() throws -> TCPViewerWindowController { if let controller = frontmostTCPViewerWindowController() { return controller diff --git a/TCPViewer/App/Document.swift b/TCPViewer/App/Document.swift index eddb1ee..9575a2e 100644 --- a/TCPViewer/App/Document.swift +++ b/TCPViewer/App/Document.swift @@ -41,9 +41,13 @@ class Document: NSDocument { tcpviewerWindowController?.rootViewController.exportSession(format: .pcapng) } + @IBAction func exportSessionToFile(_ sender: Any?) { + tcpviewerWindowController?.rootViewController.exportTCPViewSession() + } + override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { switch menuItem.action { - case #selector(exportSessionAsPcap(_:)), #selector(exportSessionAsPcapng(_:)): + case #selector(exportSessionAsPcap(_:)), #selector(exportSessionAsPcapng(_:)), #selector(exportSessionToFile(_:)): return canExportSession default: return super.validateMenuItem(menuItem) diff --git a/TCPViewer/Base.lproj/Main.storyboard b/TCPViewer/Base.lproj/Main.storyboard index 760cb11..7b32f28 100644 --- a/TCPViewer/Base.lproj/Main.storyboard +++ b/TCPViewer/Base.lproj/Main.storyboard @@ -88,6 +88,11 @@ + + + + + diff --git a/TCPViewer/Core/WorkspaceFoundation.swift b/TCPViewer/Core/WorkspaceFoundation.swift index 7e7f7a4..f2e023a 100644 --- a/TCPViewer/Core/WorkspaceFoundation.swift +++ b/TCPViewer/Core/WorkspaceFoundation.swift @@ -47,6 +47,7 @@ struct PacketIngestState: Sendable, Equatable { var packetIndexByID: [PacketSummary.ID: Int] var importedFiles: [ImportedCaptureFile] var importedPacketReferenceByID: [PacketSummary.ID: ImportedPacketReference] + var sessionClientIconFilePathByClientID: [String: String] var packetRevision: UInt64 var packetLineageRevision: UInt64 var lastMutation: PacketIngestMutation @@ -62,6 +63,7 @@ struct PacketIngestState: Sendable, Equatable { packetIndexByID: [:], importedFiles: [], importedPacketReferenceByID: [:], + sessionClientIconFilePathByClientID: [:], packetRevision: 0, packetLineageRevision: 0, lastMutation: .none, @@ -100,6 +102,7 @@ struct PacketIngestState: Sendable, Equatable { packetIndexByID = [:] importedFiles = [] importedPacketReferenceByID = [:] + sessionClientIconFilePathByClientID = [:] packetRevision &+= 1 packetLineageRevision &+= 1 lastMutation = .reset @@ -127,6 +130,7 @@ struct PacketIngestState: Sendable, Equatable { rebuildPacketIndex() importedFiles = [] importedPacketReferenceByID = [:] + sessionClientIconFilePathByClientID = [:] packetRevision &+= 1 packetLineageRevision &+= 1 lastMutation = .replace @@ -137,6 +141,20 @@ struct PacketIngestState: Sendable, Equatable { } } + mutating func replaceSession( + with batch: [PacketSummary], + importedFiles: [ImportedCaptureFile], + importedPacketReferenceByID: [PacketSummary.ID: ImportedPacketReference], + clientIconFilePathByClientID: [String: String], + source: CaptureSource, + message: String? = nil + ) { + replace(with: batch, source: source, message: message) + self.importedFiles = importedFiles + self.importedPacketReferenceByID = importedPacketReferenceByID + self.sessionClientIconFilePathByClientID = clientIconFilePathByClientID + } + mutating func appendImportedFile( _ file: ImportedCaptureFile, packets batch: [PacketSummary], @@ -695,7 +713,9 @@ struct TCPViewerServiceRegistry { } enum TCPViewerCaptureFileImportPolicy { - static let allowedFilenameExtensions: Set = ["pcap", "pcapng"] + static let captureFilenameExtensions: Set = ["pcap", "pcapng"] + static let sessionFilenameExtension = TCPViewSessionFormat.fileExtension + static let allowedFilenameExtensions: Set = captureFilenameExtensions.union([sessionFilenameExtension]) static var allowedContentTypes: [UTType] { allowedFilenameExtensions.compactMap { UTType(filenameExtension: $0) } @@ -707,7 +727,7 @@ enum TCPViewerCaptureFileImportPolicy { panel.canChooseDirectories = false panel.allowsMultipleSelection = true panel.title = "Open Capture File" - panel.message = "Choose a pcap or pcapng file to inspect." + panel.message = "Choose a pcap, pcapng, or TCPViewer session file to inspect." panel.allowedContentTypes = allowedContentTypes } @@ -715,6 +735,10 @@ enum TCPViewerCaptureFileImportPolicy { allowedFilenameExtensions.contains(url.pathExtension.lowercased()) } + static func isSessionFileURL(_ url: URL) -> Bool { + url.pathExtension.lowercased() == sessionFilenameExtension + } + static func standardizedFileURL(_ url: URL) -> URL { url.standardizedFileURL.resolvingSymlinksInPath() } @@ -865,6 +889,11 @@ struct TCPViewerWorkspaceMemoryDebugSnapshot: Equatable { #endif final class TCPViewerWorkspaceController { + private struct ImportedSessionCaptureExportGroup { + let fileID: ImportedCaptureFileID + var originalPacketIDs: [PacketSummary.ID] + } + weak var delegate: TCPViewerWorkspaceControllerDelegate? private(set) var snapshot: TCPViewerWindowSnapshot { @@ -880,6 +909,7 @@ final class TCPViewerWorkspaceController { let services: TCPViewerServiceRegistry private let backgroundCoordinator: TCPViewerBackgroundCoordinator + private let sessionPackageQueue = DispatchQueue(label: "com.proxyman.tcpviewer.session-package", qos: .userInitiated) private let preferences: TCPViewerPreferences private let interfaceHistoryStore: InterfaceSelectionHistoryStore private let activeInterfaceIDProvider: () -> String? @@ -890,6 +920,8 @@ final class TCPViewerWorkspaceController { private var liveEventGeneration = 0 private var document: (any OfflineCaptureDocumentProviding)? private var importedDocumentsByFileID: [ImportedCaptureFileID: any OfflineCaptureDocumentProviding] = [:] + private(set) var currentDocumentSessionState: TCPViewSessionState? + private(set) var currentDocumentSessionImportReport: TCPViewSessionImportReport? private var documentEventGeneration = 0 private var inspectionGeneration = 0 private var filterValidationGeneration = 0 @@ -1341,6 +1373,12 @@ final class TCPViewerWorkspaceController { } func openDocument(at fileURL: URL, completion: (() -> Void)? = nil) { + let standardizedURL = TCPViewerCaptureFileImportPolicy.standardizedFileURL(fileURL) + guard !TCPViewerCaptureFileImportPolicy.isSessionFileURL(standardizedURL) else { + openSessionDocument(at: standardizedURL, completion: completion) + return + } + stopLiveCaptureIfNeeded { [weak self] stopResult in DispatchQueue.main.async { guard let self else { @@ -1358,6 +1396,92 @@ final class TCPViewerWorkspaceController { } self.releaseDocumentContext() + self.currentDocumentSessionState = nil + self.currentDocumentSessionImportReport = nil + self.importedDocumentsByFileID.removeAll(keepingCapacity: false) + self.resetInspectionState() + self.snapshot.selectedPacketID = nil + self.snapshot.documentState = CaptureDocumentState( + phase: .opening, + fileURL: standardizedURL, + format: nil, + metadata: nil, + packetCount: 0, + isDirty: false, + isPartialResult: false, + statusMessage: "Opening \(standardizedURL.lastPathComponent)...", + lastError: nil + ) + self.services.packetMetadataEnricher.reset() + self.snapshot.packetIngestState.reset(source: .offline, message: "Opening \(standardizedURL.lastPathComponent)...") + self.synchronizeVisiblePackets(message: "Opening \(standardizedURL.lastPathComponent)...") + self.snapshot.loadState.progress = PacketLoadProgress( + phase: .loading, + loadedPacketCount: 0, + message: "Opening \(standardizedURL.lastPathComponent)..." + ) + + self.services.core.openOfflineCaptureDocument(at: standardizedURL) { [weak self] result in + DispatchQueue.main.async { + guard let self else { + completion?() + return + } + + switch result { + case .success(let document): + self.document = document + self.observeDocumentEvents(document) + document.open { [weak self] result in + DispatchQueue.main.async { + guard let self else { + completion?() + return + } + + switch result { + case .success: + self.refreshDocumentSnapshotFromHandle( + document, + phase: .loaded, + message: "Loaded \(self.snapshot.packetIngestState.totalPacketCount) packets from \(standardizedURL.lastPathComponent)." + ) + case .failure(let error): + self.handleDocumentLoadFailure(error, document: document) + } + completion?() + } + } + case .failure(let error): + self.handleDocumentLoadFailure(error, document: nil) + completion?() + } + } + } + } + } + } + + private func openSessionDocument(at fileURL: URL, completion: (() -> Void)? = nil) { + stopLiveCaptureIfNeeded { [weak self] stopResult in + DispatchQueue.main.async { + guard let self else { + completion?() + return + } + + if case .failure(let error) = stopResult { + let tcpviewerError = self.tcpviewerError(from: error, defaultCode: .liveSessionControlFailed) + self.snapshot.sessionState.phase = .failed + self.snapshot.sessionState.lastError = tcpviewerError + self.snapshot.sessionState.statusMessage = tcpviewerError.message + completion?() + return + } + + self.releaseDocumentContext() + self.currentDocumentSessionState = nil + self.currentDocumentSessionImportReport = nil self.importedDocumentsByFileID.removeAll(keepingCapacity: false) self.resetInspectionState() self.snapshot.selectedPacketID = nil @@ -1381,16 +1505,23 @@ final class TCPViewerWorkspaceController { message: "Opening \(fileURL.lastPathComponent)..." ) - self.services.core.openOfflineCaptureDocument(at: fileURL) { [weak self] result in + let importService = TCPViewSessionImportService() + self.sessionPackageQueue.async { [weak self] in + let loadResult = Result { + try importService.loadPackage(at: fileURL) + } DispatchQueue.main.async { guard let self else { completion?() return } - switch result { - case .success(let document): + switch loadResult { + case .success(let contents): + let document = TCPViewSessionOfflineDocument(contents: contents, core: self.services.core) self.document = document + self.currentDocumentSessionState = contents.state + self.currentDocumentSessionImportReport = contents.importReport self.observeDocumentEvents(document) document.open { [weak self] result in DispatchQueue.main.async { @@ -1401,18 +1532,24 @@ final class TCPViewerWorkspaceController { switch result { case .success: + self.currentDocumentSessionState = document.state + self.currentDocumentSessionImportReport = document.importReport self.refreshDocumentSnapshotFromHandle( document, phase: .loaded, message: "Loaded \(self.snapshot.packetIngestState.totalPacketCount) packets from \(fileURL.lastPathComponent)." ) case .failure(let error): + self.currentDocumentSessionState = nil + self.currentDocumentSessionImportReport = nil self.handleDocumentLoadFailure(error, document: document) } completion?() } } case .failure(let error): + self.currentDocumentSessionState = nil + self.currentDocumentSessionImportReport = nil self.handleDocumentLoadFailure(error, document: nil) completion?() } @@ -1433,6 +1570,23 @@ final class TCPViewerWorkspaceController { return } + let sessionURLs = requestedURLs.filter(TCPViewerCaptureFileImportPolicy.isSessionFileURL) + if !sessionURLs.isEmpty { + guard requestedURLs.count == 1, let sessionURL = sessionURLs.first else { + let error = TCPViewerCoreError( + code: .offlineFileOpenFailed, + message: "Open one TCPViewer session at a time. Sessions cannot be merged with other capture files." + ) + snapshot.documentState.lastError = error + snapshot.documentState.statusMessage = error.message + completion?() + return + } + + openSessionDocument(at: sessionURL, completion: completion) + return + } + stopLiveCaptureIfNeeded { [weak self] stopResult in DispatchQueue.main.async { guard let self else { @@ -1451,6 +1605,8 @@ final class TCPViewerWorkspaceController { if replacingCurrent || self.snapshot.packetIngestState.source != .offline { self.releaseDocumentContext() + self.currentDocumentSessionState = nil + self.currentDocumentSessionImportReport = nil self.importedDocumentsByFileID.removeAll(keepingCapacity: false) self.services.packetMetadataEnricher.reset() self.snapshot.packetIngestState.reset(source: .offline, message: "Opening \(requestedURLs.first?.lastPathComponent ?? "capture")...") @@ -1604,6 +1760,388 @@ final class TCPViewerWorkspaceController { } } + func exportTCPViewSession( + snapshot exportSnapshot: TCPViewSessionExportSnapshot, + to url: URL, + exportService: any TCPViewSessionExportWriting, + progress: PacketExportProgressHandler? = nil, + shouldCancel: PacketExportCancellationCheck? = nil, + completion: @escaping TCPViewerVoidCompletion + ) { + let identifiers = exportSnapshot.packets.map(\.id) + guard !identifiers.isEmpty else { + completion(.failure(TCPViewerCoreError(code: .offlineFileSaveFailed, message: "There are no packets to export."))) + return + } + + let cancellationCheck = shouldCancel ?? { false } + guard !cancellationCheck() else { + completion(.failure(Self.exportCancelledError())) + return + } + + let liveSessionToResume = exportSnapshot.source == .live ? liveSession : nil + let shouldResumeCapture = exportSnapshot.source == .live && snapshot.sessionState.phase == .running + let beginExport = { [weak self] in + self?.beginTCPViewSessionExport( + snapshot: exportSnapshot, + to: url, + exportService: exportService, + progress: progress, + cancellationCheck: cancellationCheck, + liveSessionToResume: liveSessionToResume, + shouldResumeCapture: shouldResumeCapture, + completion: completion + ) + } + + guard shouldResumeCapture else { + beginExport() + return + } + + guard let liveSessionToResume else { + completion(.failure(TCPViewerCoreError(code: .offlineFileSaveFailed, message: "The live capture backing store is not available for export."))) + return + } + + snapshot.sessionState.statusMessage = "Pausing capture for export..." + liveSessionToResume.pause { [weak self] result in + DispatchQueue.main.async { + guard let self else { + completion(result) + return + } + + switch result { + case .success: + beginExport() + case .failure(let error): + let tcpviewerError = self.tcpviewerError(from: error, defaultCode: .liveSessionControlFailed) + self.snapshot.sessionState.lastError = tcpviewerError + self.snapshot.sessionState.statusMessage = tcpviewerError.message + completion(.failure(tcpviewerError)) + } + } + } + } + + private func beginTCPViewSessionExport( + snapshot exportSnapshot: TCPViewSessionExportSnapshot, + to url: URL, + exportService: any TCPViewSessionExportWriting, + progress: PacketExportProgressHandler?, + cancellationCheck: @escaping PacketExportCancellationCheck, + liveSessionToResume: (any LiveCaptureSessionProviding)?, + shouldResumeCapture: Bool, + completion: @escaping TCPViewerVoidCompletion + ) { + let temporaryDirectoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("TCPViewSessionCapture-\(UUID().uuidString)", isDirectory: true) + let captureURL = temporaryDirectoryURL.appendingPathComponent(TCPViewSessionFormat.capturePath) + do { + try FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true) + } catch { + completion(.failure(error)) + return + } + + let packageUnitCount = exportSnapshot.packets.count + 6 + let combinedUnitCount = max(exportSnapshot.packets.count + packageUnitCount, 1) + let captureProgress: PacketExportProgressHandler = { packetProgress in + progress?(PacketExportProgress( + exportedPacketCount: min(packetProgress.exportedPacketCount, exportSnapshot.packets.count), + totalPacketCount: combinedUnitCount + )) + } + let packageProgress: PacketExportProgressHandler = { packetProgress in + progress?(PacketExportProgress( + exportedPacketCount: min(combinedUnitCount, exportSnapshot.packets.count + packetProgress.exportedPacketCount), + totalPacketCount: combinedUnitCount + )) + } + + exportSessionCaptureFile( + snapshot: exportSnapshot, + to: captureURL, + progress: captureProgress, + shouldCancel: cancellationCheck + ) { [weak self] result in + guard let self else { + try? FileManager.default.removeItem(at: temporaryDirectoryURL) + completion(result) + return + } + + switch result { + case .success: + self.sessionPackageQueue.async { [weak self] in + let packageResult = Result { + try exportService.writePackage( + snapshot: exportSnapshot, + captureFileURL: captureURL, + to: url, + progress: packageProgress, + shouldCancel: cancellationCheck + ) + } + try? FileManager.default.removeItem(at: temporaryDirectoryURL) + + DispatchQueue.main.async { + guard let self else { + completion(packageResult) + return + } + if let liveSessionToResume { + self.resumeLiveSessionAfterSessionExportIfNeeded( + liveSessionToResume, + shouldResumeCapture: shouldResumeCapture, + exportResult: packageResult, + url: url, + source: exportSnapshot.source, + completion: completion + ) + } else { + self.completeSessionExport(packageResult, url: url, source: exportSnapshot.source, completion: completion) + } + } + } + case .failure: + try? FileManager.default.removeItem(at: temporaryDirectoryURL) + if let liveSessionToResume { + self.resumeLiveSessionAfterSessionExportIfNeeded( + liveSessionToResume, + shouldResumeCapture: shouldResumeCapture, + exportResult: result, + url: url, + source: exportSnapshot.source, + completion: completion + ) + } else { + completion(result) + } + } + } + } + + // Export the raw packet backing for a session, flattening multi-file imports into one pcapng. + private func exportSessionCaptureFile( + snapshot exportSnapshot: TCPViewSessionExportSnapshot, + to captureURL: URL, + progress: PacketExportProgressHandler?, + shouldCancel: @escaping PacketExportCancellationCheck, + completion: @escaping TCPViewerVoidCompletion + ) { + if exportSnapshot.source == .live { + guard let liveSession else { + completion(.failure(TCPViewerCoreError(code: .offlineFileSaveFailed, message: "The live capture backing store is not available for export."))) + return + } + + snapshot.sessionState.statusMessage = "Exporting \(captureURL.lastPathComponent)..." + liveSession.exportPackets( + withIDs: exportSnapshot.packets.map(\.id), + to: captureURL, + format: .pcapng, + progress: progress, + shouldCancel: shouldCancel + ) { result in + DispatchQueue.main.async { + completion(result) + } + } + return + } + + if !(document is TCPViewSessionOfflineDocument), + let groups = importedSessionCaptureExportGroups(for: exportSnapshot), + groups.count > 1 { + exportImportedSessionCaptureFile( + groups: groups, + to: captureURL, + totalPacketCount: exportSnapshot.packets.count, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + return + } + + exportPackets( + withIDs: exportSnapshot.packets.map(\.id), + to: captureURL, + format: .pcapng, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + + // Preserve session order while grouping adjacent imported packets by their original file. + private func importedSessionCaptureExportGroups( + for exportSnapshot: TCPViewSessionExportSnapshot + ) -> [ImportedSessionCaptureExportGroup]? { + guard !exportSnapshot.importedPacketReferenceByID.isEmpty else { + return nil + } + + var groups: [ImportedSessionCaptureExportGroup] = [] + for packet in exportSnapshot.packets { + guard let reference = exportSnapshot.importedPacketReferenceByID[packet.id] else { + return nil + } + + if groups.last?.fileID == reference.fileID { + groups[groups.count - 1].originalPacketIDs.append(reference.originalPacketID) + } else { + groups.append(ImportedSessionCaptureExportGroup( + fileID: reference.fileID, + originalPacketIDs: [reference.originalPacketID] + )) + } + } + + return groups + } + + // Concatenate pcapng section files; each part remains a valid section in the final pcapng. + private func exportImportedSessionCaptureFile( + groups: [ImportedSessionCaptureExportGroup], + to captureURL: URL, + totalPacketCount: Int, + progress: PacketExportProgressHandler?, + shouldCancel: @escaping PacketExportCancellationCheck, + completion: @escaping TCPViewerVoidCompletion + ) { + guard !shouldCancel() else { + completion(.failure(Self.exportCancelledError())) + return + } + guard FileManager.default.createFile(atPath: captureURL.path, contents: nil) else { + completion(.failure(TCPViewerCoreError( + code: .offlineFileSaveFailed, + message: "Could not create the temporary session pcapng file." + ))) + return + } + + let partsDirectoryURL = captureURL.deletingLastPathComponent() + .appendingPathComponent("SessionCaptureParts-\(UUID().uuidString)", isDirectory: true) + do { + try FileManager.default.createDirectory(at: partsDirectoryURL, withIntermediateDirectories: true) + } catch { + completion(.failure(error)) + return + } + + exportImportedSessionCaptureGroup( + groups, + index: 0, + completedPacketCount: 0, + partsDirectoryURL: partsDirectoryURL, + captureURL: captureURL, + totalPacketCount: totalPacketCount, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + + private func exportImportedSessionCaptureGroup( + _ groups: [ImportedSessionCaptureExportGroup], + index: Int, + completedPacketCount: Int, + partsDirectoryURL: URL, + captureURL: URL, + totalPacketCount: Int, + progress: PacketExportProgressHandler?, + shouldCancel: @escaping PacketExportCancellationCheck, + completion: @escaping TCPViewerVoidCompletion + ) { + guard index < groups.count else { + try? FileManager.default.removeItem(at: partsDirectoryURL) + progress?(PacketExportProgress(exportedPacketCount: totalPacketCount, totalPacketCount: totalPacketCount)) + completion(.success(())) + return + } + guard !shouldCancel() else { + try? FileManager.default.removeItem(at: partsDirectoryURL) + completion(.failure(Self.exportCancelledError())) + return + } + + let group = groups[index] + guard let document = importedDocumentsByFileID[group.fileID] else { + try? FileManager.default.removeItem(at: partsDirectoryURL) + completion(.failure(TCPViewerCoreError( + code: .offlineFileSaveFailed, + message: "The imported capture backing for the TCPViewer session export is not available." + ))) + return + } + + let partURL = partsDirectoryURL.appendingPathComponent("part-\(index).pcapng") + let groupProgress: PacketExportProgressHandler = { packetProgress in + progress?(PacketExportProgress( + exportedPacketCount: min(totalPacketCount, completedPacketCount + packetProgress.exportedPacketCount), + totalPacketCount: totalPacketCount + )) + } + document.exportPackets( + withIDs: group.originalPacketIDs, + to: partURL, + format: .pcapng, + progress: groupProgress, + shouldCancel: shouldCancel + ) { [weak self] result in + guard let self else { + completion(result) + return + } + + switch result { + case .success: + do { + try self.appendFile(at: partURL, to: captureURL) + try? FileManager.default.removeItem(at: partURL) + self.exportImportedSessionCaptureGroup( + groups, + index: index + 1, + completedPacketCount: completedPacketCount + group.originalPacketIDs.count, + partsDirectoryURL: partsDirectoryURL, + captureURL: captureURL, + totalPacketCount: totalPacketCount, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } catch { + try? FileManager.default.removeItem(at: partsDirectoryURL) + completion(.failure(error)) + } + case .failure: + try? FileManager.default.removeItem(at: partsDirectoryURL) + completion(result) + } + } + } + + private func appendFile(at sourceURL: URL, to destinationURL: URL) throws { + let sourceHandle = try FileHandle(forReadingFrom: sourceURL) + defer { try? sourceHandle.close() } + let destinationHandle = try FileHandle(forWritingTo: destinationURL) + defer { try? destinationHandle.close() } + + try destinationHandle.seekToEnd() + while true { + let data = try sourceHandle.read(upToCount: 1024 * 1024) ?? Data() + guard !data.isEmpty else { + break + } + try destinationHandle.write(contentsOf: data) + } + } + func exportPackets( withIDs identifiers: [PacketSummary.ID], to url: URL, @@ -1675,6 +2213,16 @@ final class TCPViewerWorkspaceController { } } case .offline: + if let sessionDocument = document as? TCPViewSessionOfflineDocument { + snapshot.documentState.statusMessage = "Exporting \(url.lastPathComponent)..." + sessionDocument.exportPackets(withIDs: identifiers, to: url, format: format, progress: progress, shouldCancel: cancellationCheck) { [weak self] result in + DispatchQueue.main.async { + self?.completeOfflineExport(result, url: url, completion: completion) + } + } + return + } + let importedReferences = identifiers.compactMap { snapshot.packetIngestState.importedPacketReference(for: $0) } if importedReferences.count == identifiers.count, let fileID = importedReferences.first?.fileID { guard importedReferences.allSatisfy({ $0.fileID == fileID }) else { @@ -1788,6 +2336,77 @@ final class TCPViewerWorkspaceController { } } + private func resumeLiveSessionAfterSessionExportIfNeeded( + _ liveSession: any LiveCaptureSessionProviding, + shouldResumeCapture: Bool, + exportResult: Result, + url: URL, + source: CaptureSource?, + completion: @escaping TCPViewerVoidCompletion + ) { + guard shouldResumeCapture else { + completeSessionExport(exportResult, url: url, source: source, completion: completion) + return + } + + snapshot.sessionState.statusMessage = "Resuming capture..." + liveSession.resume { [weak self] resumeResult in + DispatchQueue.main.async { + guard let self else { + completion(exportResult) + return + } + + switch (exportResult, resumeResult) { + case (.success, .success): + self.completeSessionExport(.success(()), url: url, source: source, completion: completion) + case (.failure, .success): + self.completeSessionExport(exportResult, url: url, source: source, completion: completion) + case (.success, .failure(let resumeError)): + let tcpviewerError = self.tcpviewerError(from: resumeError, defaultCode: .liveSessionControlFailed) + self.completeSessionExport(.failure(tcpviewerError), url: url, source: source, completion: completion) + case (.failure(let exportError), .failure): + self.completeSessionExport(.failure(exportError), url: url, source: source, completion: completion) + } + } + } + } + + private func completeSessionExport( + _ result: Result, + url: URL, + source: CaptureSource?, + completion: @escaping TCPViewerVoidCompletion + ) { + switch result { + case .success: + switch source { + case .live: + snapshot.sessionState.lastError = nil + snapshot.sessionState.statusMessage = "Exported \(url.lastPathComponent)." + case .offline, nil: + snapshot.documentState.lastError = nil + snapshot.documentState.statusMessage = "Exported \(url.lastPathComponent)." + @unknown default: + break + } + completion(.success(())) + case .failure(let error): + let tcpviewerError = tcpviewerError(from: error, defaultCode: .offlineFileSaveFailed) + switch source { + case .live: + snapshot.sessionState.lastError = tcpviewerError + snapshot.sessionState.statusMessage = tcpviewerError.code == .operationCancelled ? "Export cancelled." : tcpviewerError.message + case .offline, nil: + snapshot.documentState.lastError = tcpviewerError + snapshot.documentState.statusMessage = tcpviewerError.message + @unknown default: + break + } + completion(.failure(tcpviewerError)) + } + } + func cancelDocumentLoading(completion: (() -> Void)? = nil) { let documents = Array(importedDocumentsByFileID.values) guard !documents.isEmpty else { @@ -2373,7 +2992,19 @@ final class TCPViewerWorkspaceController { snapshot.loadState.progress = progress if packets.isEmpty { snapshot.packetIngestState.reset(source: .offline, message: resolvedMessage) + } else if let sessionDocument = document as? TCPViewSessionOfflineDocument { + currentDocumentSessionState = sessionDocument.state + currentDocumentSessionImportReport = sessionDocument.importReport + snapshot.packetIngestState.replaceSession( + with: packets, + importedFiles: sessionDocument.importedFiles, + importedPacketReferenceByID: sessionDocument.importedPacketReferenceByID, + clientIconFilePathByClientID: sessionDocument.clientIconFilePathByClientID, + source: .offline, + message: resolvedMessage + ) } else { + currentDocumentSessionImportReport = nil snapshot.packetIngestState.replace(with: packets, source: .offline, message: resolvedMessage) } @@ -2685,6 +3316,7 @@ final class TCPViewerWorkspaceController { let currentDocument = document let importedDocuments = importedDocumentsExcluding(currentDocument) document = nil + currentDocumentSessionImportReport = nil importedDocumentsByFileID.removeAll(keepingCapacity: false) backgroundCoordinator.cancelAll() currentDocument?.cancelLoading(completion: nil) @@ -2701,6 +3333,8 @@ final class TCPViewerWorkspaceController { document?.eventHandler = nil inspectionGeneration += 1 document = nil + currentDocumentSessionState = nil + currentDocumentSessionImportReport = nil importedDocumentsByFileID.removeAll(keepingCapacity: false) backgroundCoordinator.endOperation("document-events") currentDocument?.cancelLoading(completion: nil) diff --git a/TCPViewer/Features/NetworkInspector/Models/NetworkInspectorModels.swift b/TCPViewer/Features/NetworkInspector/Models/NetworkInspectorModels.swift index 2035018..c7d74c8 100644 --- a/TCPViewer/Features/NetworkInspector/Models/NetworkInspectorModels.swift +++ b/TCPViewer/Features/NetworkInspector/Models/NetworkInspectorModels.swift @@ -194,7 +194,8 @@ struct PacketTableRow: Identifiable, Sendable, Hashable { init( packet: PacketSummary, previousVisiblePacketTimestamp: Date?, - previousVisibleStreamPacketTimestamp: Date? + previousVisibleStreamPacketTimestamp: Date?, + clientIconFilePath: String? = nil ) { // Passthrough strings keep native-layer / Foundation backing; copy them into Swift-owned // buffers so a row never depends on an NSString whose lifetime is owned elsewhere. @@ -202,7 +203,7 @@ struct PacketTableRow: Identifiable, Sendable, Hashable { self.sourceAddress = packet.endpoints.source.address?.tcpviewerNativeCopy self.destinationAddress = packet.endpoints.destination.address?.tcpviewerNativeCopy self.sniDomainName = packet.sniDomainName?.tcpviewerNativeCopy - self.clientIconFilePath = PacketClientIconPathResolver.iconFilePath(for: packet.client)?.tcpviewerNativeCopy + self.clientIconFilePath = (clientIconFilePath ?? PacketClientIconPathResolver.iconFilePath(for: packet.client))?.tcpviewerNativeCopy self.hasClient = packet.client != nil self.timestamp = packet.timestamp self.streamID = packet.streamID @@ -317,12 +318,13 @@ struct PacketTableRowTimingState: Sendable, Hashable { private var previousVisibleStreamPacketTimestampByID: [UInt32: Date] = [:] // Build a row with deltas from the previous visible packet and stream packet. - mutating func row(for packet: PacketSummary) -> PacketTableRow { + mutating func row(for packet: PacketSummary, clientIconFilePath: String? = nil) -> PacketTableRow { let streamTimestamp = packet.streamID.flatMap { previousVisibleStreamPacketTimestampByID[$0] } let row = PacketTableRow( packet: packet, previousVisiblePacketTimestamp: previousVisiblePacketTimestamp, - previousVisibleStreamPacketTimestamp: streamTimestamp + previousVisibleStreamPacketTimestamp: streamTimestamp, + clientIconFilePath: clientIconFilePath ) previousVisiblePacketTimestamp = packet.timestamp if let streamID = packet.streamID { diff --git a/TCPViewer/Features/NetworkInspector/Models/PacketQuickFilterModels.swift b/TCPViewer/Features/NetworkInspector/Models/PacketQuickFilterModels.swift index cdfaa25..32c8bc1 100644 --- a/TCPViewer/Features/NetworkInspector/Models/PacketQuickFilterModels.swift +++ b/TCPViewer/Features/NetworkInspector/Models/PacketQuickFilterModels.swift @@ -8,7 +8,7 @@ import Foundation import PcapPlusPlusCore -enum PacketQuickFilterID: String, CaseIterable, Identifiable, Sendable, Hashable { +enum PacketQuickFilterID: String, CaseIterable, Codable, Identifiable, Sendable, Hashable { case all case tcp case udp @@ -85,7 +85,7 @@ struct PacketCustomFilterItem: Identifiable, Equatable, Sendable { let isSelected: Bool } -struct PacketQuickFilterSelection: Equatable, Sendable, Hashable { +struct PacketQuickFilterSelection: Codable, Equatable, Sendable, Hashable { let selectedIDs: Set static let all = PacketQuickFilterSelection(selectedIDs: [.all]) @@ -152,6 +152,10 @@ final class PacketQuickFilterService { return selection } + func apply(_ selection: PacketQuickFilterSelection) { + self.selection = selection + } + func matches(_ packet: PacketSummary, selection: PacketQuickFilterSelection? = nil) -> Bool { let selection = selection ?? self.selection guard selection.isActive else { diff --git a/TCPViewer/Features/NetworkInspector/Services/PacketCustomFilterService.swift b/TCPViewer/Features/NetworkInspector/Services/PacketCustomFilterService.swift index 245d179..821063b 100644 --- a/TCPViewer/Features/NetworkInspector/Services/PacketCustomFilterService.swift +++ b/TCPViewer/Features/NetworkInspector/Services/PacketCustomFilterService.swift @@ -37,6 +37,7 @@ final class PacketCustomFilterService { private let userDataDirectory: TCPViewerUserDataDirectory private let usesUserDataDirectoryStorage: Bool private var cachedFilters: [PacketCustomFilter] + private var isDocumentScoped = false init( storageURL: URL? = nil, @@ -55,6 +56,16 @@ final class PacketCustomFilterService { cachedFilters } + func useDocumentFilters(_ filters: [PacketCustomFilter]) { + isDocumentScoped = true + cachedFilters = filters + } + + func reloadPersistentFilters() { + isDocumentScoped = false + cachedFilters = (try? Self.loadFilters(from: storageURL, fileManager: fileManager)) ?? [] + } + // Look up a saved filter by stable identifier for quick button actions. func filter(id: PacketCustomFilter.ID) -> PacketCustomFilter? { cachedFilters.first { $0.id == id } @@ -169,6 +180,10 @@ final class PacketCustomFilterService { } private func persist() throws { + guard !isDocumentScoped else { + return + } + if usesUserDataDirectoryStorage { try userDataDirectory.createSettingsDirectoryIfNeeded() } else { diff --git a/TCPViewer/Features/NetworkInspector/Services/PacketExportService.swift b/TCPViewer/Features/NetworkInspector/Services/PacketExportService.swift index 97efe77..2173cc9 100644 --- a/TCPViewer/Features/NetworkInspector/Services/PacketExportService.swift +++ b/TCPViewer/Features/NetworkInspector/Services/PacketExportService.swift @@ -29,16 +29,25 @@ final class PacketExportCancellationToken: @unchecked Sendable { final class PacketExportProgressSheetController: NSViewController { private let fileName: String + private let progressTitle: String + private let unitLabel: String private let cancelHandler: () -> Void - private let titleLabel = NSTextField(labelWithString: "Exporting Packets") + private let titleLabel = NSTextField(labelWithString: "") private let detailLabel = NSTextField(labelWithString: "") private let percentLabel = NSTextField(labelWithString: "0%") private let progressIndicator = NSProgressIndicator() private let cancelButton = NSButton(title: "Cancel", target: nil, action: nil) private var sheetWindow: NSWindow? - init(fileName: String, cancelHandler: @escaping () -> Void) { + init( + fileName: String, + progressTitle: String = "Exporting Packets", + unitLabel: String = "packets", + cancelHandler: @escaping () -> Void + ) { self.fileName = fileName + self.progressTitle = progressTitle + self.unitLabel = unitLabel self.cancelHandler = cancelHandler super.init(nibName: nil, bundle: nil) } @@ -49,6 +58,7 @@ final class PacketExportProgressSheetController: NSViewController { } override func loadView() { + titleLabel.stringValue = progressTitle titleLabel.font = .systemFont(ofSize: NSFont.systemFontSize + 2, weight: .semibold) detailLabel.stringValue = "Preparing \(fileName)..." detailLabel.textColor = .secondaryLabelColor @@ -102,7 +112,7 @@ final class PacketExportProgressSheetController: NSViewController { progressIndicator.doubleValue = progress.fractionCompleted let percent = Int((progress.fractionCompleted * 100).rounded()) percentLabel.stringValue = "\(percent)%" - detailLabel.stringValue = "Exported \(progress.exportedPacketCount) of \(progress.totalPacketCount) packets to \(fileName)." + detailLabel.stringValue = "Exported \(progress.exportedPacketCount) of \(progress.totalPacketCount) \(unitLabel) to \(fileName)." } func dismiss() { @@ -143,6 +153,10 @@ final class PacketExportService { "\(sanitizedScopeName(scopeName))-\(Self.timestampFormatter.string(from: now())).\(format.rawValue)" } + func defaultSessionFileName(scopeName: String) -> String { + "\(sanitizedScopeName(scopeName))-\(Self.timestampFormatter.string(from: now())).\(TCPViewSessionFormat.fileExtension)" + } + func lastDirectoryURL() -> URL? { guard let path = defaults.string(forKey: Key.lastDirectoryPath), !path.isEmpty else { @@ -171,8 +185,34 @@ final class PacketExportService { return PacketExportDestination(url: url, format: format) } - func showProgressSheet(attachedTo window: NSWindow?, fileName: String, cancelHandler: @escaping () -> Void) -> PacketExportProgressSheetController { - let controller = PacketExportProgressSheetController(fileName: fileName, cancelHandler: cancelHandler) + func chooseTCPViewSessionDestination(scopeName: String) -> URL? { + let panel = NSSavePanel() + panel.title = "Export TCPViewer Session" + panel.nameFieldStringValue = defaultSessionFileName(scopeName: scopeName) + panel.directoryURL = lastDirectoryURL() + panel.allowedContentTypes = [UTType(filenameExtension: TCPViewSessionFormat.fileExtension)].compactMap { $0 } + panel.canCreateDirectories = true + + guard panel.runModal() == .OK, let url = panel.url else { + return nil + } + + return url + } + + func showProgressSheet( + attachedTo window: NSWindow?, + fileName: String, + progressTitle: String = "Exporting Packets", + unitLabel: String = "packets", + cancelHandler: @escaping () -> Void + ) -> PacketExportProgressSheetController { + let controller = PacketExportProgressSheetController( + fileName: fileName, + progressTitle: progressTitle, + unitLabel: unitLabel, + cancelHandler: cancelHandler + ) controller.show(attachedTo: window) return controller } diff --git a/TCPViewer/Features/NetworkInspector/Services/PacketPinService.swift b/TCPViewer/Features/NetworkInspector/Services/PacketPinService.swift index 0191444..428e1e2 100644 --- a/TCPViewer/Features/NetworkInspector/Services/PacketPinService.swift +++ b/TCPViewer/Features/NetworkInspector/Services/PacketPinService.swift @@ -75,6 +75,7 @@ final class PacketPinService { private let userDataDirectory: TCPViewerUserDataDirectory private let usesUserDataDirectoryStorage: Bool private var cachedPins: [PacketPin] + private var isDocumentScoped = false init( storageURL: URL? = nil, @@ -92,6 +93,16 @@ final class PacketPinService { cachedPins } + func useDocumentPins(_ pins: [PacketPin]) { + isDocumentScoped = true + cachedPins = pins + } + + func reloadPersistentPins() { + isDocumentScoped = false + cachedPins = (try? Self.loadPins(from: storageURL, fileManager: fileManager)) ?? [] + } + func deletePin(id: PacketPinID) throws { guard let index = cachedPins.firstIndex(where: { $0.id == id }) else { return @@ -246,6 +257,10 @@ final class PacketPinService { } private func persist() throws { + guard !isDocumentScoped else { + return + } + if usesUserDataDirectoryStorage { try userDataDirectory.createSettingsDirectoryIfNeeded() } else { diff --git a/TCPViewer/Features/NetworkInspector/Services/PacketSourceListService.swift b/TCPViewer/Features/NetworkInspector/Services/PacketSourceListService.swift index 87ab52f..1eb9ff2 100644 --- a/TCPViewer/Features/NetworkInspector/Services/PacketSourceListService.swift +++ b/TCPViewer/Features/NetworkInspector/Services/PacketSourceListService.swift @@ -316,7 +316,7 @@ enum PacketSourceListDeletionPolicy { } enum PacketSourceListClassifier { - static func clientIdentity(for packet: PacketSummary) -> PacketSourceClientIdentity? { + static func clientIdentity(for packet: PacketSummary, iconFilePathOverride: String? = nil) -> PacketSourceClientIdentity? { guard let client = packet.client else { return nil } @@ -351,7 +351,7 @@ enum PacketSourceListClassifier { return PacketSourceClientIdentity( key: PacketSourceClientKey(rawValue: "\(keyPrefix):\(identityValue)"), displayName: displayName, - iconFilePath: PacketClientIconPathResolver.iconFilePath(for: client) + iconFilePath: iconFilePathOverride ?? PacketClientIconPathResolver.iconFilePath(for: client) ) } @@ -567,7 +567,10 @@ final class PacketSourceListService { self.pinnedItems = pinnedItems self.savedPacketCount = savedPacketCount - importedFilesByID = Dictionary(uniqueKeysWithValues: ingestState.importedFiles.map { ($0.id, $0) }) + importedFilesByID = [:] + for file in ingestState.importedFiles where importedFilesByID[file.id] == nil { + importedFilesByID[file.id] = file + } if packetLineageRevision == ingestState.packetLineageRevision, sourcePacketCount <= ingestState.packets.count { @@ -644,7 +647,10 @@ final class PacketSourceListService { private func makeAssignment(for packet: PacketSummary, in ingestState: PacketIngestState) -> PacketBucketAssignment { PacketBucketAssignment( fileID: ingestState.importedPacketReference(for: packet.id)?.fileID, - appIdentity: PacketSourceListClassifier.clientIdentity(for: packet), + appIdentity: PacketSourceListClassifier.clientIdentity( + for: packet, + iconFilePathOverride: ingestState.tcpviewSessionClientIconFilePath(for: packet.client) + ), domainIdentity: PacketSourceListClassifier.domainIdentity(for: packet), ipAddressIdentities: PacketSourceListClassifier.ipAddressIdentities(for: packet), pinIPAddresses: Self.pinIPAddresses(for: packet) diff --git a/TCPViewer/Features/NetworkInspector/Services/SavedPacketService.swift b/TCPViewer/Features/NetworkInspector/Services/SavedPacketService.swift index 1b82f2e..45434ea 100644 --- a/TCPViewer/Features/NetworkInspector/Services/SavedPacketService.swift +++ b/TCPViewer/Features/NetworkInspector/Services/SavedPacketService.swift @@ -21,6 +21,7 @@ final class SavedPacketService { private let userDataDirectory: TCPViewerUserDataDirectory private let usesUserDataDirectoryStorage: Bool private var cachedRecords: [SavedPacketRecord] + private var isDocumentScoped = false init( storageURL: URL? = nil, @@ -38,6 +39,16 @@ final class SavedPacketService { cachedRecords } + func useDocumentRecords(_ records: [SavedPacketRecord]) { + isDocumentScoped = true + cachedRecords = records + } + + func reloadPersistentRecords() { + isDocumentScoped = false + cachedRecords = (try? Self.loadRecords(from: storageURL, fileManager: fileManager)) ?? [] + } + func packets() -> [PacketSummary] { cachedRecords.map(\.packet) } @@ -77,6 +88,10 @@ final class SavedPacketService { } private func persist() throws { + guard !isDocumentScoped else { + return + } + if usesUserDataDirectoryStorage { try userDataDirectory.createSettingsDirectoryIfNeeded() } else { diff --git a/TCPViewer/Features/NetworkInspector/Services/TCPViewSessionExportService.swift b/TCPViewer/Features/NetworkInspector/Services/TCPViewSessionExportService.swift new file mode 100644 index 0000000..2826938 --- /dev/null +++ b/TCPViewer/Features/NetworkInspector/Services/TCPViewSessionExportService.swift @@ -0,0 +1,272 @@ +// +// TCPViewSessionExportService.swift +// TCPViewer +// +// Created by Proxyman LLC on 15/6/26. +// + +import AppKit +import Foundation +import PcapPlusPlusCore +import ZIPFoundation + +protocol TCPViewSessionExportWriting: AnyObject { + func writePackage( + snapshot: TCPViewSessionExportSnapshot, + captureFileURL: URL, + to destinationURL: URL, + progress: PacketExportProgressHandler?, + shouldCancel: PacketExportCancellationCheck? + ) throws +} + +final class TCPViewSessionExportService: TCPViewSessionExportWriting { + private let fileManager: FileManager + private let bundle: Bundle + private let now: () -> Date + + init( + fileManager: FileManager = .default, + bundle: Bundle = .main, + now: @escaping () -> Date = Date.init + ) { + self.fileManager = fileManager + self.bundle = bundle + self.now = now + } + + // Build the inspectable ZIP package in a staging directory before touching the destination file. + func writePackage( + snapshot: TCPViewSessionExportSnapshot, + captureFileURL: URL, + to destinationURL: URL, + progress: PacketExportProgressHandler? = nil, + shouldCancel: PacketExportCancellationCheck? = nil + ) throws { + let cancellationCheck = shouldCancel ?? { false } + try throwIfCancelled(cancellationCheck) + + guard fileManager.fileExists(atPath: captureFileURL.path) else { + throw TCPViewerCoreError( + code: .offlineFileSaveFailed, + message: "The temporary pcapng file could not be found." + ) + } + + let stagingRoot = fileManager.temporaryDirectory + .appendingPathComponent("TCPViewSessionExport-\(UUID().uuidString)", isDirectory: true) + defer { + try? fileManager.removeItem(at: stagingRoot) + } + + let packageURL = stagingRoot.appendingPathComponent(TCPViewSessionFormat.packageDirectoryName, isDirectory: true) + let iconsDirectoryURL = packageURL.appendingPathComponent(TCPViewSessionFormat.iconsDirectoryPath, isDirectory: true) + let zipURL = stagingRoot.appendingPathComponent("\(destinationURL.deletingPathExtension().lastPathComponent).zip") + try fileManager.createDirectory(at: iconsDirectoryURL, withIntermediateDirectories: true) + + let totalUnits = max(snapshot.packets.count + 6, 1) + var completedUnits = 0 + func report(_ units: Int = 0) { + completedUnits = min(totalUnits, completedUnits + units) + progress?(PacketExportProgress(exportedPacketCount: completedUnits, totalPacketCount: totalUnits)) + } + + try fileManager.copyItem( + at: captureFileURL, + to: packageURL.appendingPathComponent(TCPViewSessionFormat.capturePath) + ) + report(1) + + let iconIDByClientID = writeIcons(for: snapshot.packets, to: iconsDirectoryURL) + let storeResult = TCPViewSessionClientStoreBuilder.buildClientStore( + packets: snapshot.packets, + iconIDForClient: { client in + iconIDByClientID[TCPViewSessionClientStoreBuilder.stableClientID(for: client)] + } + ) + + try writePacketRecords( + storeResult.records, + to: packageURL.appendingPathComponent(TCPViewSessionFormat.packetsPath), + completedUnits: &completedUnits, + totalUnits: totalUnits, + progress: progress, + shouldCancel: cancellationCheck + ) + try throwIfCancelled(cancellationCheck) + + try writeJSON( + storeResult.clients, + to: packageURL.appendingPathComponent(TCPViewSessionFormat.clientsPath) + ) + report(1) + + try writeJSON( + annotations(for: snapshot.packets), + to: packageURL.appendingPathComponent(TCPViewSessionFormat.annotationsPath) + ) + report(1) + + try writeJSON( + snapshot.state, + to: packageURL.appendingPathComponent(TCPViewSessionFormat.statePath) + ) + report(1) + + let manifest = manifest(packetCount: snapshot.packets.count) + try writeJSON( + manifest, + to: packageURL.appendingPathComponent(TCPViewSessionFormat.manifestPath) + ) + report(1) + + try throwIfCancelled(cancellationCheck) + let zipProgress = Progress(totalUnitCount: 100) + try fileManager.zipItem( + at: packageURL, + to: zipURL, + shouldKeepParent: true, + compressionMethod: .deflate, + progress: zipProgress + ) + report(1) + try throwIfCancelled(cancellationCheck) + + try atomicallyReplaceItem(at: destinationURL, with: zipURL) + } + + private func manifest(packetCount: Int) -> TCPViewSessionManifest { + TCPViewSessionManifest( + createdAt: now(), + applicationName: bundle.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "TCPViewer", + applicationVersion: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0", + applicationBuild: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1", + packetCount: packetCount + ) + } + + private func annotations(for packets: [PacketSummary]) -> TCPViewSessionAnnotations { + TCPViewSessionAnnotations( + annotations: packets.compactMap { packet in + guard packet.captureMetadata.packetComment != nil else { + return nil + } + return TCPViewSessionPacketAnnotation( + packetID: packet.id, + packetComment: packet.captureMetadata.packetComment, + customComment: nil, + colorHex: nil + ) + } + ) + } + + private func writePacketRecords( + _ records: [TCPViewSessionPacketRecord], + to url: URL, + completedUnits: inout Int, + totalUnits: Int, + progress: PacketExportProgressHandler?, + shouldCancel: PacketExportCancellationCheck + ) throws { + let encoder = compactJSONEncoder() + var data = Data() + data.reserveCapacity(records.count * 512) + + for (index, record) in records.enumerated() { + if index.isMultiple(of: 512) { + try throwIfCancelled(shouldCancel) + progress?(PacketExportProgress( + exportedPacketCount: min(totalUnits, completedUnits + index), + totalPacketCount: totalUnits + )) + } + data.append(try encoder.encode(record)) + data.append(0x0A) + } + + try data.write(to: url, options: .atomic) + completedUnits = min(totalUnits, completedUnits + records.count) + progress?(PacketExportProgress(exportedPacketCount: completedUnits, totalPacketCount: totalUnits)) + } + + private func writeJSON(_ value: Value, to url: URL) throws { + try compactJSONEncoder().encode(value).write(to: url, options: .atomic) + } + + private func compactJSONEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + } + + private func writeIcons(for packets: [PacketSummary], to iconsDirectoryURL: URL) -> [String: String] { + var iconIDByClientID: [String: String] = [:] + var writtenIconIDs = Set() + + for packet in packets { + guard let client = packet.client, + let iconPath = PacketClientIconPathResolver.iconFilePath(for: client), + fileManager.fileExists(atPath: iconPath) else { + continue + } + + let clientID = TCPViewSessionClientStoreBuilder.stableClientID(for: client) + let iconID = TCPViewSessionClientStoreBuilder.stableIconID(for: iconPath) + iconIDByClientID[clientID] = iconID + + guard writtenIconIDs.insert(iconID).inserted else { + continue + } + + let iconURL = iconsDirectoryURL.appendingPathComponent("\(iconID).png") + try? writePNGIcon(forFile: iconPath, to: iconURL) + } + + return iconIDByClientID + } + + private func writePNGIcon(forFile path: String, to url: URL) throws { + let icon = NSWorkspace.shared.icon(forFile: path) + icon.size = NSSize(width: 64, height: 64) + guard let tiffData = icon.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) else { + return + } + + try pngData.write(to: url, options: .atomic) + } + + private func atomicallyReplaceItem(at destinationURL: URL, with stagedURL: URL) throws { + let parentURL = destinationURL.deletingLastPathComponent() + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) + + let replacementURL = parentURL.appendingPathComponent(".\(destinationURL.lastPathComponent).\(UUID().uuidString).tmp") + try fileManager.moveItem(at: stagedURL, to: replacementURL) + do { + if fileManager.fileExists(atPath: destinationURL.path) { + _ = try fileManager.replaceItemAt( + destinationURL, + withItemAt: replacementURL, + backupItemName: nil, + options: [.usingNewMetadataOnly] + ) + } else { + try fileManager.moveItem(at: replacementURL, to: destinationURL) + } + } catch { + try? fileManager.removeItem(at: replacementURL) + throw error + } + } + + private func throwIfCancelled(_ shouldCancel: PacketExportCancellationCheck) throws { + guard shouldCancel() else { + return + } + + throw TCPViewerCoreError(code: .operationCancelled, message: "TCPViewer session export was cancelled.") + } +} diff --git a/TCPViewer/Features/NetworkInspector/Services/TCPViewSessionFormat.swift b/TCPViewer/Features/NetworkInspector/Services/TCPViewSessionFormat.swift new file mode 100644 index 0000000..e6b2509 --- /dev/null +++ b/TCPViewer/Features/NetworkInspector/Services/TCPViewSessionFormat.swift @@ -0,0 +1,526 @@ +// +// TCPViewSessionFormat.swift +// TCPViewer +// +// Created by Proxyman LLC on 15/6/26. +// + +import Foundation +import PcapPlusPlusCore + +enum TCPViewSessionFormat { + static let fileExtension = "tcpviewsession" + static let importedTypeIdentifier = "com.proxyman.tcpviewer.session" + static let packageDirectoryName = "TCPViewerSession" + static let magic = "TCPViewerSession" + static let schemaVersion = 1 + static let minimumCompatibleSchemaVersion = 1 + + static let manifestPath = "manifest.json" + static let capturePath = "capture.pcapng" + static let packetsPath = "packets.jsonl" + static let clientsPath = "clients.json" + static let annotationsPath = "annotations.json" + static let statePath = "state.json" + static let iconsDirectoryPath = "icons" +} + +struct TCPViewSessionManifest: Codable, Equatable { + var magic: String + var schemaVersion: Int + var minimumCompatibleSchemaVersion: Int + var createdAt: Date + var applicationName: String + var applicationVersion: String + var applicationBuild: String + var packetCount: Int + var files: [String] + var captureFile: String + var packetsFile: String + var clientsFile: String + var annotationsFile: String + var stateFile: String + var iconsDirectory: String + + init( + createdAt: Date, + applicationName: String, + applicationVersion: String, + applicationBuild: String, + packetCount: Int, + files: [String] = [ + TCPViewSessionFormat.manifestPath, + TCPViewSessionFormat.capturePath, + TCPViewSessionFormat.packetsPath, + TCPViewSessionFormat.clientsPath, + TCPViewSessionFormat.annotationsPath, + TCPViewSessionFormat.statePath, + TCPViewSessionFormat.iconsDirectoryPath, + ] + ) { + self.magic = TCPViewSessionFormat.magic + self.schemaVersion = TCPViewSessionFormat.schemaVersion + self.minimumCompatibleSchemaVersion = TCPViewSessionFormat.minimumCompatibleSchemaVersion + self.createdAt = createdAt + self.applicationName = applicationName + self.applicationVersion = applicationVersion + self.applicationBuild = applicationBuild + self.packetCount = packetCount + self.files = files + self.captureFile = TCPViewSessionFormat.capturePath + self.packetsFile = TCPViewSessionFormat.packetsPath + self.clientsFile = TCPViewSessionFormat.clientsPath + self.annotationsFile = TCPViewSessionFormat.annotationsPath + self.stateFile = TCPViewSessionFormat.statePath + self.iconsDirectory = TCPViewSessionFormat.iconsDirectoryPath + } +} + +struct TCPViewSessionPacketRecord: Codable, Equatable { + let packetID: PacketSummary.ID + let captureOrdinal: Int + let clientID: String? + let packet: PacketSummary +} + +struct TCPViewSessionClientRecord: Codable, Equatable { + let id: String + let client: PacketClient + let iconID: String? +} + +struct TCPViewSessionClientStore: Codable, Equatable { + let clients: [TCPViewSessionClientRecord] +} + +struct TCPViewSessionPacketAnnotation: Codable, Equatable { + let packetID: PacketSummary.ID + let packetComment: String? + let customComment: String? + let colorHex: String? +} + +struct TCPViewSessionAnnotations: Codable, Equatable { + let annotations: [TCPViewSessionPacketAnnotation] +} + +struct TCPViewSessionImportReport: Sendable, Equatable { + let importedFlowCount: Int + let failedFlowCount: Int + + var hasFailedFlows: Bool { + failedFlowCount > 0 + } + + func addingFailedFlows(_ count: Int) -> TCPViewSessionImportReport { + guard count > 0 else { + return self + } + + return TCPViewSessionImportReport( + importedFlowCount: max(0, importedFlowCount - count), + failedFlowCount: failedFlowCount + count + ) + } +} + +struct TCPViewSessionImportedFileRecord: Codable, Equatable { + let fileID: String + let urlPath: String + let displayName: String + let packetIDs: [PacketSummary.ID] + + init(file: ImportedCaptureFile) { + self.fileID = file.id.rawValue + self.urlPath = file.url.path + self.displayName = file.displayName + self.packetIDs = file.packetIDs + } + + func importedFile() -> ImportedCaptureFile { + ImportedCaptureFile( + id: ImportedCaptureFileID(rawValue: fileID), + url: URL(fileURLWithPath: urlPath), + displayName: displayName, + packetIDs: packetIDs + ) + } +} + +struct TCPViewSessionImportedPacketReferenceRecord: Codable, Equatable { + let packetID: PacketSummary.ID + let fileID: String + let originalPacketID: PacketSummary.ID + + init(packetID: PacketSummary.ID, reference: ImportedPacketReference) { + self.packetID = packetID + self.fileID = reference.fileID.rawValue + self.originalPacketID = reference.originalPacketID + } + + func importedPacketReference() -> (PacketSummary.ID, ImportedPacketReference) { + ( + packetID, + ImportedPacketReference( + fileID: ImportedCaptureFileID(rawValue: fileID), + originalPacketID: originalPacketID + ) + ) + } +} + +struct TCPViewSessionSourceListSelectionRecord: Codable, Equatable { + let kind: String + let values: [String] + + init(selection: PacketSourceListSelection) { + switch selection { + case .allPackets: + kind = "allPackets"; values = [] + case .pinned: + kind = "pinned"; values = [] + case .pinnedItem(let pinID): + kind = "pinnedItem"; values = [pinID.rawValue] + case .pinnedItemDomain(let pinID, let domain): + kind = "pinnedItemDomain"; values = [pinID.rawValue, domain.rawValue, String(domain.isMissingDomain)] + case .pinnedItemIPAddress(let pinID, let ipAddress): + kind = "pinnedItemIPAddress"; values = [pinID.rawValue, ipAddress.rawValue] + case .saved: + kind = "saved"; values = [] + case .files: + kind = "files"; values = [] + case .file(let fileID): + kind = "file"; values = [fileID.rawValue] + case .fileApps(let fileID): + kind = "fileApps"; values = [fileID.rawValue] + case .fileApp(let fileID, let client): + kind = "fileApp"; values = [fileID.rawValue, client.rawValue] + case .fileAppDomain(let fileID, let client, let domain): + kind = "fileAppDomain"; values = [fileID.rawValue, client.rawValue, domain.rawValue, String(domain.isMissingDomain)] + case .fileAppIPAddress(let fileID, let client, let ipAddress): + kind = "fileAppIPAddress"; values = [fileID.rawValue, client.rawValue, ipAddress.rawValue] + case .fileDomains(let fileID): + kind = "fileDomains"; values = [fileID.rawValue] + case .fileDomain(let fileID, let domain): + kind = "fileDomain"; values = [fileID.rawValue, domain.rawValue, String(domain.isMissingDomain)] + case .fileIPAddress(let fileID, let ipAddress): + kind = "fileIPAddress"; values = [fileID.rawValue, ipAddress.rawValue] + case .apps: + kind = "apps"; values = [] + case .app(let client): + kind = "app"; values = [client.rawValue] + case .appDomain(let client, let domain): + kind = "appDomain"; values = [client.rawValue, domain.rawValue, String(domain.isMissingDomain)] + case .appIPAddress(let client, let ipAddress): + kind = "appIPAddress"; values = [client.rawValue, ipAddress.rawValue] + case .domains: + kind = "domains"; values = [] + case .domain(let domain): + kind = "domain"; values = [domain.rawValue, String(domain.isMissingDomain)] + case .ipAddress(let ipAddress): + kind = "ipAddress"; values = [ipAddress.rawValue] + } + } + + func selection() -> PacketSourceListSelection? { + switch kind { + case "allPackets": + return .allPackets + case "pinned": + return .pinned + case "pinnedItem": + guard let pinID = value(0) else { return nil } + return .pinnedItem(PacketPinID(rawValue: pinID)) + case "pinnedItemDomain": + guard let pinID = value(0), let domain = domainKey(startingAt: 1) else { return nil } + return .pinnedItemDomain(PacketPinID(rawValue: pinID), domain) + case "pinnedItemIPAddress": + guard let pinID = value(0), let ipAddress = ipAddressKey(1) else { return nil } + return .pinnedItemIPAddress(PacketPinID(rawValue: pinID), ipAddress) + case "saved": + return .saved + case "files": + return .files + case "file": + guard let fileID = fileID(0) else { return nil } + return .file(fileID) + case "fileApps": + guard let fileID = fileID(0) else { return nil } + return .fileApps(fileID) + case "fileApp": + guard let fileID = fileID(0), let client = clientKey(1) else { return nil } + return .fileApp(fileID, client) + case "fileAppDomain": + guard let fileID = fileID(0), let client = clientKey(1), let domain = domainKey(startingAt: 2) else { return nil } + return .fileAppDomain(fileID, client, domain) + case "fileAppIPAddress": + guard let fileID = fileID(0), let client = clientKey(1), let ipAddress = ipAddressKey(2) else { return nil } + return .fileAppIPAddress(fileID, client, ipAddress) + case "fileDomains": + guard let fileID = fileID(0) else { return nil } + return .fileDomains(fileID) + case "fileDomain": + guard let fileID = fileID(0), let domain = domainKey(startingAt: 1) else { return nil } + return .fileDomain(fileID, domain) + case "fileIPAddress": + guard let fileID = fileID(0), let ipAddress = ipAddressKey(1) else { return nil } + return .fileIPAddress(fileID, ipAddress) + case "apps": + return .apps + case "app": + guard let client = clientKey(0) else { return nil } + return .app(client) + case "appDomain": + guard let client = clientKey(0), let domain = domainKey(startingAt: 1) else { return nil } + return .appDomain(client, domain) + case "appIPAddress": + guard let client = clientKey(0), let ipAddress = ipAddressKey(1) else { return nil } + return .appIPAddress(client, ipAddress) + case "domains": + return .domains + case "domain": + guard let domain = domainKey(startingAt: 0) else { return nil } + return .domain(domain) + case "ipAddress": + guard let ipAddress = ipAddressKey(0) else { return nil } + return .ipAddress(ipAddress) + default: + return nil + } + } + + private func value(_ index: Int) -> String? { + values.indices.contains(index) ? values[index] : nil + } + + private func fileID(_ index: Int) -> ImportedCaptureFileID? { + value(index).map { ImportedCaptureFileID(rawValue: $0) } + } + + private func clientKey(_ index: Int) -> PacketSourceClientKey? { + value(index).map { PacketSourceClientKey(rawValue: $0) } + } + + private func ipAddressKey(_ index: Int) -> PacketSourceIPAddressKey? { + value(index).map { PacketSourceIPAddressKey(rawValue: $0) } + } + + private func domainKey(startingAt index: Int) -> PacketSourceDomainKey? { + guard let rawValue = value(index) else { + return nil + } + let isMissingDomain = value(index + 1).flatMap(Bool.init) ?? false + return PacketSourceDomainKey(rawValue: rawValue, isMissingDomain: isMissingDomain) + } +} + +struct TCPViewSessionState: Codable, Equatable { + let source: String? + let backingIdentity: String? + let importedFiles: [TCPViewSessionImportedFileRecord] + let importedPacketReferences: [TCPViewSessionImportedPacketReferenceRecord] + let pins: [PacketPin] + let savedPackets: [SavedPacketRecord] + let customFilters: [PacketCustomFilter] + let quickFilterSelection: PacketQuickFilterSelection + let structuredFilterGroup: PacketStructuredFilterGroup + let displayFilterText: String + let sourceListFilterText: String + let selectedPacketID: PacketSummary.ID? + let selectedSourceListSelection: TCPViewSessionSourceListSelectionRecord? + let workspaceMode: String + let inspectorTab: String + let inspectorPlacement: String + let isInspectorVisible: Bool + let isStructuredFilterVisible: Bool + let tableColumnLayout: PacketTableColumnLayout? + let importedFileProvenance: String? + let sourceMetadata: TCPViewSessionSourceMetadata? + + var importedCaptureFiles: [ImportedCaptureFile] { + importedFiles.map { $0.importedFile() } + } + + var importedPacketReferenceByID: [PacketSummary.ID: ImportedPacketReference] { + var references: [PacketSummary.ID: ImportedPacketReference] = [:] + for record in importedPacketReferences where references[record.packetID] == nil { + let reference = record.importedPacketReference() + references[reference.0] = reference.1 + } + return references + } +} + +struct TCPViewSessionSourceMetadata: Codable, Equatable { + let fileName: String? + let filePath: String? + let format: String? + let packetCount: Int +} + +struct TCPViewSessionExportSnapshot { + let packets: [PacketSummary] + let source: CaptureSource? + let backingIdentity: String? + let importedFiles: [ImportedCaptureFile] + let importedPacketReferenceByID: [PacketSummary.ID: ImportedPacketReference] + let pins: [PacketPin] + let savedPackets: [SavedPacketRecord] + let customFilters: [PacketCustomFilter] + let quickFilterSelection: PacketQuickFilterSelection + let structuredFilterGroup: PacketStructuredFilterGroup + let displayFilterText: String + let sourceListFilterText: String + let selectedPacketID: PacketSummary.ID? + let selectedSourceListSelection: PacketSourceListSelection + let workspaceMode: NetworkInspectorWorkspaceMode + let inspectorTab: PacketInspectorTab + let inspectorPlacement: NetworkInspectorPlacement + let isInspectorVisible: Bool + let isStructuredFilterVisible: Bool + let tableColumnLayout: PacketTableColumnLayout? + let sourceMetadata: TCPViewSessionSourceMetadata? + + var state: TCPViewSessionState { + TCPViewSessionState( + source: source?.rawValue, + backingIdentity: backingIdentity, + importedFiles: importedFiles.map(TCPViewSessionImportedFileRecord.init), + importedPacketReferences: importedPacketReferenceByID + .map { TCPViewSessionImportedPacketReferenceRecord(packetID: $0.key, reference: $0.value) } + .sorted { $0.packetID < $1.packetID }, + pins: pins, + savedPackets: savedPackets, + customFilters: customFilters, + quickFilterSelection: quickFilterSelection, + structuredFilterGroup: structuredFilterGroup, + displayFilterText: displayFilterText, + sourceListFilterText: sourceListFilterText, + selectedPacketID: selectedPacketID, + selectedSourceListSelection: TCPViewSessionSourceListSelectionRecord(selection: selectedSourceListSelection), + workspaceMode: workspaceMode.rawValue, + inspectorTab: inspectorTab.rawValue, + inspectorPlacement: inspectorPlacement.rawValue, + isInspectorVisible: isInspectorVisible, + isStructuredFilterVisible: isStructuredFilterVisible, + tableColumnLayout: tableColumnLayout, + importedFileProvenance: importedFiles.isEmpty ? nil : "Imported capture grouping is preserved for TCPViewer source-list reconstruction.", + sourceMetadata: sourceMetadata + ) + } +} + +enum TCPViewSessionClientStoreBuilder { + static func buildClientStore( + packets: [PacketSummary], + iconIDForClient: ((PacketClient) -> String?)? = nil + ) -> (records: [TCPViewSessionPacketRecord], clients: TCPViewSessionClientStore) { + var clientRecordByID: [String: TCPViewSessionClientRecord] = [:] + var clientOrder: [String] = [] + var packetRecords: [TCPViewSessionPacketRecord] = [] + packetRecords.reserveCapacity(packets.count) + + for (ordinal, packet) in packets.enumerated() { + let clientID = packet.client.map(stableClientID) + if let client = packet.client, let clientID, clientRecordByID[clientID] == nil { + clientRecordByID[clientID] = TCPViewSessionClientRecord( + id: clientID, + client: client, + iconID: iconIDForClient?(client) + ) + clientOrder.append(clientID) + } + + packetRecords.append(TCPViewSessionPacketRecord( + packetID: packet.id, + captureOrdinal: ordinal, + clientID: clientID, + packet: packet.tcpviewSessionPacketWithoutClient() + )) + } + + let clients = TCPViewSessionClientStore( + clients: clientOrder.compactMap { clientRecordByID[$0] } + ) + return (packetRecords, clients) + } + + static func rehydratePackets(records: [TCPViewSessionPacketRecord], clients: TCPViewSessionClientStore) -> [PacketSummary] { + var clientsByID: [String: PacketClient] = [:] + for record in clients.clients where clientsByID[record.id] == nil { + clientsByID[record.id] = record.client + } + return records + .sorted { $0.captureOrdinal < $1.captureOrdinal } + .map { record in + record.packet.tcpviewSessionPacketWithClient(record.clientID.flatMap { clientsByID[$0] }) + } + } + + static func stableClientID(for client: PacketClient) -> String { + let fingerprint = [ + "\(client.pid)", + client.name, + client.displayName, + client.executablePath ?? "", + client.bundleIdentifier ?? "", + client.bundlePath ?? "", + ].joined(separator: "\u{1f}") + return "client-\(fnv1a64Hex(fingerprint))" + } + + static func stableIconID(for iconPath: String) -> String { + "icon-\(fnv1a64Hex(iconPath))" + } + + private static func fnv1a64Hex(_ value: String) -> String { + var hash: UInt64 = 0xcbf29ce484222325 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash &*= 0x100000001b3 + } + return String(format: "%016llx", hash) + } +} + +extension PacketIngestState { + func tcpviewSessionClientIconFilePath(for client: PacketClient?) -> String? { + guard let client else { + return nil + } + + let clientID = TCPViewSessionClientStoreBuilder.stableClientID(for: client) + return sessionClientIconFilePathByClientID[clientID] + } +} + +extension PacketSummary { + func tcpviewSessionPacketWithoutClient() -> PacketSummary { + tcpviewSessionPacketWithClient(nil) + } + + func tcpviewSessionPacketWithClient(_ client: PacketClient?) -> PacketSummary { + PacketSummary( + id: id, + packetNumber: packetNumber, + timestamp: timestamp, + source: source, + interfaceID: interfaceID, + transportHint: transportHint, + protocolSummary: protocolSummary, + endpoints: endpoints, + originalLength: originalLength, + capturedLength: capturedLength, + streamID: streamID, + direction: direction, + tcpFlags: tcpFlags, + tcpPayloadLength: tcpPayloadLength, + infoSummary: infoSummary, + layers: layers, + decodeStatus: decodeStatus, + captureMetadata: captureMetadata, + sniDomainName: sniDomainName, + client: client + ) + } +} diff --git a/TCPViewer/Features/NetworkInspector/Services/TCPViewSessionImportService.swift b/TCPViewer/Features/NetworkInspector/Services/TCPViewSessionImportService.swift new file mode 100644 index 0000000..67767e2 --- /dev/null +++ b/TCPViewer/Features/NetworkInspector/Services/TCPViewSessionImportService.swift @@ -0,0 +1,825 @@ +// +// TCPViewSessionImportService.swift +// TCPViewer +// +// Created by Proxyman LLC on 15/6/26. +// + +import Foundation +import PcapPlusPlusCore +import ZIPFoundation + +struct TCPViewSessionPackageContents { + let sourceURL: URL + let extractionDirectoryURL: URL + let packageDirectoryURL: URL + let captureFileURL: URL + let manifest: TCPViewSessionManifest + let packetRecords: [TCPViewSessionPacketRecord] + let clientStore: TCPViewSessionClientStore + let annotations: TCPViewSessionAnnotations + let state: TCPViewSessionState + let packets: [PacketSummary] + let clientIconFilePathByClientID: [String: String] + let importReport: TCPViewSessionImportReport +} + +final class TCPViewSessionImportService { + private struct PacketRecordDecodeResult { + let records: [TCPViewSessionPacketRecord] + let failedLineCount: Int + } + + private struct SanitizedSidecars { + let packetRecords: [TCPViewSessionPacketRecord] + let clientStore: TCPViewSessionClientStore + let annotations: TCPViewSessionAnnotations + let state: TCPViewSessionState + let importReport: TCPViewSessionImportReport + } + + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + // Validate ZIP entries before extraction so a malformed package never writes outside temp. + func loadPackage(at url: URL) throws -> TCPViewSessionPackageContents { + try validateArchive(at: url) + + let extractionRoot = fileManager.temporaryDirectory + .appendingPathComponent("TCPViewSessionImport-\(UUID().uuidString)", isDirectory: true) + do { + try fileManager.createDirectory(at: extractionRoot, withIntermediateDirectories: true) + try fileManager.unzipItem(at: url, to: extractionRoot) + + let packageDirectoryURL = extractionRoot.appendingPathComponent(TCPViewSessionFormat.packageDirectoryName, isDirectory: true) + let manifest: TCPViewSessionManifest = try decodeJSON( + TCPViewSessionManifest.self, + from: packageDirectoryURL.appendingPathComponent(TCPViewSessionFormat.manifestPath) + ) + try validateManifest(manifest) + + let decodedPacketRecords = try decodePacketRecords( + from: packageDirectoryURL.appendingPathComponent(TCPViewSessionFormat.packetsPath) + ) + let clients: TCPViewSessionClientStore = try decodeJSON( + TCPViewSessionClientStore.self, + from: packageDirectoryURL.appendingPathComponent(TCPViewSessionFormat.clientsPath) + ) + let annotations: TCPViewSessionAnnotations = try decodeJSON( + TCPViewSessionAnnotations.self, + from: packageDirectoryURL.appendingPathComponent(TCPViewSessionFormat.annotationsPath) + ) + let state: TCPViewSessionState = try decodeJSON( + TCPViewSessionState.self, + from: packageDirectoryURL.appendingPathComponent(TCPViewSessionFormat.statePath) + ) + let captureFileURL = packageDirectoryURL.appendingPathComponent(TCPViewSessionFormat.capturePath) + guard fileManager.fileExists(atPath: captureFileURL.path) else { + throw invalidPackage("The session package is missing capture.pcapng.") + } + let sidecars = try sanitizeSidecars( + decodedPacketRecords: decodedPacketRecords, + clients: clients, + annotations: annotations, + state: state, + packetCount: manifest.packetCount + ) + + let clientIconFilePathByClientID = resolveClientIconFilePaths( + clients: sidecars.clientStore, + packageDirectoryURL: packageDirectoryURL + ) + let packets = TCPViewSessionClientStoreBuilder.rehydratePackets(records: sidecars.packetRecords, clients: sidecars.clientStore) + return TCPViewSessionPackageContents( + sourceURL: url, + extractionDirectoryURL: extractionRoot, + packageDirectoryURL: packageDirectoryURL, + captureFileURL: captureFileURL, + manifest: manifest, + packetRecords: sidecars.packetRecords, + clientStore: sidecars.clientStore, + annotations: sidecars.annotations, + state: sidecars.state, + packets: packets, + clientIconFilePathByClientID: clientIconFilePathByClientID, + importReport: sidecars.importReport + ) + } catch { + try? fileManager.removeItem(at: extractionRoot) + throw error + } + } + + private func validateArchive(at url: URL) throws { + let archive: Archive + do { + archive = try Archive(url: url, accessMode: .read) + } catch { + throw invalidPackage("The TCPViewer session file is not a readable ZIP archive.") + } + + var files = Set() + let rootPrefix = "\(TCPViewSessionFormat.packageDirectoryName)/" + for entry in archive { + guard entry.type != .symlink else { + throw invalidPackage("The TCPViewer session file contains an unsupported symbolic link.") + } + + let path = entry.path + guard !path.hasPrefix("/"), + path == TCPViewSessionFormat.packageDirectoryName || path.hasPrefix(rootPrefix), + !path.split(separator: "/").contains("..") else { + throw invalidPackage("The TCPViewer session file contains an unsafe path.") + } + + guard entry.type == .file else { + continue + } + + let relativePath = String(path.dropFirst(rootPrefix.count)) + files.insert(relativePath) + } + + for requiredFile in requiredFiles { + guard files.contains(requiredFile) else { + throw invalidPackage("The TCPViewer session file is missing \(requiredFile).") + } + } + } + + private var requiredFiles: [String] { + [ + TCPViewSessionFormat.manifestPath, + TCPViewSessionFormat.capturePath, + TCPViewSessionFormat.packetsPath, + TCPViewSessionFormat.clientsPath, + TCPViewSessionFormat.annotationsPath, + TCPViewSessionFormat.statePath, + ] + } + + private func validateManifest(_ manifest: TCPViewSessionManifest) throws { + guard manifest.magic == TCPViewSessionFormat.magic else { + throw invalidPackage("The selected file is not a TCPViewer session.") + } + guard manifest.schemaVersion <= TCPViewSessionFormat.schemaVersion, + manifest.minimumCompatibleSchemaVersion <= TCPViewSessionFormat.schemaVersion else { + throw invalidPackage("This TCPViewer session uses an unsupported schema version.") + } + guard manifest.packetCount >= 0 else { + throw invalidPackage("The session manifest contains an invalid packet count.") + } + } + + private func sanitizeSidecars( + decodedPacketRecords: PacketRecordDecodeResult, + clients: TCPViewSessionClientStore, + annotations: TCPViewSessionAnnotations, + state: TCPViewSessionState, + packetCount: Int + ) throws -> SanitizedSidecars { + let clientStore = sanitizedClientStore(clients) + let clientIDs = Set(clientStore.clients.map(\.id)) + var packetIDs = Set() + var ordinals = Set() + var validRecords: [TCPViewSessionPacketRecord] = [] + validRecords.reserveCapacity(decodedPacketRecords.records.count) + var skippedDecodedRecordCount = 0 + + for record in decodedPacketRecords.records { + guard isValidPacketRecord( + record, + packetCount: packetCount, + clientIDs: clientIDs, + packetIDs: &packetIDs, + ordinals: &ordinals + ) else { + skippedDecodedRecordCount += 1 + continue + } + + validRecords.append(record) + } + + let validPacketIDs = Set(validRecords.map(\.packetID)) + let failedFlowCount = max( + decodedPacketRecords.failedLineCount + skippedDecodedRecordCount, + max(0, packetCount - validRecords.count) + ) + let sanitizedState = sanitizeState(state, validPacketIDs: validPacketIDs) + let sanitizedAnnotations = sanitizeAnnotations(annotations, validPacketIDs: validPacketIDs) + return SanitizedSidecars( + packetRecords: validRecords, + clientStore: clientStore, + annotations: sanitizedAnnotations, + state: sanitizedState, + importReport: TCPViewSessionImportReport( + importedFlowCount: validRecords.count, + failedFlowCount: failedFlowCount + ) + ) + } + + private func sanitizedClientStore(_ clients: TCPViewSessionClientStore) -> TCPViewSessionClientStore { + var seenClientIDs = Set() + var sanitizedClients: [TCPViewSessionClientRecord] = [] + sanitizedClients.reserveCapacity(clients.clients.count) + for client in clients.clients { + guard !client.id.isEmpty, + seenClientIDs.insert(client.id).inserted else { + continue + } + + sanitizedClients.append(TCPViewSessionClientRecord( + id: client.id, + client: client.client, + iconID: client.iconID.flatMap { isValidIconID($0) ? $0 : nil } + )) + } + return TCPViewSessionClientStore(clients: sanitizedClients) + } + + private func isValidPacketRecord( + _ record: TCPViewSessionPacketRecord, + packetCount: Int, + clientIDs: Set, + packetIDs: inout Set, + ordinals: inout Set + ) -> Bool { + guard record.packetID == record.packet.id else { + return false + } + guard record.captureOrdinal >= 0, + record.captureOrdinal < packetCount else { + return false + } + guard !packetIDs.contains(record.packetID) else { + return false + } + guard !ordinals.contains(record.captureOrdinal) else { + return false + } + if let clientID = record.clientID, !clientIDs.contains(clientID) { + return false + } + packetIDs.insert(record.packetID) + ordinals.insert(record.captureOrdinal) + return true + } + + private func sanitizeAnnotations( + _ annotations: TCPViewSessionAnnotations, + validPacketIDs: Set + ) -> TCPViewSessionAnnotations { + var seenPacketIDs = Set() + let sanitized = annotations.annotations.filter { annotation in + validPacketIDs.contains(annotation.packetID) && + seenPacketIDs.insert(annotation.packetID).inserted + } + return TCPViewSessionAnnotations(annotations: sanitized) + } + + private func sanitizeState( + _ state: TCPViewSessionState, + validPacketIDs: Set + ) -> TCPViewSessionState { + var importedFileIDs = Set() + var importedFiles: [TCPViewSessionImportedFileRecord] = [] + for file in state.importedFiles { + guard importedFileIDs.insert(file.fileID).inserted else { + continue + } + + let packetIDs = file.packetIDs.filter { validPacketIDs.contains($0) } + guard !packetIDs.isEmpty else { + continue + } + + importedFiles.append(TCPViewSessionImportedFileRecord(file: ImportedCaptureFile( + id: ImportedCaptureFileID(rawValue: file.fileID), + url: URL(fileURLWithPath: file.urlPath), + displayName: file.displayName, + packetIDs: packetIDs + ))) + } + + let validImportedFileIDs = Set(importedFiles.map(\.fileID)) + let selectedSourceListSelection = sanitizeSourceListSelection( + state.selectedSourceListSelection, + validImportedFileIDs: validImportedFileIDs + ) + return TCPViewSessionState( + source: state.source, + backingIdentity: state.backingIdentity, + importedFiles: importedFiles, + importedPacketReferences: sanitizedImportedPacketReferences( + state.importedPacketReferences, + validPacketIDs: validPacketIDs, + validImportedFileIDs: validImportedFileIDs + ), + pins: uniquePins(state.pins), + savedPackets: sanitizedSavedPackets(state.savedPackets, validPacketIDs: validPacketIDs), + customFilters: uniqueCustomFilters(state.customFilters), + quickFilterSelection: state.quickFilterSelection, + structuredFilterGroup: state.structuredFilterGroup, + displayFilterText: state.displayFilterText, + sourceListFilterText: state.sourceListFilterText, + selectedPacketID: state.selectedPacketID.flatMap { validPacketIDs.contains($0) ? $0 : nil }, + selectedSourceListSelection: selectedSourceListSelection, + workspaceMode: state.workspaceMode, + inspectorTab: state.inspectorTab, + inspectorPlacement: state.inspectorPlacement, + isInspectorVisible: state.isInspectorVisible, + isStructuredFilterVisible: state.isStructuredFilterVisible, + tableColumnLayout: state.tableColumnLayout, + importedFileProvenance: importedFiles.isEmpty ? nil : state.importedFileProvenance, + sourceMetadata: state.sourceMetadata.map { metadata in + TCPViewSessionSourceMetadata( + fileName: metadata.fileName, + filePath: metadata.filePath, + format: metadata.format, + packetCount: validPacketIDs.count + ) + } + ) + } + + private func sanitizedImportedPacketReferences( + _ references: [TCPViewSessionImportedPacketReferenceRecord], + validPacketIDs: Set, + validImportedFileIDs: Set + ) -> [TCPViewSessionImportedPacketReferenceRecord] { + var seenPacketIDs = Set() + return references.filter { reference in + validPacketIDs.contains(reference.packetID) && + validImportedFileIDs.contains(reference.fileID) && + seenPacketIDs.insert(reference.packetID).inserted + } + } + + private func sanitizedSavedPackets( + _ records: [SavedPacketRecord], + validPacketIDs: Set + ) -> [SavedPacketRecord] { + var seenPacketIDs = Set() + return records.filter { record in + validPacketIDs.contains(record.packet.id) && + seenPacketIDs.insert(record.packet.id).inserted + } + } + + private func uniquePins(_ pins: [PacketPin]) -> [PacketPin] { + var seenPinIDs = Set() + return pins.filter { seenPinIDs.insert($0.id).inserted } + } + + private func uniqueCustomFilters(_ filters: [PacketCustomFilter]) -> [PacketCustomFilter] { + var seenFilterIDs = Set() + return filters.filter { seenFilterIDs.insert($0.id).inserted } + } + + private func sanitizeSourceListSelection( + _ selection: TCPViewSessionSourceListSelectionRecord?, + validImportedFileIDs: Set + ) -> TCPViewSessionSourceListSelectionRecord? { + guard let selection, + selection.kind.hasPrefix("file"), + let fileID = selection.values.first else { + return selection + } + return validImportedFileIDs.contains(fileID) + ? selection + : TCPViewSessionSourceListSelectionRecord(selection: .allPackets) + } + + private func resolveClientIconFilePaths( + clients: TCPViewSessionClientStore, + packageDirectoryURL: URL + ) -> [String: String] { + let iconsDirectoryURL = packageDirectoryURL.appendingPathComponent(TCPViewSessionFormat.iconsDirectoryPath, isDirectory: true) + var iconPathByClientID: [String: String] = [:] + for client in clients.clients { + guard let iconID = client.iconID, + isValidIconID(iconID) else { + continue + } + + let iconURL = iconsDirectoryURL.appendingPathComponent("\(iconID).png") + if fileManager.fileExists(atPath: iconURL.path) { + iconPathByClientID[client.id] = iconURL.path + } + } + return iconPathByClientID + } + + private func isValidIconID(_ iconID: String) -> Bool { + !iconID.isEmpty && + iconID.utf8.allSatisfy { byte in + (48...57).contains(Int(byte)) || + (65...90).contains(Int(byte)) || + (97...122).contains(Int(byte)) || + byte == 45 || + byte == 95 + } + } + + private func decodePacketRecords(from url: URL) throws -> PacketRecordDecodeResult { + let data = try Data(contentsOf: url) + guard let payload = String(data: data, encoding: .utf8) else { + throw invalidPackage("The packet sidecar is not valid UTF-8.") + } + + let decoder = jsonDecoder() + var records: [TCPViewSessionPacketRecord] = [] + var failedLineCount = 0 + for line in payload.split(separator: "\n", omittingEmptySubsequences: true) { + guard let lineData = String(line).data(using: .utf8) else { + failedLineCount += 1 + continue + } + do { + records.append(try decoder.decode(TCPViewSessionPacketRecord.self, from: lineData)) + } catch { + failedLineCount += 1 + } + } + return PacketRecordDecodeResult(records: records, failedLineCount: failedLineCount) + } + + private func decodeJSON(_ type: Value.Type, from url: URL) throws -> Value { + try jsonDecoder().decode(type, from: Data(contentsOf: url)) + } + + private func jsonDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + private func invalidPackage(_ message: String) -> TCPViewerCoreError { + TCPViewerCoreError(code: .offlineFileOpenFailed, message: message) + } +} + +final class TCPViewSessionOfflineDocument: OfflineCaptureDocumentProviding { + var eventHandler: PacketIngestEventHandler? + + private(set) var state: TCPViewSessionState + private(set) var importedFiles: [ImportedCaptureFile] + private(set) var importedPacketReferenceByID: [PacketSummary.ID: ImportedPacketReference] + let clientIconFilePathByClientID: [String: String] + private(set) var importReport: TCPViewSessionImportReport + + private let contents: TCPViewSessionPackageContents + private let core: any TCPViewerCoreProviding + private let fileManager: FileManager + private var innerDocument: (any OfflineCaptureDocumentProviding)? + private var innerPacketIDBySessionID: [PacketSummary.ID: PacketSummary.ID] = [:] + private var sessionPacketByID: [PacketSummary.ID: PacketSummary] = [:] + private var openedPackets: [PacketSummary] + private var progress: PacketLoadProgress + private var metadata: CaptureDocumentMetadata + + init( + contents: TCPViewSessionPackageContents, + core: any TCPViewerCoreProviding, + fileManager: FileManager = .default + ) { + self.contents = contents + self.core = core + self.fileManager = fileManager + self.state = contents.state + self.importedFiles = contents.state.importedCaptureFiles + self.importedPacketReferenceByID = contents.state.importedPacketReferenceByID + self.clientIconFilePathByClientID = contents.clientIconFilePathByClientID + self.importReport = contents.importReport + self.openedPackets = contents.packets + self.progress = PacketLoadProgress( + phase: .idle, + loadedPacketCount: 0, + message: "TCPViewer session is ready to open." + ) + self.metadata = CaptureDocumentMetadata( + format: .pcapng, + captureApplication: contents.manifest.applicationName, + fileComment: "TCPViewer Session" + ) + } + + deinit { + try? fileManager.removeItem(at: contents.extractionDirectoryURL) + } + + func open(completion: @escaping TCPViewerCompletion<[PacketSummary]>) { + progress = PacketLoadProgress( + phase: .loading, + loadedPacketCount: 0, + message: "Opening \(contents.sourceURL.lastPathComponent)..." + ) + eventHandler?(.success(.loadProgressChanged(progress))) + + core.openOfflineCaptureDocument(at: contents.captureFileURL) { [weak self] result in + guard let self else { + completion(.failure(Self.cancelledError())) + return + } + + switch result { + case .success(let document): + self.innerDocument = document + document.open { [weak self] result in + guard let self else { + completion(.failure(Self.cancelledError())) + return + } + self.finishInnerOpen(result, completion: completion) + } + case .failure(let error): + self.progress = PacketLoadProgress( + phase: .failed, + loadedPacketCount: 0, + message: Self.errorMessage(error) + ) + completion(.failure(error)) + } + } + } + + func reopen(completion: @escaping TCPViewerCompletion<[PacketSummary]>) { + guard let innerDocument else { + open(completion: completion) + return + } + + progress = PacketLoadProgress( + phase: .loading, + loadedPacketCount: 0, + message: "Reopening \(contents.sourceURL.lastPathComponent)..." + ) + innerDocument.reopen { [weak self] result in + self?.finishInnerOpen(result, completion: completion) + } + } + + func cancelLoading(completion: (() -> Void)?) { + guard let innerDocument else { + completion?() + return + } + + innerDocument.cancelLoading(completion: completion) + } + + func inspectPacket(id: PacketSummary.ID, completion: @escaping TCPViewerCompletion) { + guard let innerDocument, + let innerID = innerPacketIDBySessionID[id] else { + completion(.failure(Self.unavailableBackingError())) + return + } + + innerDocument.inspectPacket(id: innerID) { [weak self] result in + guard let self else { + completion(.failure(Self.cancelledError())) + return + } + + completion(result.map { inspection in + let sessionPacket = self.sessionPacketByID[id] + return PacketInspection( + packetID: id, + packetNumber: sessionPacket?.packetNumber ?? inspection.packetNumber, + rawBytes: inspection.rawBytes, + byteViews: inspection.byteViews, + detailNodes: inspection.detailNodes, + decodeStatus: inspection.decodeStatus + ) + }) + } + } + + func save(completion: @escaping TCPViewerVoidCompletion) { + completion(.failure(Self.readOnlyError())) + } + + func save(to url: URL, format: CaptureFileFormat, completion: @escaping TCPViewerVoidCompletion) { + completion(.failure(Self.readOnlyError())) + } + + func exportPackets( + withIDs identifiers: [PacketSummary.ID], + to url: URL, + format: CaptureFileFormat, + progress: PacketExportProgressHandler?, + shouldCancel: PacketExportCancellationCheck?, + completion: @escaping TCPViewerVoidCompletion + ) { + guard let innerDocument else { + completion(.failure(Self.unavailableBackingError())) + return + } + + let innerIDs = identifiers.compactMap { innerPacketIDBySessionID[$0] } + guard innerIDs.count == identifiers.count else { + completion(.failure(Self.unavailableBackingError())) + return + } + + innerDocument.exportPackets( + withIDs: innerIDs, + to: url, + format: format, + progress: progress, + shouldCancel: shouldCancel, + completion: completion + ) + } + + func currentURL() -> URL { + contents.sourceURL + } + + func currentMetadata() -> CaptureDocumentMetadata { + metadata + } + + func packetSummaries() -> [PacketSummary] { + openedPackets + } + + func loadProgress() -> PacketLoadProgress { + progress + } + + private func finishInnerOpen(_ result: Result<[PacketSummary], Error>, completion: @escaping TCPViewerCompletion<[PacketSummary]>) { + switch result { + case .success(let innerPackets): + let mapping = rebuildPacketMapping(innerPackets: innerPackets) + openedPackets = mapping.packets + importReport = importReport.addingFailedFlows(mapping.failedFlowCount) + if let innerDocument { + let innerMetadata = innerDocument.currentMetadata() + metadata = CaptureDocumentMetadata( + format: .pcapng, + operatingSystem: innerMetadata.operatingSystem, + hardware: innerMetadata.hardware, + captureApplication: contents.manifest.applicationName, + fileComment: innerMetadata.fileComment ?? "TCPViewer Session" + ) + } + progress = PacketLoadProgress( + phase: .completed, + loadedPacketCount: openedPackets.count, + message: progressMessage(loadedPacketCount: openedPackets.count) + ) + eventHandler?(.success(.documentMetadataChanged(metadata))) + eventHandler?(.success(.packetBatch(openedPackets, disposition: .replace))) + eventHandler?(.success(.loadProgressChanged(progress))) + eventHandler?(.success(.documentStateChanged(phase: .loaded, message: progress.message))) + completion(.success(openedPackets)) + case .failure(let error): + progress = PacketLoadProgress( + phase: .failed, + loadedPacketCount: 0, + message: Self.errorMessage(error) + ) + completion(.failure(error)) + } + } + + private func rebuildPacketMapping(innerPackets: [PacketSummary]) -> (packets: [PacketSummary], failedFlowCount: Int) { + innerPacketIDBySessionID.removeAll(keepingCapacity: true) + sessionPacketByID.removeAll(keepingCapacity: true) + var sessionPacketByRecordID: [PacketSummary.ID: PacketSummary] = [:] + for packet in contents.packets where sessionPacketByRecordID[packet.id] == nil { + sessionPacketByRecordID[packet.id] = packet + } + + var mappedPackets: [PacketSummary] = [] + mappedPackets.reserveCapacity(contents.packets.count) + var failedFlowCount = 0 + for record in contents.packetRecords.sorted(by: { $0.captureOrdinal < $1.captureOrdinal }) { + guard innerPackets.indices.contains(record.captureOrdinal), + let sessionPacket = sessionPacketByRecordID[record.packetID] else { + failedFlowCount += 1 + continue + } + + let innerPacket = innerPackets[record.captureOrdinal] + innerPacketIDBySessionID[sessionPacket.id] = innerPacket.id + sessionPacketByID[sessionPacket.id] = sessionPacket + mappedPackets.append(sessionPacket) + } + + trimDocumentState(to: Set(mappedPackets.map(\.id))) + return (mappedPackets, failedFlowCount) + } + + private func trimDocumentState(to packetIDs: Set) { + importedFiles = contents.state.importedCaptureFiles.compactMap { file in + let remainingPacketIDs = file.packetIDs.filter { packetIDs.contains($0) } + guard !remainingPacketIDs.isEmpty else { + return nil + } + return ImportedCaptureFile( + id: file.id, + url: file.url, + displayName: file.displayName, + packetIDs: remainingPacketIDs + ) + } + importedPacketReferenceByID = contents.state.importedPacketReferenceByID.filter { packetIDs.contains($0.key) } + let importedFileRecords = importedFiles.map(TCPViewSessionImportedFileRecord.init) + let validImportedFileIDs = Set(importedFileRecords.map(\.fileID)) + state = TCPViewSessionState( + source: state.source, + backingIdentity: state.backingIdentity, + importedFiles: importedFileRecords, + importedPacketReferences: importedPacketReferenceByID + .map { TCPViewSessionImportedPacketReferenceRecord(packetID: $0.key, reference: $0.value) } + .sorted { $0.packetID < $1.packetID }, + pins: state.pins, + savedPackets: state.savedPackets.filter { packetIDs.contains($0.packet.id) }, + customFilters: state.customFilters, + quickFilterSelection: state.quickFilterSelection, + structuredFilterGroup: state.structuredFilterGroup, + displayFilterText: state.displayFilterText, + sourceListFilterText: state.sourceListFilterText, + selectedPacketID: state.selectedPacketID.flatMap { packetIDs.contains($0) ? $0 : nil }, + selectedSourceListSelection: sessionSelection( + state.selectedSourceListSelection, + validImportedFileIDs: validImportedFileIDs + ), + workspaceMode: state.workspaceMode, + inspectorTab: state.inspectorTab, + inspectorPlacement: state.inspectorPlacement, + isInspectorVisible: state.isInspectorVisible, + isStructuredFilterVisible: state.isStructuredFilterVisible, + tableColumnLayout: state.tableColumnLayout, + importedFileProvenance: importedFileRecords.isEmpty ? nil : state.importedFileProvenance, + sourceMetadata: state.sourceMetadata.map { metadata in + TCPViewSessionSourceMetadata( + fileName: metadata.fileName, + filePath: metadata.filePath, + format: metadata.format, + packetCount: packetIDs.count + ) + } + ) + } + + private func sessionSelection( + _ selection: TCPViewSessionSourceListSelectionRecord?, + validImportedFileIDs: Set + ) -> TCPViewSessionSourceListSelectionRecord? { + guard let selection, + selection.kind.hasPrefix("file"), + let fileID = selection.values.first else { + return selection + } + return validImportedFileIDs.contains(fileID) + ? selection + : TCPViewSessionSourceListSelectionRecord(selection: .allPackets) + } + + private func progressMessage(loadedPacketCount: Int) -> String { + let baseMessage = "Loaded \(loadedPacketCount) packets from \(contents.sourceURL.lastPathComponent)." + guard importReport.hasFailedFlows else { + return baseMessage + } + + let reason = importReport.failedFlowCount == 1 ? "it was malformed" : "they were malformed" + return "\(baseMessage) Skipped \(flowCountText(importReport.failedFlowCount)) because \(reason)." + } + + private func flowCountText(_ count: Int) -> String { + "\(count) malformed flow\(count == 1 ? "" : "s")" + } + + private static func readOnlyError() -> TCPViewerCoreError { + TCPViewerCoreError( + code: .offlineFileSaveFailed, + message: "TCPViewer sessions are read-only. Export the packets or create a new session file instead." + ) + } + + private static func unavailableBackingError() -> TCPViewerCoreError { + TCPViewerCoreError( + code: .offlineFileOpenFailed, + message: "The TCPViewer session backing pcapng is not available." + ) + } + + private static func cancelledError() -> TCPViewerCoreError { + TCPViewerCoreError(code: .operationCancelled, message: "The TCPViewer session operation was cancelled.") + } + + private static func errorMessage(_ error: Error) -> String { + if let tcpviewerError = error as? TCPViewerCoreError { + return tcpviewerError.message + } + return error.localizedDescription + } +} diff --git a/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift b/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift index 373dac0..e6292b3 100644 --- a/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift +++ b/TCPViewer/Features/NetworkInspector/ViewModels/NetworkInspectorViewModel.swift @@ -232,7 +232,10 @@ private enum PacketTableContentBuilder { } let rowIndex = store.rows.count - store.rows.append(rowTimingState.row(for: packet)) + store.rows.append(rowTimingState.row( + for: packet, + clientIconFilePath: clientIconFilePath(for: packet, in: input.ingestState) + )) store.rowIDs.append(packet.id) store.visiblePacketRowIndexByID[packet.id] = rowIndex } @@ -245,6 +248,10 @@ private enum PacketTableContentBuilder { ) } + private static func clientIconFilePath(for packet: PacketSummary, in ingestState: PacketIngestState) -> String? { + ingestState.tcpviewSessionClientIconFilePath(for: packet.client) + } + private static func packets(from input: PacketTableBuildInput) -> [PacketSummary] { switch input.signature.sourceListSelection { case .saved: @@ -557,7 +564,10 @@ private struct PacketTableContentCache { } let rowIndex = store.rows.count - store.rows.append(rowTimingState.row(for: packet)) + store.rows.append(rowTimingState.row( + for: packet, + clientIconFilePath: clientIconFilePath(for: packet, in: ingestState) + )) store.rowIDs.append(packet.id) store.visiblePacketRowIndexByID[packet.id] = rowIndex } @@ -785,7 +795,10 @@ private struct PacketTableContentCache { guard isVisibleNow, let rowIndex = store.visiblePacketRowIndexByID[packetID] else { continue } - store.rows[rowIndex] = rowTimingState.row(for: packet) + store.rows[rowIndex] = rowTimingState.row( + for: packet, + clientIconFilePath: clientIconFilePath(for: packet, in: ingestState) + ) reloadIndexes.insert(rowIndex) } @@ -857,6 +870,10 @@ private struct PacketTableContentCache { } } + private func clientIconFilePath(for packet: PacketSummary, in ingestState: PacketIngestState) -> String? { + ingestState.tcpviewSessionClientIconFilePath(for: packet.client) + } + private func matches( _ packet: PacketSummary, selection: PacketSourceListSelection, @@ -928,6 +945,8 @@ final class NetworkInspectorViewModel { private let structuredFilterService: PacketStructuredFilterService private let structuredFilterStore: PacketStructuredFilterStore private let packetExportService: PacketExportService + private let tcpViewSessionExportService: any TCPViewSessionExportWriting + private let packetTableColumnLayoutStore: PacketTableColumnLayoutStore private let packetTableFilterQueue = DispatchQueue(label: "com.proxyman.TCPViewer.packet-table-filter") private let packetTableAsyncRebuildThreshold: Int private let packetTableFilterBuildHook: (@Sendable () -> Void)? @@ -939,6 +958,7 @@ final class NetworkInspectorViewModel { private var packetTableFilterGeneration = 0 private var isPacketTableFiltering = false private var selectsFirstVisiblePacketAfterFiltering = false + private var pendingSessionImportReport: TCPViewSessionImportReport? // Trailing-edge debounce for delegate-driven rebuilds. Live ingest fires the controller delegate // up to ~10 Hz; coalescing to ~12 Hz keeps the UI feeling live without burning CPU on redundant @@ -957,6 +977,7 @@ final class NetworkInspectorViewModel { private var structuredFilterGroup: PacketStructuredFilterGroup private var selectedCustomFilterID: PacketCustomFilter.ID? private var helperOnboardingDismissed = false + private var isUsingSessionDocumentState = false convenience init(userDefaults: UserDefaults = .standard) { self.init(services: .foundation, userDefaults: userDefaults) @@ -972,6 +993,7 @@ final class NetworkInspectorViewModel { customFilterService: PacketCustomFilterService = PacketCustomFilterService(), structuredFilterService: PacketStructuredFilterService = PacketStructuredFilterService(), packetExportService: PacketExportService? = nil, + tcpViewSessionExportService: (any TCPViewSessionExportWriting)? = nil, packetTableAsyncRebuildThreshold: Int = 5_000, packetTableFilterBuildHook: (@Sendable () -> Void)? = nil ) { @@ -988,6 +1010,8 @@ final class NetworkInspectorViewModel { self.structuredFilterService = structuredFilterService self.structuredFilterStore = PacketStructuredFilterStore(defaults: userDefaults) self.packetExportService = packetExportService ?? PacketExportService(defaults: userDefaults) + self.tcpViewSessionExportService = tcpViewSessionExportService ?? TCPViewSessionExportService() + self.packetTableColumnLayoutStore = PacketTableColumnLayoutStore(defaults: userDefaults) self.packetTableAsyncRebuildThreshold = max(1, packetTableAsyncRebuildThreshold) self.packetTableFilterBuildHook = packetTableFilterBuildHook self.inspectorPlacement = preferences.inspectorPlacement @@ -1077,6 +1101,17 @@ final class NetworkInspectorViewModel { controller.networkHelperToolSnapshot } + func consumeSessionImportReportWithFailures() -> TCPViewSessionImportReport? { + guard let report = pendingSessionImportReport, + report.hasFailedFlows else { + pendingSessionImportReport = nil + return nil + } + + pendingSessionImportReport = nil + return report + } + func dismissNetworkHelperOnboarding() { helperOnboardingDismissed = true rebuildSnapshot() @@ -1174,7 +1209,9 @@ final class NetworkInspectorViewModel { func updateDisplayFilterText(_ text: String) { displayFilterText = text - preferences.persistDisplayFilter(text) + if !isUsingSessionDocumentState { + preferences.persistDisplayFilter(text) + } rebuildSnapshot() } @@ -1185,7 +1222,9 @@ final class NetworkInspectorViewModel { func updateStructuredFilterGroup(_ group: PacketStructuredFilterGroup) { structuredFilterGroup = PacketStructuredFilterGroup(filters: group.filters, operator: group.operator) selectedCustomFilterID = nil - structuredFilterStore.save(structuredFilterGroup) + if !isUsingSessionDocumentState { + structuredFilterStore.save(structuredFilterGroup) + } rebuildSnapshot() } @@ -1195,7 +1234,9 @@ final class NetworkInspectorViewModel { } isStructuredFilterVisible = isVisible - preferences.persistStructuredFilterVisible(isVisible) + if !isUsingSessionDocumentState { + preferences.persistStructuredFilterVisible(isVisible) + } rebuildSnapshot() } @@ -1226,17 +1267,23 @@ final class NetworkInspectorViewModel { if isStructuredFilterVisible, selectedCustomFilterID == filter.id { isStructuredFilterVisible = false - preferences.persistStructuredFilterVisible(false) + if !isUsingSessionDocumentState { + preferences.persistStructuredFilterVisible(false) + } rebuildSnapshot() return } structuredFilterGroup = PacketStructuredFilterGroup(filters: filter.group.filters, operator: filter.group.operator) - structuredFilterStore.save(structuredFilterGroup) + if !isUsingSessionDocumentState { + structuredFilterStore.save(structuredFilterGroup) + } selectedCustomFilterID = filter.id if !isStructuredFilterVisible { isStructuredFilterVisible = true - preferences.persistStructuredFilterVisible(true) + if !isUsingSessionDocumentState { + preferences.persistStructuredFilterVisible(true) + } } rebuildSnapshot() } @@ -1252,7 +1299,9 @@ final class NetworkInspectorViewModel { let replacementGroup = PacketStructuredFilterGroup(filters: group.filters, operator: group.operator) try customFilterService.updateGroup(id: id, group: replacementGroup) structuredFilterGroup = replacementGroup - structuredFilterStore.save(replacementGroup) + if !isUsingSessionDocumentState { + structuredFilterStore.save(replacementGroup) + } selectedCustomFilterID = id rebuildSnapshot() } @@ -1392,6 +1441,41 @@ final class NetworkInspectorViewModel { return uniquePins } + private func makeTCPViewSessionExportSnapshot() -> TCPViewSessionExportSnapshot { + let ingestState = controller.snapshot.packetIngestState + let documentState = controller.snapshot.documentState + let sourceMetadata = TCPViewSessionSourceMetadata( + fileName: documentState.fileURL?.lastPathComponent, + filePath: documentState.fileURL?.path, + format: documentState.format?.rawValue, + packetCount: ingestState.totalPacketCount + ) + + return TCPViewSessionExportSnapshot( + packets: ingestState.packets, + source: ingestState.source, + backingIdentity: ingestState.backingIdentity, + importedFiles: ingestState.importedFiles, + importedPacketReferenceByID: ingestState.importedPacketReferenceByID, + pins: pinService.pins(), + savedPackets: savedPacketService.records(), + customFilters: customFilterService.filters(), + quickFilterSelection: quickFilterService.selection, + structuredFilterGroup: structuredFilterGroup, + displayFilterText: displayFilterText, + sourceListFilterText: sourceListFilterText, + selectedPacketID: controller.snapshot.selectedPacketID, + selectedSourceListSelection: selectedSourceListSelection, + workspaceMode: workspaceMode, + inspectorTab: inspectorTab, + inspectorPlacement: inspectorPlacement, + isInspectorVisible: isInspectorVisible, + isStructuredFilterVisible: isStructuredFilterVisible, + tableColumnLayout: packetTableColumnLayoutStore.load(), + sourceMetadata: sourceMetadata + ) + } + private func presentExportPanel( identifiers: [PacketSummary.ID], scopeName: String, @@ -1482,7 +1566,10 @@ final class NetworkInspectorViewModel { _ identifiers: [PacketSummary.ID], requiresSavedBacking: Bool ) throws -> [PacketSummary.ID] { - let activePacketsByID = Dictionary(uniqueKeysWithValues: controller.snapshot.packetIngestState.packets.map { ($0.id, $0) }) + var activePacketsByID: [PacketSummary.ID: PacketSummary] = [:] + for packet in controller.snapshot.packetIngestState.packets where activePacketsByID[packet.id] == nil { + activePacketsByID[packet.id] = packet + } let missingActiveIDs = identifiers.filter { activePacketsByID[$0] == nil } guard missingActiveIDs.isEmpty else { throw TCPViewerCoreError(code: .offlineFileSaveFailed, message: "Some selected packets are no longer available in the active capture.") @@ -1496,7 +1583,10 @@ final class NetworkInspectorViewModel { throw TCPViewerCoreError(code: .offlineFileSaveFailed, message: "Saved packets from another session cannot be exported because their raw bytes are not available.") } - let savedRecordsByPacketID = Dictionary(uniqueKeysWithValues: savedPacketService.records().map { ($0.packet.id, $0) }) + var savedRecordsByPacketID: [PacketSummary.ID: SavedPacketRecord] = [:] + for record in savedPacketService.records() where savedRecordsByPacketID[record.packet.id] == nil { + savedRecordsByPacketID[record.packet.id] = record + } for identifier in identifiers { guard let record = savedRecordsByPacketID[identifier], record.backingIdentity == activeBackingIdentity, @@ -1583,6 +1673,7 @@ final class NetworkInspectorViewModel { completion?() } } else { + restorePersistentDocumentState() controller.startLiveCapture { [weak self] in self?.rebuildSnapshot() completion?() @@ -1613,17 +1704,43 @@ final class NetworkInspectorViewModel { func openDocument(at fileURL: URL, completion: (() -> Void)? = nil) { controller.openDocument(at: fileURL) { [weak self] in - self?.workspaceMode = .packets - self?.selectedSidebar = .liveCapture - self?.selectedSourceListSelection = .allPackets - self?.rebuildSnapshot() + guard let self else { + completion?() + return + } + + if let sessionState = self.controller.currentDocumentSessionState { + self.applySessionDocumentState(sessionState) + self.pendingSessionImportReport = self.controller.currentDocumentSessionImportReport + } else { + self.pendingSessionImportReport = nil + self.restorePersistentDocumentState() + self.workspaceMode = .packets + self.selectedSidebar = .liveCapture + self.selectedSourceListSelection = .allPackets + } + self.rebuildSnapshot() completion?() } } func importDocuments(at fileURLs: [URL], completion: (() -> Void)? = nil) { controller.importDocuments(at: fileURLs) { [weak self] in - self?.finishImportedDocuments(fileURLs: fileURLs, completion: completion) + guard let self else { + completion?() + return + } + + if let sessionState = self.controller.currentDocumentSessionState { + self.applySessionDocumentState(sessionState) + self.pendingSessionImportReport = self.controller.currentDocumentSessionImportReport + self.rebuildSnapshot() + completion?() + } else { + self.pendingSessionImportReport = nil + self.restorePersistentDocumentState() + self.finishImportedDocuments(fileURLs: fileURLs, completion: completion) + } } } @@ -1640,6 +1757,60 @@ final class NetworkInspectorViewModel { completion?() } + private func applySessionDocumentState(_ state: TCPViewSessionState) { + isUsingSessionDocumentState = true + pinService.useDocumentPins(state.pins) + savedPacketService.useDocumentRecords(remappedSavedRecordsForCurrentDocument(state.savedPackets)) + customFilterService.useDocumentFilters(state.customFilters) + quickFilterService.apply(state.quickFilterSelection) + structuredFilterGroup = state.structuredFilterGroup + displayFilterText = state.displayFilterText + sourceListFilterText = state.sourceListFilterText + selectedCustomFilterID = nil + selectedSourceListSelection = state.selectedSourceListSelection?.selection() ?? .allPackets + workspaceMode = NetworkInspectorWorkspaceMode(rawValue: state.workspaceMode) ?? .packets + selectedSidebar = workspaceMode == .packets ? .liveCapture : .view(workspaceMode) + inspectorTab = PacketInspectorTab(rawValue: state.inspectorTab) ?? .summary + inspectorPlacement = NetworkInspectorPlacement(rawValue: state.inspectorPlacement) ?? .trailing + isInspectorVisible = state.isInspectorVisible + isStructuredFilterVisible = state.isStructuredFilterVisible + packetTableContentCache.reset() + + if let selectedPacketID = state.selectedPacketID, + controller.snapshot.packetIngestState.packet(withID: selectedPacketID) != nil { + controller.selectPacket(selectedPacketID) + } + } + + private func restorePersistentDocumentState() { + guard isUsingSessionDocumentState else { + return + } + + isUsingSessionDocumentState = false + pinService.reloadPersistentPins() + savedPacketService.reloadPersistentRecords() + customFilterService.reloadPersistentFilters() + quickFilterService.reset() + selectedCustomFilterID = nil + sourceListFilterText = "" + displayFilterText = preferences.displayFilterText + structuredFilterGroup = structuredFilterStore.load() + isStructuredFilterVisible = preferences.isStructuredFilterVisible + inspectorPlacement = preferences.inspectorPlacement + isInspectorVisible = preferences.isInspectorVisible + packetTableContentCache.reset() + } + + private func remappedSavedRecordsForCurrentDocument(_ records: [SavedPacketRecord]) -> [SavedPacketRecord] { + let backingIdentity = controller.snapshot.packetIngestState.backingIdentity + return records.map { record in + var remappedRecord = record + remappedRecord.backingIdentity = backingIdentity + return remappedRecord + } + } + func saveDocument(completion: (() -> Void)? = nil) { controller.saveDocument { [weak self] in self?.rebuildSnapshot() @@ -1669,6 +1840,47 @@ final class NetworkInspectorViewModel { ) } + func presentTCPViewSessionExportPanel(attachedTo window: NSWindow?) { + guard !controller.snapshot.packetIngestState.packets.isEmpty else { + packetExportService.presentFailure(TCPViewerCoreError(code: .offlineFileSaveFailed, message: "There are no packets to export.")) + return + } + + guard let destinationURL = packetExportService.chooseTCPViewSessionDestination(scopeName: "TCPViewer-Session") else { + return + } + + let cancellationToken = PacketExportCancellationToken() + let progressSheet = packetExportService.showProgressSheet( + attachedTo: window, + fileName: destinationURL.lastPathComponent, + progressTitle: "Exporting Session", + unitLabel: "items" + ) { + cancellationToken.cancel() + } + + exportTCPViewSession( + to: destinationURL, + progress: { progress in + DispatchQueue.main.async { + progressSheet.update(progress) + } + }, + shouldCancel: { + cancellationToken.isCancelled() + } + ) { [weak self] result in + DispatchQueue.main.async { + progressSheet.dismiss() + if case .failure(let error) = result, + self?.isExportCancellation(error) != true { + self?.packetExportService.presentFailure(error) + } + } + } + } + func presentPacketExportPanel(identifiers: [PacketSummary.ID], format: CaptureFileFormat, attachedTo window: NSWindow?) { presentExportPanel( identifiers: identifiers, @@ -1712,6 +1924,33 @@ final class NetworkInspectorViewModel { ) } + func exportTCPViewSession( + to url: URL, + progress: PacketExportProgressHandler? = nil, + shouldCancel: PacketExportCancellationCheck? = nil, + completion: @escaping TCPViewerVoidCompletion + ) { + let exportSnapshot = makeTCPViewSessionExportSnapshot() + guard !exportSnapshot.packets.isEmpty else { + completion(.failure(TCPViewerCoreError(code: .offlineFileSaveFailed, message: "There are no packets to export."))) + return + } + + controller.exportTCPViewSession( + snapshot: exportSnapshot, + to: url, + exportService: tcpViewSessionExportService, + progress: progress, + shouldCancel: shouldCancel + ) { [weak self] result in + if case .success = result { + self?.packetExportService.rememberDestination(url) + } + self?.rebuildSnapshot() + completion(result) + } + } + func exportPackets( _ identifiers: [PacketSummary.ID], to url: URL, @@ -1787,7 +2026,9 @@ final class NetworkInspectorViewModel { } isInspectorVisible = isVisible - preferences.persistInspectorVisible(isVisible) + if !isUsingSessionDocumentState { + preferences.persistInspectorVisible(isVisible) + } rebuildSnapshot() } @@ -1803,8 +2044,10 @@ final class NetworkInspectorViewModel { inspectorPlacement = placement isInspectorVisible = true - preferences.persistInspectorPlacement(placement) - preferences.persistInspectorVisible(true) + if !isUsingSessionDocumentState { + preferences.persistInspectorPlacement(placement) + preferences.persistInspectorVisible(true) + } rebuildSnapshot() } @@ -1815,7 +2058,9 @@ final class NetworkInspectorViewModel { return } - preferences.persistInspectorThickness(thickness, placement: placement ?? inspectorPlacement) + if !isUsingSessionDocumentState { + preferences.persistInspectorThickness(thickness, placement: placement ?? inspectorPlacement) + } } // Reject invalid or collapse-threshold sizes so reopen can fall back to a visible default. diff --git a/TCPViewer/Features/NetworkInspector/Views/PacketQuickFilterViewController.swift b/TCPViewer/Features/NetworkInspector/Views/PacketQuickFilterViewController.swift index 9f606cd..fe66b4f 100644 --- a/TCPViewer/Features/NetworkInspector/Views/PacketQuickFilterViewController.swift +++ b/TCPViewer/Features/NetworkInspector/Views/PacketQuickFilterViewController.swift @@ -109,7 +109,10 @@ final class PacketQuickFilterViewController: NSTitlebarAccessoryViewController { func render(snapshot: NetworkInspectorSnapshot) { ensureButtons(for: snapshot.quickFilterItems, customItems: snapshot.customFilterItems) - customItemsByID = Dictionary(uniqueKeysWithValues: snapshot.customFilterItems.map { ($0.id, $0) }) + customItemsByID = [:] + for item in snapshot.customFilterItems { + customItemsByID[item.id] = item + } for item in snapshot.quickFilterItems { guard let button = buttons[item.id] else { continue diff --git a/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift b/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift index a8cb6e1..6927db4 100644 --- a/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift +++ b/TCPViewer/Features/NetworkInspector/Views/TCPViewerRootViewController.swift @@ -145,12 +145,21 @@ final class TCPViewerRootViewController: NSViewController { } func openDocument(at url: URL) { + guard !TCPViewerCaptureFileImportPolicy.isSessionFileURL(url) else { + viewModel.openDocument(at: url) { [weak self] in + self?.sidebarViewController.revealSelectedImportedFileIfNeeded() + self?.presentSessionImportReportIfNeeded() + } + return + } + importDocuments(at: [url]) } func importDocuments(at urls: [URL], completion: (() -> Void)? = nil) { viewModel.importDocuments(at: urls) { [weak self] in self?.sidebarViewController.revealSelectedImportedFileIfNeeded() + self?.presentSessionImportReportIfNeeded() completion?() } } @@ -175,6 +184,10 @@ final class TCPViewerRootViewController: NSViewController { viewModel.presentSessionExportPanel(format: format, attachedTo: view.window) } + func exportTCPViewSession() { + viewModel.presentTCPViewSessionExportPanel(attachedTo: view.window) + } + func cancelDocumentLoading() { viewModel.cancelDocumentLoading() } @@ -1141,6 +1154,28 @@ extension TCPViewerRootViewController: PacketWorkspaceViewControllerDelegate { alert.runModal() } } + + private func presentSessionImportReportIfNeeded() { + guard let report = viewModel.consumeSessionImportReportWithFailures() else { + return + } + + let alert = NSAlert() + alert.messageText = "Session Imported with Skipped Flows" + let reason = report.failedFlowCount == 1 ? "it was malformed" : "they were malformed" + alert.informativeText = "Imported \(Self.flowCountText(report.importedFlowCount)). Skipped \(Self.flowCountText(report.failedFlowCount)) because \(reason)." + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + if let window = view.window { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } + + private static func flowCountText(_ count: Int) -> String { + "\(count) flow\(count == 1 ? "" : "s")" + } } extension TCPViewerRootViewController: PacketInspectorViewControllerDelegate { diff --git a/TCPViewer/Info.plist b/TCPViewer/Info.plist index 7c6b885..dc18a55 100644 --- a/TCPViewer/Info.plist +++ b/TCPViewer/Info.plist @@ -50,6 +50,20 @@ NSDocumentClass $(PRODUCT_MODULE_NAME).Document + + CFBundleTypeIconFile + TCPViewSessionDocumentIcon.icns + CFBundleTypeRole + Editor + CFBundleTypeName + TCPViewer Session + LSItemContentTypes + + com.proxyman.tcpviewer.session + + NSDocumentClass + $(PRODUCT_MODULE_NAME).Document + UTImportedTypeDeclarations @@ -87,6 +101,26 @@ + + UTTypeConformsTo + + public.zip-archive + public.data + + UTTypeDescription + TCPViewer Session + UTTypeIconFile + TCPViewSessionDocumentIcon.icns + UTTypeIdentifier + com.proxyman.tcpviewer.session + UTTypeTagSpecification + + public.filename-extension + + tcpviewsession + + + diff --git a/TCPViewer/Resources/TCPViewSessionDocumentIcon.icns b/TCPViewer/Resources/TCPViewSessionDocumentIcon.icns new file mode 100644 index 0000000..c02c33d Binary files /dev/null and b/TCPViewer/Resources/TCPViewSessionDocumentIcon.icns differ diff --git a/TCPViewer/Resources/TCPViewSessionDocumentIcon.png b/TCPViewer/Resources/TCPViewSessionDocumentIcon.png new file mode 100644 index 0000000..3b253c0 Binary files /dev/null and b/TCPViewer/Resources/TCPViewSessionDocumentIcon.png differ diff --git a/TCPViewerTests/App/WorkspaceControllerTests.swift b/TCPViewerTests/App/WorkspaceControllerTests.swift index c052c36..a6a974b 100644 --- a/TCPViewerTests/App/WorkspaceControllerTests.swift +++ b/TCPViewerTests/App/WorkspaceControllerTests.swift @@ -800,6 +800,120 @@ struct WindowControllerTests { await tearDown(controller) } + @Test func exportTCPViewSessionPausesAndResumesRunningLiveCapture() async { + let exportURL = URL(fileURLWithPath: "/tmp/live-session.tcpviewsession") + let packets = [ + makePacket(packetNumber: 1, source: .live, transportHint: .tcp), + makePacket(packetNumber: 2, source: .live, transportHint: .udp), + ] + let liveSession = FakeLiveSession() + let exportWriter = FakeTCPViewSessionExportWriter() + let controller = TCPViewerWorkspaceController( + services: TCPViewerServiceRegistry(core: FakeTCPViewerCore( + interfaceInventories: [[makeInterface(id: "en0", displayName: "Wi-Fi")]], + liveSession: liveSession + )) + ) + + await controller.refreshInterfaces() + await controller.startLiveCapture() + liveSession.send(.liveStateChanged(phase: .running, message: "Capture running.")) + liveSession.send(.packetBatch(packets, disposition: .append)) + await waitUntil { + controller.snapshot.sessionState.phase == .running && + controller.snapshot.packetIngestState.totalPacketCount == packets.count + } + + let result = await withCheckedContinuation { continuation in + controller.exportTCPViewSession( + snapshot: makeSessionSnapshot(packets: packets, source: .live), + to: exportURL, + exportService: exportWriter + ) { result in + continuation.resume(returning: result) + } + } + + guard case .success = result else { + Issue.record("Expected TCPViewer session export to succeed.") + return + } + #expect(liveSession.pauseCount == 1) + #expect(liveSession.resumeCount == 1) + #expect(liveSession.exportRequests.first?.0 == packets.map(\.id)) + #expect(liveSession.exportRequests.first?.2 == .pcapng) + #expect(exportWriter.requests.first?.snapshot.packets.map(\.id) == packets.map(\.id)) + #expect(exportWriter.requests.first?.destinationURL == exportURL) + + await tearDown(controller) + } + + @Test func exportTCPViewSessionFlattensMultipleImportedFilesIntoOneCapture() async { + let firstURL = TCPViewerCaptureFileImportPolicy.standardizedFileURL(URL(fileURLWithPath: "/tmp/session-import-one.pcapng")) + let secondURL = TCPViewerCaptureFileImportPolicy.standardizedFileURL(URL(fileURLWithPath: "/tmp/session-import-two.pcapng")) + let destinationURL = URL(fileURLWithPath: "/tmp/imported-session.tcpviewsession") + let firstPackets = [ + makePacket(packetNumber: 1, source: .offline, transportHint: .tcp), + makePacket(packetNumber: 2, source: .offline, transportHint: .udp), + ] + let secondPackets = [ + makePacket(packetNumber: 1, source: .offline, transportHint: .dns), + makePacket(packetNumber: 2, source: .offline, transportHint: .tls), + ] + let firstDocument = FakeOfflineDocument( + url: firstURL, + metadata: CaptureDocumentMetadata(format: .pcapng), + openPlan: .completed(firstPackets) + ) + let secondDocument = FakeOfflineDocument( + url: secondURL, + metadata: CaptureDocumentMetadata(format: .pcapng), + openPlan: .completed(secondPackets) + ) + let exportWriter = FakeTCPViewSessionExportWriter() + let controller = TCPViewerWorkspaceController( + services: TCPViewerServiceRegistry(core: FakeTCPViewerCore( + interfaceInventories: [[makeInterface(id: "en0", displayName: "Wi-Fi")]], + documentFactory: { url in + url == secondURL ? secondDocument : firstDocument + } + )) + ) + + await controller.importDocuments(at: [firstURL, secondURL]) + await waitUntil { + controller.snapshot.documentState.phase == .loaded && + controller.snapshot.packetIngestState.totalPacketCount == 4 + } + let sessionPackets = controller.snapshot.packetIngestState.packets + + let result = await withCheckedContinuation { continuation in + controller.exportTCPViewSession( + snapshot: makeSessionSnapshot( + packets: sessionPackets, + source: .offline, + importedFiles: controller.snapshot.packetIngestState.importedFiles, + importedPacketReferenceByID: controller.snapshot.packetIngestState.importedPacketReferenceByID + ), + to: destinationURL, + exportService: exportWriter + ) { result in + continuation.resume(returning: result) + } + } + + guard case .success = result else { + Issue.record("Expected imported TCPViewer session export to succeed.") + return + } + #expect(firstDocument.exportRequests.map(\.0) == [[1, 2]]) + #expect(secondDocument.exportRequests.map(\.0) == [[1, 2]]) + #expect(exportWriter.requests.count == 1) + #expect(exportWriter.requests.first?.snapshot.packets.map(\.id) == sessionPackets.map(\.id)) + + await tearDown(controller) + } + @Test func openingNewDocumentIgnoresEventsFromPreviousDocumentStream() async { let firstURL = URL(fileURLWithPath: "/tmp/first-stream.pcapng") let secondURL = URL(fileURLWithPath: "/tmp/second-stream.pcapng") @@ -1343,6 +1457,42 @@ struct WindowControllerTests { ) } + private func makeSessionSnapshot( + packets: [PacketSummary], + source: CaptureSource, + importedFiles: [ImportedCaptureFile] = [], + importedPacketReferenceByID: [PacketSummary.ID: ImportedPacketReference] = [:] + ) -> TCPViewSessionExportSnapshot { + TCPViewSessionExportSnapshot( + packets: packets, + source: source, + backingIdentity: "test-backing", + importedFiles: importedFiles, + importedPacketReferenceByID: importedPacketReferenceByID, + pins: [], + savedPackets: [], + customFilters: [], + quickFilterSelection: .all, + structuredFilterGroup: .default, + displayFilterText: "", + sourceListFilterText: "", + selectedPacketID: packets.first?.id, + selectedSourceListSelection: .allPackets, + workspaceMode: .packets, + inspectorTab: .detail, + inspectorPlacement: .trailing, + isInspectorVisible: true, + isStructuredFilterVisible: false, + tableColumnLayout: nil, + sourceMetadata: TCPViewSessionSourceMetadata( + fileName: "test.pcapng", + filePath: "/tmp/test.pcapng", + format: "pcapng", + packetCount: packets.count + ) + ) + } + private func settleEventLoop() async { for _ in 0..<5 { await Task.yield() @@ -1660,6 +1810,47 @@ private final class FakeLiveSession: LiveCaptureSessionProviding, @unchecked Sen } } +private final class FakeTCPViewSessionExportWriter: TCPViewSessionExportWriting, @unchecked Sendable { + struct Request { + let snapshot: TCPViewSessionExportSnapshot + let captureFileURL: URL + let destinationURL: URL + } + + private let lock = NSLock() + private var storedRequests: [Request] = [] + var error: Error? + + var requests: [Request] { + lock.lock() + defer { lock.unlock() } + return storedRequests + } + + func writePackage( + snapshot: TCPViewSessionExportSnapshot, + captureFileURL: URL, + to destinationURL: URL, + progress: PacketExportProgressHandler?, + shouldCancel: PacketExportCancellationCheck? + ) throws { + if shouldCancel?() == true { + throw TCPViewerCoreError(code: .operationCancelled, message: "TCPViewer session export was cancelled.") + } + if let error { + throw error + } + + progress?(PacketExportProgress( + exportedPacketCount: snapshot.packets.count + 6, + totalPacketCount: snapshot.packets.count + 6 + )) + lock.lock() + storedRequests.append(Request(snapshot: snapshot, captureFileURL: captureFileURL, destinationURL: destinationURL)) + lock.unlock() + } +} + private final class FakeOfflineDocument: OfflineCaptureDocumentProviding, @unchecked Sendable { struct LoadPlan { var batches: [[PacketSummary]] @@ -1801,6 +1992,9 @@ private final class FakeOfflineDocument: OfflineCaptureDocumentProviding, @unche progress?(PacketExportProgress(exportedPacketCount: identifiers.count, totalPacketCount: identifiers.count)) exportRequests.append((identifiers, url, format)) + if url.path.contains("SessionCaptureParts") { + try? Data("pcapng-part-\(identifiers.map(String.init).joined(separator: ","))".utf8).write(to: url) + } completion(.success(())) } diff --git a/TCPViewerTests/Features/NetworkInspector/TCPViewSessionFormatTests.swift b/TCPViewerTests/Features/NetworkInspector/TCPViewSessionFormatTests.swift new file mode 100644 index 0000000..7c58163 --- /dev/null +++ b/TCPViewerTests/Features/NetworkInspector/TCPViewSessionFormatTests.swift @@ -0,0 +1,929 @@ +// +// TCPViewSessionFormatTests.swift +// TCPViewer +// +// Created by Proxyman LLC on 15/6/26. +// + +import Foundation +import PcapPlusPlusCore +import Testing +@testable import TCPViewer + +@Suite(.serialized) +struct TCPViewSessionFormatTests { + + @Test func sessionStateRoundTripsDocumentLocalData() throws { + let packet = Self.makePacket( + id: 42, + packetNumber: 7, + client: Self.client, + packetComment: "packet note" + ) + let fileID = ImportedCaptureFileID(rawValue: "file-a") + let importedFile = ImportedCaptureFile( + id: fileID, + url: URL(fileURLWithPath: "/tmp/source-a.pcapng"), + displayName: "source-a.pcapng", + packetIDs: [packet.id] + ) + let structuredGroup = PacketStructuredFilterGroup( + filters: [ + PacketStructuredFilter( + id: "structured-row", + query: .urlDomain, + condition: .contains, + text: "example.com", + isEnabled: true + ), + ], + operator: .or + ) + let pin = PacketPin( + id: PacketPinID(rawValue: "domain:example.com"), + kind: .domain, + title: "example.com", + createdAt: Self.fixedDate, + domain: "example.com", + ipAddress: nil, + clientKey: nil, + clientDisplayName: nil, + clientIconFilePath: nil + ) + let savedRecord = SavedPacketRecord( + id: "saved-1", + savedAt: Self.fixedDate, + backingIdentity: "backing-a", + packet: packet + ) + let customFilter = PacketCustomFilter( + id: "custom-1", + name: "Example", + createdAt: Self.fixedDate, + updatedAt: Self.fixedDate, + group: structuredGroup + ) + let layout = PacketTableColumnLayout(columns: [ + PacketTableColumnLayout.Column(identifier: "protocol", isVisible: true, width: 120), + PacketTableColumnLayout.Column(identifier: "length", isVisible: false, width: 72), + ]) + let selectedSource = PacketSourceListSelection.fileAppDomain( + fileID, + PacketSourceClientKey(rawValue: "client-key"), + PacketSourceDomainKey(rawValue: "example.com", isMissingDomain: false) + ) + let snapshot = Self.makeSnapshot( + packets: [packet], + importedFiles: [importedFile], + importedPacketReferenceByID: [ + packet.id: ImportedPacketReference(fileID: fileID, originalPacketID: 1), + ], + pins: [pin], + savedPackets: [savedRecord], + customFilters: [customFilter], + quickFilterSelection: PacketQuickFilterSelection(selectedIDs: [.tcp, .dns]), + structuredFilterGroup: structuredGroup, + selectedPacketID: packet.id, + selectedSourceListSelection: selectedSource, + tableColumnLayout: layout + ) + + let decoded = try Self.decode(TCPViewSessionState.self, from: Self.encode(snapshot.state)) + + #expect(decoded == snapshot.state) + #expect(decoded.importedCaptureFiles == [importedFile]) + #expect(decoded.importedPacketReferenceByID[packet.id]?.originalPacketID == 1) + #expect(decoded.selectedSourceListSelection?.selection() == selectedSource) + #expect(decoded.importedFileProvenance != nil) + } + + @Test func annotationsRoundTripReservedCommentAndColorFields() throws { + let annotations = TCPViewSessionAnnotations(annotations: [ + TCPViewSessionPacketAnnotation( + packetID: 9, + packetComment: "pcapng comment", + customComment: "custom comment", + colorHex: "#4A90E2" + ), + ]) + + let decoded = try Self.decode(TCPViewSessionAnnotations.self, from: Self.encode(annotations)) + + #expect(decoded == annotations) + } + + @Test func clientStoreDeduplicatesTwentyThousandPacketsAndRestoresClients() { + let packets = (1...20_000).map { + Self.makePacket(id: UInt64($0), packetNumber: UInt64($0), client: Self.client) + } + let iconID = TCPViewSessionClientStoreBuilder.stableIconID(for: "/bin/ls") + + let result = TCPViewSessionClientStoreBuilder.buildClientStore( + packets: packets, + iconIDForClient: { _ in iconID } + ) + let restoredPackets = TCPViewSessionClientStoreBuilder.rehydratePackets( + records: result.records, + clients: result.clients + ) + + #expect(result.records.count == 20_000) + #expect(result.clients.clients.count == 1) + #expect(result.clients.clients.first?.iconID == iconID) + #expect(result.records.allSatisfy { $0.clientID == result.clients.clients.first?.id }) + #expect(result.records.allSatisfy { $0.packet.client == nil }) + #expect(restoredPackets.count == packets.count) + #expect(restoredPackets[12_345].client == Self.client) + } + + @Test func exportWritesInspectablePackageAndImportRestoresSidecars() throws { + let directory = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let captureURL = directory.appendingPathComponent("capture-source.pcapng") + try Data("pcapng-placeholder".utf8).write(to: captureURL) + let destinationURL = directory.appendingPathComponent("sample.tcpviewsession") + let packets = [ + Self.makePacket(id: 10, packetNumber: 1, client: Self.client, packetComment: "first comment"), + Self.makePacket(id: 11, packetNumber: 2, client: Self.client), + ] + let snapshot = Self.makeSnapshot( + packets: packets, + importedFiles: [ + ImportedCaptureFile( + id: ImportedCaptureFileID(rawValue: "source"), + url: URL(fileURLWithPath: "/tmp/source.pcapng"), + displayName: "source.pcapng", + packetIDs: packets.map(\.id) + ), + ], + importedPacketReferenceByID: [ + 10: ImportedPacketReference(fileID: ImportedCaptureFileID(rawValue: "source"), originalPacketID: 1), + 11: ImportedPacketReference(fileID: ImportedCaptureFileID(rawValue: "source"), originalPacketID: 2), + ] + ) + var progressValues: [Double] = [] + let exportService = TCPViewSessionExportService(now: { Self.fixedDate }) + + try exportService.writePackage( + snapshot: snapshot, + captureFileURL: captureURL, + to: destinationURL, + progress: { progress in progressValues.append(progress.fractionCompleted) }, + shouldCancel: nil + ) + let contents = try TCPViewSessionImportService().loadPackage(at: destinationURL) + defer { try? FileManager.default.removeItem(at: contents.extractionDirectoryURL) } + let packetsSidecarURL = contents.packageDirectoryURL.appendingPathComponent(TCPViewSessionFormat.packetsPath) + let jsonl = try String(contentsOf: packetsSidecarURL, encoding: .utf8) + + #expect(FileManager.default.fileExists(atPath: destinationURL.path)) + #expect(contents.manifest.magic == TCPViewSessionFormat.magic) + #expect(contents.manifest.packetCount == packets.count) + #expect(contents.packetRecords.map(\.captureOrdinal) == [0, 1]) + #expect(contents.clientStore.clients.count == 1) + #expect(contents.packets.map(\.client) == [Self.client, Self.client]) + #expect(contents.annotations.annotations.first?.packetComment == "first comment") + #expect(contents.state.importedCaptureFiles.first?.displayName == "source.pcapng") + #expect(jsonl.split(separator: "\n").count == packets.count) + #expect(Self.zipPackageContainsRequiredFiles(contents.packageDirectoryURL)) + #expect(progressValues == progressValues.sorted()) + } + + @Test func cancellationLeavesExistingDestinationUntouched() throws { + let directory = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let captureURL = directory.appendingPathComponent("capture-source.pcapng") + let destinationURL = directory.appendingPathComponent("existing.tcpviewsession") + let existingData = Data("existing-session".utf8) + try Data("pcapng-placeholder".utf8).write(to: captureURL) + try existingData.write(to: destinationURL) + let snapshot = Self.makeSnapshot(packets: [Self.makePacket(id: 1, packetNumber: 1, client: Self.client)]) + let exportService = TCPViewSessionExportService(now: { Self.fixedDate }) + var checks = 0 + + do { + try exportService.writePackage( + snapshot: snapshot, + captureFileURL: captureURL, + to: destinationURL, + progress: nil, + shouldCancel: { + checks += 1 + return checks > 1 + } + ) + Issue.record("Expected cancellation to throw.") + } catch let error as TCPViewerCoreError { + #expect(error.code == .operationCancelled) + } + + let finalData = try Data(contentsOf: destinationURL) + #expect(finalData == existingData) + } + + @Test func importRejectsUnsupportedSchema() throws { + let directory = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let packageURL = directory.appendingPathComponent(TCPViewSessionFormat.packageDirectoryName, isDirectory: true) + try FileManager.default.createDirectory(at: packageURL, withIntermediateDirectories: true) + try Data("pcapng-placeholder".utf8).write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.capturePath)) + try Data().write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.packetsPath)) + try Self.encode(TCPViewSessionClientStore(clients: [])).write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.clientsPath)) + try Self.encode(TCPViewSessionAnnotations(annotations: [])).write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.annotationsPath)) + try Self.encode(Self.makeSnapshot(packets: []).state).write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.statePath)) + var manifest = TCPViewSessionManifest( + createdAt: Self.fixedDate, + applicationName: "TCPViewer", + applicationVersion: "1.0", + applicationBuild: "1", + packetCount: 0 + ) + manifest.schemaVersion = TCPViewSessionFormat.schemaVersion + 1 + try Self.encode(manifest).write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.manifestPath)) + + let sessionURL = directory.appendingPathComponent("unsupported.tcpviewsession") + try Self.zipKeepingParent(packageURL, to: sessionURL) + + do { + _ = try TCPViewSessionImportService().loadPackage(at: sessionURL) + Issue.record("Expected unsupported schema to be rejected.") + } catch let error as TCPViewerCoreError { + #expect(error.code == .offlineFileOpenFailed) + #expect(error.message.contains("unsupported schema")) + } + } + + @Test func importDeduplicatesClientStoreWithoutDroppingValidFlows() throws { + let directory = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let packet = Self.makePacket(id: 1, packetNumber: 1, client: Self.client) + let clientID = TCPViewSessionClientStoreBuilder.stableClientID(for: Self.client) + let sessionURL = try Self.writeSessionPackage( + in: directory, + packetRecords: [ + TCPViewSessionPacketRecord( + packetID: packet.id, + captureOrdinal: 0, + clientID: clientID, + packet: packet.tcpviewSessionPacketWithoutClient() + ), + ], + clients: TCPViewSessionClientStore(clients: [ + TCPViewSessionClientRecord(id: clientID, client: Self.client, iconID: nil), + TCPViewSessionClientRecord(id: clientID, client: Self.client, iconID: nil), + ]), + state: Self.makeSnapshot(packets: [packet]).state + ) + + let contents = try TCPViewSessionImportService().loadPackage(at: sessionURL) + defer { try? FileManager.default.removeItem(at: contents.extractionDirectoryURL) } + + #expect(contents.clientStore.clients.count == 1) + #expect(contents.packets.map(\.id) == [packet.id]) + #expect(contents.packets.first?.client == Self.client) + #expect(contents.importReport == TCPViewSessionImportReport(importedFlowCount: 1, failedFlowCount: 0)) + } + + @Test func importSkipsMalformedPacketRowsAndReportsFailures() throws { + let directory = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let firstPacket = Self.makePacket(id: 1, packetNumber: 1, client: nil) + let secondPacket = Self.makePacket(id: 2, packetNumber: 2, client: nil) + let thirdPacket = Self.makePacket(id: 3, packetNumber: 3, client: nil) + let sessionURL = try Self.writeSessionPackage( + in: directory, + packetRecords: [ + TCPViewSessionPacketRecord(packetID: firstPacket.id, captureOrdinal: 0, clientID: nil, packet: firstPacket), + TCPViewSessionPacketRecord(packetID: secondPacket.id, captureOrdinal: 0, clientID: nil, packet: secondPacket), + TCPViewSessionPacketRecord(packetID: 99, captureOrdinal: 1, clientID: nil, packet: firstPacket), + TCPViewSessionPacketRecord(packetID: thirdPacket.id, captureOrdinal: 4, clientID: nil, packet: thirdPacket), + ], + clients: TCPViewSessionClientStore(clients: []), + state: Self.makeSnapshot(packets: [firstPacket, secondPacket, thirdPacket]).state, + additionalPacketSidecarLines: [Data("{bad-json}".utf8)] + ) + + let contents = try TCPViewSessionImportService().loadPackage(at: sessionURL) + defer { try? FileManager.default.removeItem(at: contents.extractionDirectoryURL) } + + #expect(contents.packetRecords.map(\.packetID) == [firstPacket.id]) + #expect(contents.packets.map(\.id) == [firstPacket.id]) + #expect(contents.importReport == TCPViewSessionImportReport(importedFlowCount: 1, failedFlowCount: 4)) + } + + @Test func importSanitizesStateReferencesForSkippedFlows() throws { + let directory = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let importedPacket = Self.makePacket(id: 1, packetNumber: 1, client: nil) + let skippedPacket = Self.makePacket(id: 2, packetNumber: 2, client: nil) + let fileID = ImportedCaptureFileID(rawValue: "file-a") + let importedFile = ImportedCaptureFile( + id: fileID, + url: URL(fileURLWithPath: "/tmp/source-a.pcapng"), + displayName: "source-a.pcapng", + packetIDs: [importedPacket.id, skippedPacket.id] + ) + let state = Self.makeState( + packets: [importedPacket, skippedPacket], + importedFiles: [importedFile], + importedPacketReferences: [ + TCPViewSessionImportedPacketReferenceRecord( + packetID: importedPacket.id, + reference: ImportedPacketReference(fileID: fileID, originalPacketID: 1) + ), + TCPViewSessionImportedPacketReferenceRecord( + packetID: skippedPacket.id, + reference: ImportedPacketReference(fileID: fileID, originalPacketID: 2) + ), + ], + savedPackets: [ + SavedPacketRecord(id: "saved-imported", savedAt: Self.fixedDate, backingIdentity: "backing-a", packet: importedPacket), + SavedPacketRecord(id: "saved-skipped", savedAt: Self.fixedDate, backingIdentity: "backing-a", packet: skippedPacket), + ], + selectedPacketID: skippedPacket.id + ) + let sessionURL = try Self.writeSessionPackage( + in: directory, + packetRecords: [ + TCPViewSessionPacketRecord(packetID: importedPacket.id, captureOrdinal: 0, clientID: nil, packet: importedPacket), + TCPViewSessionPacketRecord(packetID: skippedPacket.id, captureOrdinal: 0, clientID: nil, packet: skippedPacket), + ], + clients: TCPViewSessionClientStore(clients: []), + state: state + ) + + let contents = try TCPViewSessionImportService().loadPackage(at: sessionURL) + defer { try? FileManager.default.removeItem(at: contents.extractionDirectoryURL) } + + #expect(contents.packets.map(\.id) == [importedPacket.id]) + #expect(contents.state.importedFiles.first?.packetIDs == [importedPacket.id]) + #expect(contents.state.importedPacketReferences.map(\.packetID) == [importedPacket.id]) + #expect(contents.state.savedPackets.map(\.packet.id) == [importedPacket.id]) + #expect(contents.state.selectedPacketID == nil) + #expect(contents.importReport == TCPViewSessionImportReport(importedFlowCount: 1, failedFlowCount: 1)) + } + + @Test func importedClientIconsFeedDocumentLocalTableAndSourceListModels() throws { + let directory = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let packet = Self.makePacket(id: 1, packetNumber: 1, client: Self.client) + let clientID = TCPViewSessionClientStoreBuilder.stableClientID(for: Self.client) + let iconID = TCPViewSessionClientStoreBuilder.stableIconID(for: "/Applications/Sample.app") + let sessionURL = try Self.writeSessionPackage( + in: directory, + packetRecords: [ + TCPViewSessionPacketRecord( + packetID: packet.id, + captureOrdinal: 0, + clientID: clientID, + packet: packet.tcpviewSessionPacketWithoutClient() + ), + ], + clients: TCPViewSessionClientStore(clients: [ + TCPViewSessionClientRecord(id: clientID, client: Self.client, iconID: iconID), + ]), + state: Self.makeSnapshot(packets: [packet]).state, + icons: [iconID: Data([0x89, 0x50, 0x4E, 0x47])] + ) + + let contents = try TCPViewSessionImportService().loadPackage(at: sessionURL) + defer { try? FileManager.default.removeItem(at: contents.extractionDirectoryURL) } + var ingestState = PacketIngestState.empty + ingestState.replaceSession( + with: contents.packets, + importedFiles: [], + importedPacketReferenceByID: [:], + clientIconFilePathByClientID: contents.clientIconFilePathByClientID, + source: .offline + ) + let importedIconPath = contents.clientIconFilePathByClientID[clientID] + let tableRow = PacketTableRow( + packet: contents.packets[0], + previousVisiblePacketTimestamp: nil, + previousVisibleStreamPacketTimestamp: nil, + clientIconFilePath: ingestState.tcpviewSessionClientIconFilePath(for: contents.packets[0].client) + ) + let sourceListSnapshot = PacketSourceListService().snapshot(for: ingestState) + let appItem = sourceListSnapshot.firstItem { item in + item.kind == .app && item.title == Self.client.displayName + } + + #expect(importedIconPath != nil) + #expect(tableRow.clientIconFilePath == importedIconPath) + #expect(appItem?.iconFilePath == importedIconPath) + } + + @Test func offlineSessionDocumentMapsPacketsByCaptureOrdinalAfterSkippedRows() throws { + let directory = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let sessionPacket = Self.makePacket(id: 1, packetNumber: 1, client: nil) + let innerFirstPacket = Self.makePacket(id: 100, packetNumber: 100, client: nil) + let innerSecondPacket = Self.makePacket(id: 101, packetNumber: 101, client: nil) + let record = TCPViewSessionPacketRecord( + packetID: sessionPacket.id, + captureOrdinal: 1, + clientID: nil, + packet: sessionPacket + ) + let contents = TCPViewSessionPackageContents( + sourceURL: directory.appendingPathComponent("sample.tcpviewsession"), + extractionDirectoryURL: directory, + packageDirectoryURL: directory.appendingPathComponent(TCPViewSessionFormat.packageDirectoryName, isDirectory: true), + captureFileURL: directory.appendingPathComponent(TCPViewSessionFormat.capturePath), + manifest: TCPViewSessionManifest( + createdAt: Self.fixedDate, + applicationName: "TCPViewer", + applicationVersion: "1.0", + applicationBuild: "1", + packetCount: 2 + ), + packetRecords: [record], + clientStore: TCPViewSessionClientStore(clients: []), + annotations: TCPViewSessionAnnotations(annotations: []), + state: Self.makeSnapshot(packets: [sessionPacket]).state, + packets: [sessionPacket], + clientIconFilePathByClientID: [:], + importReport: TCPViewSessionImportReport(importedFlowCount: 1, failedFlowCount: 1) + ) + let innerDocument = SessionImportFakeOfflineDocument( + url: contents.captureFileURL, + packets: [innerFirstPacket, innerSecondPacket], + inspections: [ + innerSecondPacket.id: Self.makeInspection(for: innerSecondPacket), + ] + ) + let document = TCPViewSessionOfflineDocument( + contents: contents, + core: SessionImportFakeCore(document: innerDocument) + ) + let exportURL = directory.appendingPathComponent("export.pcapng") + + let openedPackets = try Self.waitForResult { completion in + document.open(completion: completion) + } + let inspection = try Self.waitForResult { completion in + document.inspectPacket(id: sessionPacket.id, completion: completion) + } + try Self.waitForVoid { completion in + document.exportPackets(withIDs: [sessionPacket.id], to: exportURL, format: .pcapng, completion: completion) + } + + #expect(openedPackets.map(\.id) == [sessionPacket.id]) + #expect(document.packetSummaries().map(\.id) == [sessionPacket.id]) + #expect(inspection.packetID == sessionPacket.id) + #expect(inspection.rawBytes == Data(repeating: UInt8(innerSecondPacket.packetNumber), count: 64)) + #expect(innerDocument.exportRequests.first?.0 == [innerSecondPacket.id]) + #expect(document.importReport == TCPViewSessionImportReport(importedFlowCount: 1, failedFlowCount: 1)) + } + + @Test func documentScopedServicesDoNotMutatePersistentStorage() throws { + let directory = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let pinURL = directory.appendingPathComponent("Pins.json") + let savedURL = directory.appendingPathComponent("Saved.json") + let filterURL = directory.appendingPathComponent("Filters.json") + let persistentPacket = Self.makePacket(id: 1, packetNumber: 1, client: nil) + let documentPacket = Self.makePacket(id: 2, packetNumber: 2, client: Self.client) + let pinService = PacketPinService(storageURL: pinURL) + let savedService = SavedPacketService(storageURL: savedURL) + let filterService = PacketCustomFilterService(storageURL: filterURL) + + try pinService.upsertDomainPin( + PacketSourceDomainIdentity( + key: PacketSourceDomainKey(rawValue: "persistent.example", isMissingDomain: false), + displayName: "persistent.example" + ), + now: Self.fixedDate + ) + try savedService.save([persistentPacket], backingIdentity: "persistent", now: Self.fixedDate) + try filterService.save(name: "Persistent", group: .default, now: Self.fixedDate) + let persistentPinsData = try Data(contentsOf: pinURL) + let persistentSavedData = try Data(contentsOf: savedURL) + let persistentFiltersData = try Data(contentsOf: filterURL) + + pinService.useDocumentPins([ + PacketPin( + id: PacketPinID(rawValue: "domain:document.example"), + kind: .domain, + title: "document.example", + createdAt: Self.fixedDate, + domain: "document.example", + ipAddress: nil, + clientKey: nil, + clientDisplayName: nil, + clientIconFilePath: nil + ), + ]) + savedService.useDocumentRecords([ + SavedPacketRecord(id: "document-saved", savedAt: Self.fixedDate, backingIdentity: "document", packet: documentPacket), + ]) + filterService.useDocumentFilters([ + PacketCustomFilter(id: "document-filter", name: "Document", createdAt: Self.fixedDate, updatedAt: Self.fixedDate, group: .default), + ]) + + try pinService.upsertDomainPin( + PacketSourceDomainIdentity( + key: PacketSourceDomainKey(rawValue: "document-added.example", isMissingDomain: false), + displayName: "document-added.example" + ), + now: Self.fixedDate + ) + try savedService.save([documentPacket], backingIdentity: "document", now: Self.fixedDate) + try filterService.save(name: "Document Added", group: .default, now: Self.fixedDate) + + let finalPinsData = try Data(contentsOf: pinURL) + let finalSavedData = try Data(contentsOf: savedURL) + let finalFiltersData = try Data(contentsOf: filterURL) + #expect(finalPinsData == persistentPinsData) + #expect(finalSavedData == persistentSavedData) + #expect(finalFiltersData == persistentFiltersData) + + pinService.reloadPersistentPins() + savedService.reloadPersistentRecords() + filterService.reloadPersistentFilters() + + #expect(pinService.pins().map(\.title) == ["persistent.example"]) + #expect(savedService.records().map(\.packet.id) == [persistentPacket.id]) + #expect(filterService.filters().map(\.name) == ["Persistent"]) + } + + private static let fixedDate = Date(timeIntervalSince1970: 1_780_000_000) + + private static let client = PacketClient( + pid: 1234, + name: "Sample", + displayName: "Sample App", + executablePath: "/bin/ls", + bundleIdentifier: "com.example.Sample", + bundlePath: "/Applications/Sample.app" + ) + + private static func makeSnapshot( + packets: [PacketSummary], + importedFiles: [ImportedCaptureFile] = [], + importedPacketReferenceByID: [PacketSummary.ID: ImportedPacketReference] = [:], + pins: [PacketPin] = [], + savedPackets: [SavedPacketRecord] = [], + customFilters: [PacketCustomFilter] = [], + quickFilterSelection: PacketQuickFilterSelection = .all, + structuredFilterGroup: PacketStructuredFilterGroup = .default, + selectedPacketID: PacketSummary.ID? = nil, + selectedSourceListSelection: PacketSourceListSelection = .allPackets, + tableColumnLayout: PacketTableColumnLayout? = nil + ) -> TCPViewSessionExportSnapshot { + TCPViewSessionExportSnapshot( + packets: packets, + source: .offline, + backingIdentity: "backing-a", + importedFiles: importedFiles, + importedPacketReferenceByID: importedPacketReferenceByID, + pins: pins, + savedPackets: savedPackets, + customFilters: customFilters, + quickFilterSelection: quickFilterSelection, + structuredFilterGroup: structuredFilterGroup, + displayFilterText: "tcp", + sourceListFilterText: "example", + selectedPacketID: selectedPacketID, + selectedSourceListSelection: selectedSourceListSelection, + workspaceMode: .packets, + inspectorTab: .detail, + inspectorPlacement: .bottom, + isInspectorVisible: true, + isStructuredFilterVisible: true, + tableColumnLayout: tableColumnLayout, + sourceMetadata: TCPViewSessionSourceMetadata( + fileName: "source.pcapng", + filePath: "/tmp/source.pcapng", + format: "pcapng", + packetCount: packets.count + ) + ) + } + + private static func makeState( + packets: [PacketSummary], + importedFiles: [ImportedCaptureFile], + importedPacketReferences: [TCPViewSessionImportedPacketReferenceRecord], + savedPackets: [SavedPacketRecord] = [], + selectedPacketID: PacketSummary.ID? = nil + ) -> TCPViewSessionState { + TCPViewSessionState( + source: CaptureSource.offline.rawValue, + backingIdentity: "backing-a", + importedFiles: importedFiles.map(TCPViewSessionImportedFileRecord.init), + importedPacketReferences: importedPacketReferences, + pins: [], + savedPackets: savedPackets, + customFilters: [], + quickFilterSelection: .all, + structuredFilterGroup: .default, + displayFilterText: "", + sourceListFilterText: "", + selectedPacketID: selectedPacketID, + selectedSourceListSelection: TCPViewSessionSourceListSelectionRecord(selection: .allPackets), + workspaceMode: NetworkInspectorWorkspaceMode.packets.rawValue, + inspectorTab: PacketInspectorTab.summary.rawValue, + inspectorPlacement: NetworkInspectorPlacement.trailing.rawValue, + isInspectorVisible: true, + isStructuredFilterVisible: false, + tableColumnLayout: nil, + importedFileProvenance: importedFiles.isEmpty ? nil : "Imported capture grouping is preserved for TCPViewer source-list reconstruction.", + sourceMetadata: TCPViewSessionSourceMetadata( + fileName: "source.pcapng", + filePath: "/tmp/source.pcapng", + format: "pcapng", + packetCount: packets.count + ) + ) + } + + private static func makePacket( + id: UInt64, + packetNumber: UInt64, + client: PacketClient?, + packetComment: String? = nil + ) -> PacketSummary { + PacketSummary( + id: id, + packetNumber: packetNumber, + timestamp: Date(timeIntervalSince1970: TimeInterval(packetNumber)), + source: .offline, + transportHint: .tcp, + protocolSummary: "TCP", + endpoints: PacketEndpoints( + source: PacketEndpoint(address: "10.0.0.1", port: 1234), + destination: PacketEndpoint(address: "93.184.216.34", port: 443) + ), + originalLength: 128, + capturedLength: 96, + streamID: 7, + direction: .outbound, + tcpFlags: "SYN", + tcpPayloadLength: 0, + infoSummary: "Packet \(packetNumber)", + layers: [PacketLayer(name: "Ethernet"), PacketLayer(name: "IPv4"), PacketLayer(name: "TCP")], + decodeStatus: PacketDecodeStatus(kind: .complete), + captureMetadata: PacketCaptureMetadata(linkType: .ethernet, isTruncated: false, packetComment: packetComment), + sniDomainName: "example.com", + client: client + ) + } + + private static func makeInspection(for packet: PacketSummary) -> PacketInspection { + PacketInspection( + packetID: packet.id, + packetNumber: packet.packetNumber, + rawBytes: Data(repeating: UInt8(packet.packetNumber), count: 64), + detailNodes: [ + PacketDetailNode( + id: "frame", + name: "Frame", + value: "Packet \(packet.packetNumber)", + kind: .layer + ), + ], + decodeStatus: packet.decodeStatus + ) + } + + private static func waitForResult( + _ body: (@escaping TCPViewerCompletion) -> Void + ) throws -> Value { + let semaphore = DispatchSemaphore(value: 0) + var capturedResult: Result? + body { result in + capturedResult = result + semaphore.signal() + } + semaphore.wait() + return try capturedResult!.get() + } + + private static func waitForVoid( + _ body: (@escaping TCPViewerVoidCompletion) -> Void + ) throws { + let semaphore = DispatchSemaphore(value: 0) + var capturedResult: Result? + body { result in + capturedResult = result + semaphore.signal() + } + semaphore.wait() + try capturedResult!.get() + } + + private static func encode(_ value: Value) throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(value) + } + + private static func decode(_ type: Value.Type, from data: Data) throws -> Value { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(type, from: data) + } + + private static func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("TCPViewSessionFormatTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private static func zipPackageContainsRequiredFiles(_ packageURL: URL) -> Bool { + [ + TCPViewSessionFormat.manifestPath, + TCPViewSessionFormat.capturePath, + TCPViewSessionFormat.packetsPath, + TCPViewSessionFormat.clientsPath, + TCPViewSessionFormat.annotationsPath, + TCPViewSessionFormat.statePath, + ].allSatisfy { FileManager.default.fileExists(atPath: packageURL.appendingPathComponent($0).path) } + } + + private static func writeSessionPackage( + in directory: URL, + packetRecords: [TCPViewSessionPacketRecord], + clients: TCPViewSessionClientStore, + state: TCPViewSessionState, + annotations: TCPViewSessionAnnotations = TCPViewSessionAnnotations(annotations: []), + icons: [String: Data] = [:], + additionalPacketSidecarLines: [Data] = [], + manifestPacketCount: Int? = nil + ) throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let packageURL = directory.appendingPathComponent(TCPViewSessionFormat.packageDirectoryName, isDirectory: true) + let iconsURL = packageURL.appendingPathComponent(TCPViewSessionFormat.iconsDirectoryPath, isDirectory: true) + try FileManager.default.createDirectory(at: iconsURL, withIntermediateDirectories: true) + try Data("pcapng-placeholder".utf8).write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.capturePath)) + + var packetSidecarData = Data() + for record in packetRecords { + packetSidecarData.append(try Self.encode(record)) + packetSidecarData.append(0x0A) + } + for line in additionalPacketSidecarLines { + packetSidecarData.append(line) + packetSidecarData.append(0x0A) + } + try packetSidecarData.write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.packetsPath)) + try Self.encode(clients).write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.clientsPath)) + try Self.encode(annotations).write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.annotationsPath)) + try Self.encode(state).write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.statePath)) + try Self.encode(TCPViewSessionManifest( + createdAt: fixedDate, + applicationName: "TCPViewer", + applicationVersion: "1.0", + applicationBuild: "1", + packetCount: manifestPacketCount ?? packetRecords.count + )).write(to: packageURL.appendingPathComponent(TCPViewSessionFormat.manifestPath)) + for (iconID, data) in icons { + try data.write(to: iconsURL.appendingPathComponent("\(iconID).png")) + } + + let sessionURL = directory.appendingPathComponent("sample-\(UUID().uuidString).tcpviewsession") + try Self.zipKeepingParent(packageURL, to: sessionURL) + return sessionURL + } + + private static func zipKeepingParent(_ packageURL: URL, to destinationURL: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + process.arguments = ["-c", "-k", "--keepParent", packageURL.path, destinationURL.path] + try process.run() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + } +} + +private final class SessionImportFakeCore: TCPViewerCoreProviding, @unchecked Sendable { + private let document: any OfflineCaptureDocumentProviding + + init(document: any OfflineCaptureDocumentProviding) { + self.document = document + } + + func listInterfaces(completion: @escaping TCPViewerCompletion<[CaptureInterfaceSummary]>) { + completion(.success([])) + } + + func validateCaptureFilter(_ expression: String, completion: @escaping (CaptureFilterValidation) -> Void) { + completion(CaptureFilterValidation(disposition: .valid, normalizedExpression: expression)) + } + + func validateCaptureOptions(_ options: CaptureOptions, for interface: CaptureInterfaceSummary?) throws -> CaptureOptions { + options + } + + func makeLiveCaptureSession( + interfaceID: String, + options: CaptureOptions, + completion: @escaping TCPViewerCompletion + ) { + completion(.failure(TCPViewerCoreError(code: .integrationMisconfigured, message: "Live capture is not used by this test."))) + } + + func supportedOfflineFormats() -> [CaptureFileFormat] { + CaptureFileFormat.allCases + } + + func openOfflineCaptureDocument( + at fileURL: URL, + completion: @escaping TCPViewerCompletion + ) { + completion(.success(document)) + } + + func loadPacketSummaries(from fileURL: URL, completion: @escaping TCPViewerCompletion<[PacketSummary]>) { + completion(.success(document.packetSummaries())) + } +} + +private final class SessionImportFakeOfflineDocument: OfflineCaptureDocumentProviding, @unchecked Sendable { + var eventHandler: PacketIngestEventHandler? + + private let url: URL + private let packets: [PacketSummary] + private let inspections: [PacketSummary.ID: PacketInspection] + private(set) var exportRequests: [([PacketSummary.ID], URL, CaptureFileFormat)] = [] + + init( + url: URL, + packets: [PacketSummary], + inspections: [PacketSummary.ID: PacketInspection] + ) { + self.url = url + self.packets = packets + self.inspections = inspections + } + + func open(completion: @escaping TCPViewerCompletion<[PacketSummary]>) { + completion(.success(packets)) + } + + func reopen(completion: @escaping TCPViewerCompletion<[PacketSummary]>) { + completion(.success(packets)) + } + + func cancelLoading(completion: (() -> Void)?) { + completion?() + } + + func inspectPacket(id: PacketSummary.ID, completion: @escaping TCPViewerCompletion) { + guard let inspection = inspections[id] else { + completion(.failure(TCPViewerCoreError(code: .offlineFileOpenFailed, message: "Missing test inspection."))) + return + } + + completion(.success(inspection)) + } + + func save(completion: @escaping TCPViewerVoidCompletion) { + completion(.success(())) + } + + func save(to url: URL, format: CaptureFileFormat, completion: @escaping TCPViewerVoidCompletion) { + completion(.success(())) + } + + func exportPackets( + withIDs identifiers: [PacketSummary.ID], + to url: URL, + format: CaptureFileFormat, + progress: PacketExportProgressHandler?, + shouldCancel: PacketExportCancellationCheck?, + completion: @escaping TCPViewerVoidCompletion + ) { + exportRequests.append((identifiers, url, format)) + progress?(PacketExportProgress(exportedPacketCount: identifiers.count, totalPacketCount: identifiers.count)) + completion(.success(())) + } + + func currentURL() -> URL { + url + } + + func currentMetadata() -> CaptureDocumentMetadata { + CaptureDocumentMetadata(format: .pcapng, captureApplication: "TCPViewerTests") + } + + func packetSummaries() -> [PacketSummary] { + packets + } + + func loadProgress() -> PacketLoadProgress { + PacketLoadProgress(phase: .completed, loadedPacketCount: packets.count, message: "Loaded test packets.") + } +}