Pulse 5.2 initial integration commit

This commit is contained in:
Alex Grebenyuk
2026-04-19 14:07:23 -04:00
parent faaed78178
commit 8fbce32126
213 changed files with 8903 additions and 2434 deletions
+81
View File
@@ -1,5 +1,86 @@
# Pulse 5.x
## Pulse 5.2
*Apr 19, 2026*
This release introduces improvements to search, filters, session management, performance, and more. See them in action in the [recording](https://github.com/user-attachments/assets/778381e8-6c83-48e2-a601-a138100515c9).
### Search
- Rewrite the console search engine around a new `ConsoleSearchSession` that owns a private-queue Core Data context and runs fetching, matching, and live-update observation entirely off the main thread. Results are incremental, cancelable, and batched through a short flush interval, so the UI no longer hitches on large stores — tested end-to-end on stores with 1M+ messages
- Preserve grouping while searching the console: search results are now rendered under the same section headers as the main list and share collapse state, so switching into and out of search no longer flattens the view
- Bring the full search suggestions experience to PulseUI: the search bar now surfaces both recent searches and filter suggestions (domains, paths, status codes, methods) sourced from the store index, and tapping a suggested filter applies it directly to the active criteria. Previously this was only available in Rift, and PulseUI showed recent searches only
- Add simplified search tokens so you can quickly filter by domain, path, status code, and HTTP method (work-in-progress)
- Add a separate "Query" search scope so you can search URL query strings independently of the path
- Merge "Original Request Headers" and "Current Request Headers" into a single "Request Headers" scope
- Change the default network search scopes to include the response body alongside the URL — searching for a JSON value now works out of the box
- Redesign the search scopes picker: it's now sectioned by Logs and Network, with per-section "All / None" toggles and a "Reset to Defaults" action
- Scope changes within a search session are temporary. Use the new "Save as Default" action in the scopes picker to persist your preferred selection across app launches
- Stop resetting scopes and string search options when the search sheet is dismissed
- The scopes pill now shows a meaningful label ("All", "Logs", "Network", the scope title, …) instead of just a count
- Extend `StringSearchOptions`: add a new "Multiple Words" kind that matches strings containing every space-separated word, and "Matching" / "Matching Word" matching rules for exact and word-boundary matches. Reword the existing rules for consistency ("Containing", "Starting With", "Ending With"). Applied uniformly across every search surface
- Fix a bug where the URL scope silently stripped the query string before searching
### Filters
- Bring the advanced filters into the main console: every network and message predicate is now available
- Add "Status Code", "Content Type", "Duration", "Request Body Size", and "Request State" to the built-in network filters, organised into Response, Request, and Advanced sections
- Add "Path" to the "Field" options in custom network filters, backed by a new stored attribute on `NetworkTaskEntity`, and organize the field picker into groups with section headers for easier navigation
- Add "Error Domain" and "Task Description" to the "Field" options in custom filters
- Auto-save recent filters: the last-used filter combinations are now remembered and shown in a dedicated "Recent Filters" section, letting you re-apply previous criteria in one tap
- Improve filters on watchOS: replace the Status Code range picker with a standard Picker offering predefined options (Any, Success 2xx, Redirects 3xx, Client Errors 4xx, Server Errors 5xx, All Errors), and remove Duration, Response Body Size, and Request Body Size filters that didn't fit the small screen. The available network filters on watchOS are now: Status Code, Content Type, HTTP Method, Custom Filters, Hosts, Task Type, Request State, Response Source, and Redirect. Message filters: Custom Filters, Levels, and Labels.
### Sessions
- Add a "Sessions" shortcut to the console toolbar for quick access to the sessions picker. When a non-current session is selected, the icon is highlighted as a reminder
- Add a "Show Previous Session" footer to the console list: appears at the bottom of the list and loads the next older session on tap
- Redesign session cells: session numbering, a "Current" badge for the active session, message counts, and relative timestamps for today's sessions (e.g. "5 min ago")
- Add a context menu to session cells with a preview showing the session date, app version, and UUID
- Add a "Remove Logs" swipe action and context-menu item for the current session that clears its logs without deleting the session itself
- Move the Share swipe action to the leading edge and Delete to the trailing edge in the session list
- Add "Select" mode to the session list. Each date-grouped section now shows a "Select All" / "Deselect All" button in Select mode, so you can grab every session for a given day in one tap. The selection count is shown in the navigation title and actions live on the bottom bar — which now also appears correctly in read-only stores
- Bring the full sessions picker to watchOS and tvOS — previously these platforms only showed a truncated list
### Console list & toolbar
- Redesign the console heading: the navigation title is now inline and doubles as a menu for switching between "All", "Logs", and "Network", with a subtitle showing the current count (e.g. "21 Tasks"). While searching, the title reflects the search state
- Add "Select" mode to the console list: enter via the context menu or a two-finger swipe gesture, pick multiple log entries, and share them using the toolbar at the bottom. Selection works in search mode too
- Consolidate the regular and search toolbars into a single `ConsoleToolbarView`. Filters and the "Only Errors" toggle now live on the leading edge in both modes; the search scopes pill and string-options menu appear on the trailing edge only while searching
- Add "Sort By" and "Group By" pills to the console toolbar (non-search mode). Sort By exposes the field and ascending/descending order; Group By is wired all the way through `ConsoleDataSource` so the list is now rendered as proper sections in PulseUI on iOS for the first time. Both menus remain available from the "More" menu
- Add a quick-reset `xmark.circle` to the Filters, Sort By, and Group By pills: when non-default, tap to reset in one tap
- Make grouped sections in the console list collapsible: tap a section header to toggle expansion, with a chevron indicating the current state. The group name is followed by the item count
- Persist manually collapsed console sections across launches, scoped to the current `(mode, groupBy)`. "Collapse All" / "Expand All" stay in-memory only
- Move "Filters" out of the inline navigation bar items and into the "More" menu
- Unify all console toolbar pills (Filters, Only Errors, Sort By, Group By, search scopes/options) on a shared layout so they share the same height and visual style
### Customisation API (`ConsoleDelegate`)
- Add `ConsoleDelegate` protocol for per-task customization of `ConsoleView`. `ConsoleView.init` now accepts an optional `delegate` that can return a different `ConsoleListDisplaySettings` for each task. The protocol is `@MainActor` and uses `UserSettings.shared.listDisplayOptions` as the default
- Add `ConsoleDelegate.console(contentViewFor:)` to replace the task cell's main content area (method + URL) with a caller-supplied SwiftUI view. When non-nil, it supersedes `ConsoleListDisplaySettings.ContentSettings` entirely; the header and footer still render from settings as usual
- Add `ConsoleDelegate.console(responseBodyViewFor:)` to replace the built-in response body viewer with a caller-supplied SwiftUI view. Use it to plug in a protobuf decoder (via the integrator's own `SwiftProtobuf` types) or any other format the default `FileViewer` doesn't understand
- Add `ConsoleDelegate.console(inspectorViewFor:)` to inject a custom SwiftUI section into the network inspector (e.g. decoded GraphQL variables, parsed protobuf). Rendered after the built-in response/metrics sections
- Add `ConsoleDelegate.console(contextMenuFor:)` to inject app-specific items (e.g. "Replay", "Open in admin panel") into the task cell's context menu and into the network inspector's "More" menu
- Add `ConsoleDelegate.console(redact:field:for:)` for masking sensitive strings (auth tokens, PII) before they are rendered. Applied to URL, host, header, task-description, and caller-supplied strings in the task cell as well as the inspector's navigation title. A new `ConsoleRedactionField` enum identifies which field is about to be rendered
- Make `RichTextView` and `RichTextViewModel` public so integrators building custom `responseBodyViewFor:` views can reuse the standard text-viewer chrome (search, line numbers, link detection, share). Construct a `RichTextViewModel(string:contentType:)` from an `NSAttributedString` (optionally produced by the existing attributed-string helpers) and wrap it in `RichTextView`
- Add `ConsoleListDisplaySettings.ContentSettings.customText` to render a caller-supplied string verbatim in place of the default method + URL content (e.g. a GraphQL operation name). When set, `showMethod` and `components` are ignored
- Add `ConsoleListDisplaySettings.TaskField.url(components:)` to render the full URL, or a specific `URLComponent` when provided, in header/footer fields
- Add `ConsoleListDisplaySettings.TaskField.custom(String)` to render a caller-supplied string verbatim in header/footer fields
- Add `NetworkLogger.ContentType.isProtobuf` for matching `application/x-protobuf` and `application/grpc` content types
### Performance
- Optimize the console list rendering path: cache formatted timestamps on `LoggerMessageEntity` / `NetworkTaskEntity` so each visible cell no longer re-runs `DateFormatter`, switch the task cell to SwiftUI's `AttributedString` for header/footer text (measurably faster than bridging through `NSAttributedString`), and drop an expensive `.tracking()` modifier that was dominating cell layout time
- Replace `ManagedObjectsCountObserver`'s `NSFetchedResultsController` with a direct `count(for:)` query driven by `NSManagedObjectContextObjectsDidChange`, so the toolbar log/task counters no longer materialize every matching object just to read `.count`
- Optimize `LoggerStore.reduceDatabaseSize` — the new implementation is roughly 2× faster
- Switch the SQLite journal mode from `DELETE` to WAL (Write-Ahead Logging), improving write performance and reducing contention between readers and writers
### Fixes
- Fix a crash when tapping "Remove All"
- Fix a crash when saving to a Core Data store on devices with no disk space
- Fix deprecation warnings
## Pulse 5.1.2
*Oct 10, 2024*
+40 -24
View File
@@ -27,14 +27,20 @@
0C8FCB622C45F06300C4FD84 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0C8FCB612C45F06300C4FD84 /* PulseUI */; };
0C8FCB642C45F06900C4FD84 /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = 0C8FCB632C45F06900C4FD84 /* Pulse */; };
0C8FCB662C45F06900C4FD84 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0C8FCB652C45F06900C4FD84 /* PulseUI */; };
0C8FCB6A2C45F12900C4FD84 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB682C45F12900C4FD84 /* MockStore.swift */; };
0C8FCB6E2C45F12900C4FD84 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB682C45F12900C4FD84 /* MockStore.swift */; };
0C8FCB6F2C45F12900C4FD84 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB682C45F12900C4FD84 /* MockStore.swift */; };
0C8FCB702C45F12900C4FD84 /* MockTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB692C45F12900C4FD84 /* MockTask.swift */; };
0C8FCB742C45F12900C4FD84 /* MockTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB692C45F12900C4FD84 /* MockTask.swift */; };
0C8FCB752C45F12900C4FD84 /* MockTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8FCB692C45F12900C4FD84 /* MockTask.swift */; };
0CA245732C85E87A00B432DA /* PulseProxy in Frameworks */ = {isa = PBXBuildFile; productRef = 0CA245722C85E87A00B432DA /* PulseProxy */; };
0CDACDE629EC6607007C15CD /* repos.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CDACDE129EC6607007C15CD /* repos.json */; };
0CF7DEC72F9550AB007D44C2 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC42F9550AB007D44C2 /* MockStore.swift */; };
0CF7DEC82F9550AB007D44C2 /* Resources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC52F9550AB007D44C2 /* Resources.swift */; };
0CF7DEC92F9550AB007D44C2 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC22F9550AB007D44C2 /* Helpers.swift */; };
0CF7DECA2F9550AB007D44C2 /* MockDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC32F9550AB007D44C2 /* MockDataTask.swift */; };
0CF7DECC2F9550AB007D44C2 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC42F9550AB007D44C2 /* MockStore.swift */; };
0CF7DECD2F9550AB007D44C2 /* Resources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC52F9550AB007D44C2 /* Resources.swift */; };
0CF7DECE2F9550AB007D44C2 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC22F9550AB007D44C2 /* Helpers.swift */; };
0CF7DECF2F9550AB007D44C2 /* MockDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC32F9550AB007D44C2 /* MockDataTask.swift */; };
0CF7DED12F9550AB007D44C2 /* MockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC42F9550AB007D44C2 /* MockStore.swift */; };
0CF7DED22F9550AB007D44C2 /* Resources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC52F9550AB007D44C2 /* Resources.swift */; };
0CF7DED32F9550AB007D44C2 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC22F9550AB007D44C2 /* Helpers.swift */; };
0CF7DED42F9550AB007D44C2 /* MockDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7DEC32F9550AB007D44C2 /* MockDataTask.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -98,9 +104,11 @@
0C8FCB4C2C45F01E00C4FD84 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
0C8FCB4D2C45F01E00C4FD84 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = CHANGELOG.md; path = ../CHANGELOG.md; sourceTree = "<group>"; };
0C8FCB4E2C45F03500C4FD84 /* Pulse */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pulse; path = ..; sourceTree = "<group>"; };
0C8FCB682C45F12900C4FD84 /* MockStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockStore.swift; sourceTree = "<group>"; };
0C8FCB692C45F12900C4FD84 /* MockTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockTask.swift; sourceTree = "<group>"; };
0CDACDE129EC6607007C15CD /* repos.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = repos.json; sourceTree = "<group>"; };
0CF7DEC22F9550AB007D44C2 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = "<group>"; };
0CF7DEC32F9550AB007D44C2 /* MockDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataTask.swift; sourceTree = "<group>"; };
0CF7DEC42F9550AB007D44C2 /* MockStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStore.swift; sourceTree = "<group>"; };
0CF7DEC52F9550AB007D44C2 /* Resources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Resources.swift; sourceTree = "<group>"; };
49E82A8526D107A00070244F /* AlamofireIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlamofireIntegration.swift; sourceTree = "<group>"; };
49E82A8826D1083D0070244F /* MoyaIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoyaIntegration.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -182,8 +190,8 @@
0C70EA742A3F611B000B1071 /* iOS */,
0C30B2F42A3F375400D65F8F /* iOS-paired */,
0C6460D525F579B8002C55B1 /* tvOS */,
0CF7DEC62F9550AB007D44C2 /* Shared */,
0C0B31EB26D179A20045C9E1 /* Integrations */,
0C8FCB672C45F0A800C4FD84 /* Shared */,
);
path = Sources;
sourceTree = "<group>";
@@ -246,15 +254,6 @@
path = iOS;
sourceTree = "<group>";
};
0C8FCB672C45F0A800C4FD84 /* Shared */ = {
isa = PBXGroup;
children = (
0C8FCB682C45F12900C4FD84 /* MockStore.swift */,
0C8FCB692C45F12900C4FD84 /* MockTask.swift */,
);
path = Shared;
sourceTree = "<group>";
};
0CDACDE029EC65F4007C15CD /* Resources */ = {
isa = PBXGroup;
children = (
@@ -263,6 +262,17 @@
path = Resources;
sourceTree = "<group>";
};
0CF7DEC62F9550AB007D44C2 /* Shared */ = {
isa = PBXGroup;
children = (
0CF7DEC22F9550AB007D44C2 /* Helpers.swift */,
0CF7DEC32F9550AB007D44C2 /* MockDataTask.swift */,
0CF7DEC42F9550AB007D44C2 /* MockStore.swift */,
0CF7DEC52F9550AB007D44C2 /* Resources.swift */,
);
path = Shared;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -415,11 +425,13 @@
buildActionMask = 2147483647;
files = (
0C0B31F126D179A20045C9E1 /* ViewController.swift in Sources */,
0CF7DEC72F9550AB007D44C2 /* MockStore.swift in Sources */,
0CF7DEC82F9550AB007D44C2 /* Resources.swift in Sources */,
0CF7DEC92F9550AB007D44C2 /* Helpers.swift in Sources */,
0CF7DECA2F9550AB007D44C2 /* MockDataTask.swift in Sources */,
0C0B31ED26D179A20045C9E1 /* AppDelegate.swift in Sources */,
0C0B31EF26D179A20045C9E1 /* SceneDelegate.swift in Sources */,
0C57511A2B01BA40001074E5 /* DebugAnalyticsView.swift in Sources */,
0C8FCB752C45F12900C4FD84 /* MockTask.swift in Sources */,
0C8FCB6F2C45F12900C4FD84 /* MockStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -427,10 +439,12 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0CF7DED12F9550AB007D44C2 /* MockStore.swift in Sources */,
0CF7DED22F9550AB007D44C2 /* Resources.swift in Sources */,
0CF7DED32F9550AB007D44C2 /* Helpers.swift in Sources */,
0CF7DED42F9550AB007D44C2 /* MockDataTask.swift in Sources */,
0C6460DF25F579B8002C55B1 /* ContentView.swift in Sources */,
0C6460DC25F579B8002C55B1 /* Pulse_tvOSApp.swift in Sources */,
0C8FCB742C45F12900C4FD84 /* MockTask.swift in Sources */,
0C8FCB6E2C45F12900C4FD84 /* MockStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -438,9 +452,11 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0CF7DECC2F9550AB007D44C2 /* MockStore.swift in Sources */,
0CF7DECD2F9550AB007D44C2 /* Resources.swift in Sources */,
0CF7DECE2F9550AB007D44C2 /* Helpers.swift in Sources */,
0CF7DECF2F9550AB007D44C2 /* MockDataTask.swift in Sources */,
0C70EA762A3F611B000B1071 /* Pulse_Demo_iOSApp.swift in Sources */,
0C8FCB702C45F12900C4FD84 /* MockTask.swift in Sources */,
0C8FCB6A2C45F12900C4FD84 /* MockStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+47
View File
@@ -0,0 +1,47 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
public struct TestTemporaryDirectory {
public let url: URL
public static var isFirstRun = true
public init() {
let rootTempURL = Files.temporaryDirectory
.appending(directory: "com.github.kean.logger-testing")
if TestTemporaryDirectory.isFirstRun {
TestTemporaryDirectory.isFirstRun = false
try? Files.removeItem(at: rootTempURL)
}
url = rootTempURL.appending(directory: UUID().uuidString)
try? Files.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
}
public func remove() {
try? FileManager.default.removeItem(at: url)
}
}
@discardableResult
public func benchmark<T>(title: String, operation: () throws -> T) rethrows -> T {
let startTime = CFAbsoluteTimeGetCurrent()
let value = try operation()
let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
print("Time elapsed for \(title): \(timeElapsed * 1000.0) ms.")
return value
}
public func benchmarkStart() -> CFAbsoluteTime {
CFAbsoluteTimeGetCurrent()
}
public func benchmarkEnd(_ startTime: CFAbsoluteTime, title: String) {
let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
print("Time elapsed for \(title): \(timeElapsed * 1000.0) ms.")
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
../../../Sources/PulseUI/Mocks/MockStore.swift
+151
View File
@@ -0,0 +1,151 @@
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import CoreData
import Pulse
extension LoggerStore {
package static let test: LoggerStore = {
let store = try! LoggerStore(
storeURL: URL(fileURLWithPath: "/dev/null/\(UUID().uuidString)"),
options: [.create, .inMemory, .synchronous]
)
_populate(store: store)
return store
}()
}
private struct Logger {
let label: String
let store: LoggerStore
func log(level: LoggerStore.Level, _ message: String, metadata: LoggerStore.Metadata? = nil) {
self.store.storeMessage(label: label, level: level, message: message, metadata: metadata, file: #file, function: #function, line: 0)
}
}
public func _populate(store: LoggerStore) {
func logger(named: String) -> Logger {
Logger(label: named, store: store)
}
logger(named: "application")
.log(level: .info, "UIApplication.didFinishLaunching")
logger(named: "application")
.log(level: .info, "UIApplication.willEnterForeground")
logger(named: "auth")
.log(level: .trace, "Instantiated Session")
logger(named: "auth")
.log(level: .trace, "Instantiated the new login request")
let networkLogger = NetworkLogger(store: store)
networkLogger.logTask(MockDataTask.login)
logger(named: "analytics")
.log(level: .debug, "Will navigate to Dashboard")
networkLogger.logTask(MockDataTask.octocat)
networkLogger.logTask(MockDataTask.profileFailure)
let stackTrace = """
Replace this implementation with code to handle the error appropriately. fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
2015-12-08 15:04:03.888 Conversion[76776:4410388] call stack:
(
0 Conversion 0x000694b5 -[ViewController viewDidLoad] + 128
1 UIKit 0x27259f55 <redacted> + 1028
...
9 UIKit 0x274f67a7 <redacted> + 134
10 FrontBoardServices 0x2b358ca5 <redacted> + 232
11 FrontBoardServices 0x2b358f91 <redacted> + 44
12 CoreFoundation 0x230e87c7 <redacted> + 14
...
16 CoreFoundation 0x23038ecd CFRunLoopRunInMode + 108
17 UIKit 0x272c7607 <redacted> + 526
18 UIKit 0x272c22dd UIApplicationMain + 144
19 Conversion 0x000767b5 main + 108
20 libdyld.dylib 0x34f34873 <redacted> + 2
)
"""
logger(named: "auth")
.log(level: .warning, .init(stringLiteral: stackTrace))
logger(named: "default")
.log(level: .critical, "💥 0xDEADBEEF")
}
private func makeMockTask(with url: String, data: Data) -> MockDataTask {
let url = URL(string: url)!
let request = URLRequest(url: url)
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!
return MockDataTask(
request: request,
response: response,
responseBody: data,
metrics: makeMetrics(with: url.absoluteString)
)
}
private func makeMetrics(with url: String) -> NetworkLogger.Metrics {
var metrics = MockDataTask.login.metrics
metrics.transactions = metrics.transactions.map {
var transaction = $0
transaction.request = .init(URLRequest(url: URL(string: url)!))
transaction.response = .init(HTTPURLResponse(url: URL(string: url)!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
return transaction
}
return metrics
}
public struct ExportableStoreConstants {
public static let sessionOne = LoggerStore.Session(
id: UUID(uuidString: "1B39EDCF-DA05-4145-8389-63CC515C8664")!,
startDate: ISO8601DateFormatter().date(from: "2033-01-05T08:42:07Z")!
)
public static let sessionTwo = LoggerStore.Session(
id: UUID(uuidString: "F8EA7E68-66C5-4AC6-8AC9-5C0CF5C4CC85")!,
startDate: ISO8601DateFormatter().date(from: "2033-01-05T08:42:07Z")!
)
/// - warning: This is important to set to make sure some blobs are recorded as files.
public static let inlineLimit = 1000
/// Recorded in session #1 and #2
public static let blobA = String(repeating: "A", count: 5000).data(using: .utf8)!
/// Recorded in session #1
public static let blobB = String(repeating: "B", count: 7000).data(using: .utf8)!
/// Recorded in session #1 and #2
public static let blobC = String(repeating: "C", count: 100).data(using: .utf8)!
/// Recorded in session #2
public static let blobD = String(repeating: "D", count: 250).data(using: .utf8)!
}
extension NetworkLogger {
public func logTask(_ mockTask: MockDataTask, urlSession: URLSession = mockSession) {
let dataTask = urlSession.dataTask(with: mockTask.request)
dataTask.setValue(mockTask.response, forKey: "response")
logTaskCreated(dataTask)
logDataTask(dataTask, didReceive: mockTask.responseBody)
logTask(dataTask, didFinishCollecting: mockTask.metrics)
logTask(dataTask, didCompleteWithError: nil)
}
}
public let mockSession: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = [
"User-Agent": "Pulse Demo/0.19 iOS"
]
return URLSession(configuration: .default)
}()
-1
View File
@@ -1 +0,0 @@
../../../Sources/PulseUI/Mocks/MockTask.swift
File diff suppressed because one or more lines are too long
+3 -5
View File
@@ -13,14 +13,12 @@ let package = Package(
products: [
.library(name: "Pulse", targets: ["Pulse"]),
.library(name: "PulseProxy", targets: ["PulseProxy"]),
.library(name: "PulseUI", targets: ["PulseUI"])
.library(name: "PulseUI", targets: ["PulseUI"]),
],
targets: [
.target(name: "Pulse"),
.target(name: "Pulse", dependencies: ["PulseObjCHelpers"]),
.target(name: "PulseProxy", dependencies: ["Pulse"]),
.target(name: "PulseUI", dependencies: ["Pulse"]),
],
swiftLanguageVersions: [
.v5
.target(name: "PulseObjCHelpers"),
]
)
@@ -1,10 +1,31 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import CoreData
import PulseObjCHelpers
extension PulseObjCExceptionCatcher {
/// Executes the given throwing block, catching any Objective-C exceptions
/// and converting them to Swift errors.
public static func perform<T>(_ block: () throws -> T) throws -> T {
var result: Result<T, Swift.Error>?
try perform {
result = Result(catching: block)
}
return try result!.get()
}
}
extension NSManagedObjectContext {
/// Saves the context, catching Objective-C exceptions (e.g. from CoreData
/// when the disk is full) that would otherwise bypass Swift error handling.
public func safeSave() throws {
try PulseObjCExceptionCatcher.perform {
try self.save()
}
}
package func fetch<T: NSManagedObject>(_ entity: T.Type, _ configure: (NSFetchRequest<T>) -> Void = { _ in }) throws -> [T] {
let request = NSFetchRequest<T>(entityName: String(describing: entity))
configure(request)
@@ -39,7 +60,7 @@ extension NSManagedObjectContext {
}
extension NSPersistentContainer {
static var inMemoryReadonlyContainer: NSPersistentContainer {
package static var inMemoryReadonlyContainer: NSPersistentContainer {
let container = NSPersistentContainer(name: "EmptyStore", managedObjectModel: LoggerStore.model)
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
+6 -1
View File
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import CoreData
@@ -113,6 +113,11 @@ extension URL {
}
return nil
}
func getPath() -> String? {
let path = self.path
return path.isEmpty ? nil : path
}
}
package struct LoggerBlogDataStore {
+1 -1
View File
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
+1 -1
View File
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Security
+1 -1
View File
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
+1 -1
View File
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
+1 -1
View File
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import CoreData
+1 -1
View File
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import CoreData
@@ -46,6 +46,12 @@ extension LoggerStore {
/// There is no need to pass the ``create`` option. The ``sweep`` option
/// will still work with the in-memory store.
public static let inMemory = Options(rawValue: 1 << 4)
/// Disables SQLite durability features (WAL, fsync, shared locking)
/// in favor of raw write speed. Intended for one-shot bulk ingestion
/// mock data generation, imports where crash safety does not
/// matter. A crash mid-write may leave the store corrupted.
package static let unsafe = Options(rawValue: 1 << 5)
}
/// The store configuration.
@@ -81,7 +87,7 @@ extension LoggerStore {
/// value is `8 MB`. The same limit applies to requests.
public var responseBodySizeLimit: Int = 8 * 1048576
var inlineLimit = 16384 // 16 KB
package var inlineLimit = 16384 // 16 KB
/// By default, two weeks. The messages and requests that are older that
/// two weeks will get automatically deleted.
@@ -1,9 +1,16 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import CoreData
private let timestampFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US")
f.dateFormat = "HH:mm:ss.SSS"
return f
}()
public final class LoggerSessionEntity: NSManagedObject {
@NSManaged public var createdAt: Date
@NSManaged public var id: UUID
@@ -25,6 +32,7 @@ public final class LoggerMessageEntity: NSManagedObject {
@NSManaged public var task: NetworkTaskEntity?
public lazy var metadata = { KeyValueEncoding.decodeKeyValuePairs(rawMetadata) }()
public lazy var formattedTimestamp: String = timestampFormatter.string(from: createdAt)
}
public final class NetworkTaskEntity: NSManagedObject {
@@ -44,6 +52,7 @@ public final class NetworkTaskEntity: NSManagedObject {
@NSManaged public var url: String?
@NSManaged public var host: String?
@NSManaged public var path: String?
@NSManaged public var httpMethod: String?
// MARK: Response
@@ -103,6 +112,16 @@ public final class NetworkTaskEntity: NSManagedObject {
// MARK: Helpers
public lazy var metadata = { rawMetadata.map(KeyValueEncoding.decodeKeyValuePairs) }()
public lazy var formattedTimestamp: String = timestampFormatter.string(from: createdAt)
public lazy var parsedURLComponents: URLComponents? = url.flatMap { URLComponents(string: $0) }
// View-layer formatting caches populated by PulseUI. Keyed on the input
// value so they self-invalidate when the underlying field changes.
package var cachedFormattedURL: (key: AnyHashable, value: String?)?
package var cachedFormattedDuration: (duration: Double, value: String)?
package var cachedFormattedRequestBodySize: (size: Int64, value: String)?
package var cachedFormattedResponseBodySize: (size: Int64, value: String)?
package var cachedStatus: String?
/// Returns request state.
public var state: State {
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -79,7 +79,7 @@ enum AppInfo {
}
extension LoggerStore.Info.AppInfo {
static let current = LoggerStore.Info.AppInfo(
package static let current = LoggerStore.Info.AppInfo(
bundleIdentifier: AppInfo.bundleIdentifier,
name: AppInfo.appName,
version: AppInfo.appVersion,
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
extension LoggerStore {
@frozen public enum MetadataValue {
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import CoreData
@@ -51,6 +51,7 @@ extension LoggerStore {
Attribute("taskType", .integer16AttributeType),
Attribute("url", .stringAttributeType),
Attribute("host", .stringAttributeType),
Attribute("path", .stringAttributeType),
Attribute("httpMethod", .stringAttributeType),
Attribute("statusCode", .integer32AttributeType),
Attribute("errorCode", .integer32AttributeType),
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
extension LoggerStore {
/// A semantic version.
+29 -12
View File
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import CoreData
@@ -246,8 +246,17 @@ public final class LoggerStore: @unchecked Sendable, Identifiable {
private static func makeContainer(databaseURL: URL, options: Options) -> NSPersistentContainer {
let container = NSPersistentContainer(name: databaseURL.lastPathComponent, managedObjectModel: Self.model)
let store = NSPersistentStoreDescription(url: databaseURL)
store.setValue("DELETE" as NSString, forPragmaNamed: "journal_mode")
store.type = options.contains(.inMemory) ? NSInMemoryStoreType : NSSQLiteStoreType
if options.contains(.unsafe) {
// journal_mode=OFF no rollback/WAL; writes go straight to the main file
// synchronous=OFF skip fsyncs; rely on the OS to flush
// temp_store=MEMORY keep transient b-trees in RAM
// locking_mode=EXCLUSIVE we're the only writer; skip the shared-cache handshake
store.setValue("OFF" as NSString, forPragmaNamed: "journal_mode")
store.setValue("OFF" as NSString, forPragmaNamed: "synchronous")
store.setValue("MEMORY" as NSString, forPragmaNamed: "temp_store")
store.setValue("EXCLUSIVE" as NSString, forPragmaNamed: "locking_mode")
}
container.persistentStoreDescriptions = [store]
return container
}
@@ -271,7 +280,7 @@ public final class LoggerStore: @unchecked Sendable, Identifiable {
entity.id = session.id
entity.version = info.version
entity.build = info.build
try? backgroundContext.save()
try? backgroundContext.safeSave()
}
}
@@ -346,7 +355,7 @@ extension LoggerStore {
perform { _ in self._handle(event) }
}
private func _handle(_ event: Event) {
package func _handle(_ event: Event) {
switch event {
case .messageStored(let event): process(event)
case .networkTaskCreated(let event): process(event)
@@ -375,6 +384,7 @@ extension LoggerStore {
entity.url = event.originalRequest.url?.absoluteString
entity.host = event.originalRequest.url.flatMap { $0.getHost() }
entity.path = event.originalRequest.url.flatMap { $0.getPath() }
entity.httpMethod = event.originalRequest.httpMethod
entity.requestState = NetworkTaskEntity.State.pending.rawValue
entity.originalRequest = makeRequest(for: event.originalRequest)
@@ -401,6 +411,7 @@ extension LoggerStore {
entity.url = event.originalRequest.url?.absoluteString
entity.host = event.originalRequest.url.flatMap { $0.getHost() }
entity.path = event.originalRequest.url.flatMap { $0.getPath() }
entity.httpMethod = event.originalRequest.httpMethod
entity.statusCode = Int32(event.response?.statusCode ?? 0)
entity.responseContentType = event.response?.contentType?.type
@@ -744,7 +755,7 @@ extension LoggerStore {
private func saveAndReset() {
do {
try backgroundContext.save()
try backgroundContext.safeSave()
} catch {
#if DEBUG
debugPrint(error)
@@ -963,7 +974,7 @@ extension LoggerStore {
try removeMessages(with: NSCompoundPredicate(notPredicateWithSubpredicate: predicate))
}
if backgroundContext.hasChanges {
try backgroundContext.save()
try backgroundContext.safeSave()
}
}
@@ -1049,7 +1060,7 @@ extension LoggerStore {
infoBlob.key = "info"
infoBlob.data = try JSONEncoder().encode(info)
try document.context.save()
try document.context.safeSave()
try? document.close()
}
}
@@ -1104,12 +1115,18 @@ extension LoggerStore {
}
// First remove some old messages
let messages = try backgroundContext.fetch(LoggerMessageEntity.self, sortedBy: \.createdAt, ascending: false)
let count = messages.count
let count = try backgroundContext.count(for: LoggerMessageEntity.self)
guard count > 10 else { return } // Sanity check
let cutoffDate = messages[Int(Double(count) * configuration.trimRatio)].createdAt
try removeMessages(before: cutoffDate)
let offset = Int(Double(count) * configuration.trimRatio)
let cutoffMessages = try backgroundContext.fetch(LoggerMessageEntity.self) {
$0.sortDescriptors = [NSSortDescriptor(keyPath: \LoggerMessageEntity.createdAt, ascending: false)]
$0.fetchOffset = offset
$0.fetchLimit = 1
}
if let cutoffDate = cutoffMessages.first?.createdAt {
try removeMessages(before: cutoffDate)
}
}
private func removeMessages(before date: Date) throws {
@@ -1288,7 +1305,7 @@ extension LoggerStore {
extension Version {
package static let minimumSupportedVersion = LoggerStore.Version(3, 1, 0)
package static let currentStoreVersion = LoggerStore.Version(3, 6, 0)
package static let currentStoreVersion = LoggerStore.Version(3, 7, 0)
package static let currentProtocolVersion = LoggerStore.Version(4, 0, 0)
}
@@ -0,0 +1,46 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import CoreData
package protocol LoggerStoreProtocol: AnyObject {
var storeURL: URL { get }
var version: LoggerStore.Version { get }
var container: NSPersistentContainer { get }
var backgroundContext: NSManagedObjectContext { get }
/// Creates a new background context with the `LoggerBlogDataStore` user info
/// key set up so that `LoggerBlobHandleEntity.data` works on the context.
func newBackgroundContext() -> NSManagedObjectContext
/// The ID of the current (active) session, if applicable.
var currentSessionID: UUID? { get }
/// Whether the store is read-only.
var isReadonly: Bool { get }
func info() async throws -> LoggerStore.Info
func performChanges(_ changes: @escaping (NSManagedObjectContext) -> Void)
func removeAll()
func removeSessions(withIDs sessionIDs: Set<UUID>)
func clearSessions(withIDs sessionIDs: Set<UUID>)
}
extension LoggerStoreProtocol {
package var viewContext: NSManagedObjectContext { container.viewContext }
package var currentSessionID: UUID? { nil }
package var isReadonly: Bool { true }
package func removeAll() {}
package func removeSessions(withIDs sessionIDs: Set<UUID>) {}
package func clearSessions(withIDs sessionIDs: Set<UUID>) {}
}
extension LoggerStore: LoggerStoreProtocol {
package var currentSessionID: UUID? { session.id }
package var isReadonly: Bool { options.contains(.readonly) }
package func performChanges(_ changes: @escaping (NSManagedObjectContext) -> Void) {
perform(changes)
}
}
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -526,6 +526,7 @@ extension NetworkLogger {
public var isImage: Bool { type.hasPrefix("image/") }
public var isHTML: Bool { type.contains("html") }
public var isEncodedForm: Bool { type == "application/x-www-form-urlencoded" }
public var isProtobuf: Bool { type.contains("protobuf") || type.contains("grpc") }
public var lastComponent: String {
type.components(separatedBy: "/").last ?? type
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import AVFoundation
import Foundation
@@ -16,7 +16,7 @@ Add **Pulse** and **PulseUI** libraries to your app.
## 2. Integrate Pulse Framework
**Pulse** framework contains APIs for logging, capturing, and mocking network requests, as well as connecting to the Pulse Pro apps.
**Pulse** framework contains APIs for logging, capturing, and mocking network requests, as well as connecting to the Pulse Pro apps.
### 2.1. Capture Network Requests
@@ -44,7 +44,7 @@ NetworkLogger.enableProxy()
#endif
```
> important: **PulseProxy** uses swizzling and private APIs and it is not recommended that you include it in the production builds of your app.
> important: **PulseProxy** uses method swizzling and private APIs, and it is not recommended that you include it in the production builds of your app. It is also not guaranteed to continue working with new versions of the system SDKs.
### 2.2. Collect Logs
@@ -79,7 +79,7 @@ NavigationLink(destination: ConsoleView()) {
## 4. Get Pulse Apps
Pulse also provides separate indispensable [macOS and iOS apps](https://pulselogger.com) that you can use to view logs collected by the Pulse SDK and even debug your apps in real-time with features like response mocking. The app are [available on the App Store](https://apps.apple.com/us/app/pulse-network-logger/id6661031747).
Pulse also provides separate, indispensable [macOS and iOS apps](https://pulselogger.com) that you can use to view logs collected by the Pulse SDK and even debug your apps in real-time with features like response mocking. The apps are [available on the App Store](https://apps.apple.com/us/app/pulse-network-logger/id6661031747).
The apps require two more simple configuration steps.
@@ -4,10 +4,10 @@ Learn how to enable and configure network logging and debugging.
## Overview
Pulse works on the `URLSession` level, and it needs access to its callbacks to log network requests and capture network metrics. The framework is modular and provides multiple options that can accommodate almost any system. By the end of this article, you will have a system that:
Pulse works at the `URLSession` level, and it needs access to its callbacks to log network requests and capture network metrics. The framework is modular and provides multiple options that can accommodate almost any system. By the end of this article, you will have a system that:
- Captures network requests and metrics
- Supports debugging features powered by Pulse Pro, such as mocking
- Supports debugging features powered by Pulse Pro, such as mocking
## Capture Network Requests
@@ -139,7 +139,7 @@ configuration.sensitiveDataFields = ["password"]
let logger = NetworkLogger(configuration: configuration)
```
You can then replace the default decoder with your custom instance:
You can then replace the default logger with your custom instance:
```swift
NetworkLogger.shared = logger
@@ -153,7 +153,7 @@ If the built-in configuration options don't cover all of your use cases, you can
### Trace in Xcode Console
Pulse doesn't print anything in the Xcode Console by default, but it's easy to enable logging for network requests. ``LoggerStore`` re-translates all of the log events that it processes using ``LoggerStore/events`` publisher that you can leverage.
Pulse doesn't print anything in the Xcode Console by default, but it's easy to enable logging for network requests. ``LoggerStore`` re-translates all of the log events that it processes using ``LoggerStore/events`` publisher that you can leverage.
```swift
func register(store: LoggerStore) {
@@ -174,10 +174,10 @@ private func process(event: LoggerStore.Event) {
## Network Debugging
In addition to logging, Pulse provides network debugging features, such as logging. If you use the recommended ``URLSessionProxy``, these features are enabled automatically, and you don't need to do anything. In other cases, make sure to inject ``MockingURLProtocol`` in the set of URL protocols used by your `URLSession`:
In addition to logging, Pulse provides network debugging features, such as mocking. If you use the recommended ``URLSessionProxy``, these features are enabled automatically, and you don't need to do anything. In other cases, make sure to inject ``MockingURLProtocol`` in the set of URL protocols used by your `URLSession`:
```swift
let configuration = URLSesionConfiguration.default
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? [])
```
@@ -10,7 +10,7 @@ Pulse is highly customizable and you can tweak it to best match your style and y
### Configure Store
``LoggerStore`` is the primary way to configure how logs are stored. It uses a database to record logs in an efficient binary format and employes a number of space [optimizations techniques](https://kean.blog/post/pulse-2#space-savings), including fast [lzfse](https://developer.apple.com/documentation/compression/algorithm/lzfse) compression. The store automatically limits how much spaces it takes and also removed old logs.
``LoggerStore`` is the primary way to configure how logs are stored. It uses a database to record logs in an efficient binary format and employs a number of space [optimization techniques](https://kean.blog/post/pulse-2#space-savings), including fast [lzfse](https://developer.apple.com/documentation/compression/algorithm/lzfse) compression. The store automatically limits how much space it takes and also removes old logs.
```swift
LoggerStore.shared.configuration.sizeLimit = 512 * 1_000_000
@@ -58,7 +58,7 @@ struct AnalyticsLogsView: View {
}
```
> important: In the current schema, the alogger creates an associated ``LoggerMessageEntity`` entity for every ``NetworkTaskEntity``, but it will likely change in the future.
> important: In the current schema, the logger creates an associated ``LoggerMessageEntity`` entity for every ``NetworkTaskEntity``, but it will likely change in the future.
## Network Logging & Debugging
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Network
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Network
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Network
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -51,7 +51,9 @@ var isAutomaticNetworkLoggingEnabled: Bool {
}
func isConfiguringSessionSafe(delegate: URLSessionDelegate?) -> Bool {
if String(describing: delegate).contains("GTMSessionFetcher") {
guard let delegate else { return true }
let className = NSStringFromClass(type(of: delegate))
if className.contains("GTMSessionFetcher") {
return false
}
return true
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -0,0 +1,25 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#import "PulseObjCHelpers.h"
@implementation PulseObjCExceptionCatcher
+ (BOOL)performAndReturnError:(NSError **)error block:(void (NS_NOESCAPE ^)(void))block {
@try {
block();
return YES;
} @catch (NSException *exception) {
if (error) {
*error = [NSError errorWithDomain:@"com.github.kean.pulse"
code:-1
userInfo:@{
NSLocalizedDescriptionKey: exception.reason ?: @"Unknown Objective-C exception"
}];
}
return NO;
}
}
@end
@@ -0,0 +1,17 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface PulseObjCExceptionCatcher : NSObject
/// Executes the given block, catching any Objective-C exceptions and
/// converting them to NSError.
+ (BOOL)performAndReturnError:(NSError *_Nullable *_Nullable)error block:(void (NS_NOESCAPE ^)(void))block;
@end
NS_ASSUME_NONNULL_END
+1 -1
View File
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import CommonCrypto
@@ -16,7 +16,7 @@ extension Character {
}
}
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
extension AttributedString {
package init(_ string: String, _ configure: (inout AttributeContainer) -> Void) {
var attributes = AttributeContainer()
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
@@ -151,6 +151,8 @@ extension NetworkTaskEntity {
case .taskDescription: return "Task Description"
case .requestHeaderField(let name): return name
case .responseHeaderField(let name): return name
case .url: return "URL"
case .custom: return "Custom"
}
}
}
@@ -165,9 +167,9 @@ extension NetworkTaskEntity {
case .method:
httpMethod
case .requestSize:
byteCount(for: requestBodySize)
formattedRequestBodySize()
case .responseSize:
byteCount(for: responseBodySize)
formattedResponseBodySize()
case .responseContentType:
responseContentType.map(NetworkLogger.ContentType.init)?.lastComponent.uppercased()
case .duration:
@@ -184,6 +186,14 @@ extension NetworkTaskEntity {
(currentRequest?.headers ?? [:])[key]
case .responseHeaderField(let key):
(response?.headers ?? [:])[key]
case .url(let components):
if let components {
formattedURL(components: components)
} else {
url
}
case .custom(let value):
value
}
}
@@ -198,20 +208,37 @@ extension NetworkTaskEntity {
}
package func getFormattedContent(settings: ConsoleListDisplaySettings.ContentSettings) -> String? {
if let customText = settings.customText {
return customText
}
if settings.showTaskDescription, let taskDescription, !taskDescription.isEmpty {
return taskDescription
}
guard let url else {
guard url != nil else {
return nil
}
return NetworkTaskEntity.formattedURL(url, components: settings.components)
return formattedURL(components: settings.components)
}
package func formattedURL(components displayed: Set<ConsoleListDisplaySettings.URLComponent>) -> String? {
let key = AnyHashable(displayed)
if let cache = cachedFormattedURL, cache.key == key {
return cache.value
}
let value = NetworkTaskEntity.formattedURL(parsed: parsedURLComponents, displayed: displayed)
cachedFormattedURL = (key, value)
return value
}
package static func formattedURL(_ url: String, components displayed: Set<ConsoleListDisplaySettings.URLComponent>) -> String? {
formattedURL(parsed: URLComponents(string: url), displayed: displayed)
}
fileprivate static func formattedURL(parsed: URLComponents?, displayed: Set<ConsoleListDisplaySettings.URLComponent>) -> String? {
guard !displayed.isEmpty else {
return nil
}
guard var components = URLComponents(string: url) else {
guard var components = parsed else {
return nil
}
if displayed.count == 1 && displayed.first == .path {
@@ -236,6 +263,26 @@ extension NetworkTaskEntity {
}
return string
}
fileprivate func formattedRequestBodySize() -> String {
let size = requestBodySize
if let cache = cachedFormattedRequestBodySize, cache.size == size {
return cache.value
}
let value = byteCount(for: size)
cachedFormattedRequestBodySize = (size, value)
return value
}
fileprivate func formattedResponseBodySize() -> String {
let size = responseBodySize
if let cache = cachedFormattedResponseBodySize, cache.size == size {
return cache.value
}
let value = byteCount(for: size)
cachedFormattedResponseBodySize = (size, value)
return value
}
}
private func byteCount(for size: Int64) -> String {
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Combine
@@ -63,6 +63,62 @@ extension View {
}
}
// MARK: - BackportButtonRole
package enum BackportButtonRole {
case cancel
case close
case confirm
package var title: String {
switch self {
case .cancel: "Cancel"
case .close: "Close"
case .confirm: "Done"
}
}
}
@ViewBuilder
package func makeButton(role: BackportButtonRole, title: String? = nil, action: @escaping () -> Void) -> some View {
if #available(iOS 26, macOS 26, visionOS 26, tvOS 26, watchOS 26, *) {
if let title {
Button(title, role: ButtonRole(role), action: action)
} else {
Button(role: ButtonRole(role), action: action)
}
} else {
Button(title ?? role.title, action: action)
}
}
package struct ButtonClose: View {
@Environment(\.dismiss) private var dismiss
package let role: BackportButtonRole
package init(role: BackportButtonRole = .close) {
self.role = role
}
package var body: some View {
makeButton(role: role) {
dismiss()
}
}
}
@available(iOS 26, macOS 26, visionOS 26, tvOS 26, watchOS 26, *)
private extension ButtonRole {
init(_ role: BackportButtonRole) {
switch role {
case .cancel: self = .cancel
case .close: self = .close
case .confirm: self = .confirm
}
}
}
/// Allows you to use `@StateObject` only for memory management (without observing).
package final class IgnoringUpdates<T>: ObservableObject {
package var value: T
@@ -1,191 +0,0 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
import Foundation
import CoreData
import Pulse
import Combine
import SwiftUI
protocol ConsoleDataSourceDelegate: AnyObject {
/// The data source reloaded the entire dataset.
func dataSourceDidRefresh(_ dataSource: ConsoleDataSource)
/// An incremental update. If the diff is nil, it means the app is displaying
/// a grouped view that doesn't support diffing.
func dataSource(_ dataSource: ConsoleDataSource, didUpdateWith diff: CollectionDifference<NSManagedObjectID>?)
}
final class ConsoleDataSource: NSObject, NSFetchedResultsControllerDelegate {
weak var delegate: ConsoleDataSourceDelegate?
/// - warning: Incompatible with the "group by" option.
var sortDescriptors: [NSSortDescriptor] = [] {
didSet { controller.fetchRequest.sortDescriptors = sortDescriptors }
}
struct PredicateOptions {
var filters = ConsoleFilters()
var isOnlyErrors = false
var predicate: NSPredicate?
}
var predicate: PredicateOptions = .init() {
didSet { refreshPredicate() }
}
var filter: NSPredicate? {
didSet { refreshPredicate() }
}
static let fetchBatchSize = 100
private let store: LoggerStore
private let mode: ConsoleMode
private let options: ConsoleListOptions
private let controller: NSFetchedResultsController<NSManagedObject>
private var controllerDelegate: NSFetchedResultsControllerDelegate?
private var cancellables: [AnyCancellable] = []
init(store: LoggerStore, mode: ConsoleMode, options: ConsoleListOptions = .init()) {
self.store = store
self.mode = mode
self.options = options
let entityName: String
let sortKey: String
switch mode {
case .all, .logs:
entityName = "\(LoggerMessageEntity.self)"
sortKey = options.messageSortBy.key
case .network:
entityName = "\(NetworkTaskEntity.self)"
sortKey = options.taskSortBy.key
}
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
request.sortDescriptors = [
NSSortDescriptor(key: sortKey, ascending: options.order == .ascending)
].compactMap { $0 }
request.fetchBatchSize = ConsoleDataSource.fetchBatchSize
request.relationshipKeyPathsForPrefetching = ["request"]
controller = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: store.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
super.init()
let delegate = ConsoleFetchDelegate()
delegate.delegate = self
controllerDelegate = delegate
controller.delegate = controllerDelegate
}
func bind(_ filters: ConsoleFiltersViewModel) {
cancellables = []
filters.$options.sink { [weak self] in
self?.predicate = $0
}.store(in: &cancellables)
}
func refresh() {
try? controller.performFetch()
delegate?.dataSourceDidRefresh(self)
}
// MARK: Accessing Entities
var numberOfObjects: Int {
controller.fetchedObjects?.count ?? 0
}
func object(at indexPath: IndexPath) -> NSManagedObject {
controller.object(at: indexPath)
}
var entities: [NSManagedObject] {
controller.fetchedObjects ?? []
}
// MARK: NSFetchedResultsControllerDelegate
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
delegate?.dataSource(self, didUpdateWith: nil)
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith diff: CollectionDifference<NSManagedObjectID>) {
delegate?.dataSource(self, didUpdateWith: diff)
}
// MARK: Predicate
private func refreshPredicate() {
let predicate = ConsoleDataSource.makePredicate(mode: mode, options: predicate, filter: filter)
controller.fetchRequest.predicate = predicate
refresh()
}
static func makePredicate(mode: ConsoleMode, options: PredicateOptions, filter: NSPredicate? = nil) -> NSPredicate? {
let predicates = [
_makePredicate(mode, options.filters, options.isOnlyErrors),
options.predicate,
filter
].compactMap { $0 }
switch predicates.count {
case 0: return nil
case 1: return predicates[0]
default: return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
}
}
// MARK: - Predicates
private func _makePredicate(_ mode: ConsoleMode, _ filters: ConsoleFilters, _ isOnlyErrors: Bool) -> NSPredicate? {
func makeMessagesPredicate(isMessageOnly: Bool) -> NSPredicate? {
var predicates: [NSPredicate] = []
if isMessageOnly {
predicates.append(NSPredicate(format: "task == NULL"))
}
if let predicate = ConsoleFilters.makeMessagePredicates(criteria: filters, isOnlyErrors: isOnlyErrors) {
predicates.append(predicate)
}
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
switch mode {
case .all:
return makeMessagesPredicate(isMessageOnly: false)
case .logs:
return makeMessagesPredicate(isMessageOnly: true)
case .network:
return ConsoleFilters.makeNetworkPredicates(criteria: filters, isOnlyErrors: isOnlyErrors)
}
}
// MARK: - Delegates
// Using a separate class because the diff API is not supported for a fetch
// controller with sections, and it prints an error message in logs if the
// delegate implements it, which we want to avoid.
private final class ConsoleFetchDelegate: NSObject, NSFetchedResultsControllerDelegate {
weak var delegate: NSFetchedResultsControllerDelegate?
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith diff: CollectionDifference<NSManagedObjectID>) {
delegate?.controller?(controller, didChangeContentWith: diff)
}
}
package enum ConsoleUpdateEvent {
/// Full refresh of data.
case refresh
/// Incremental update.
case update(CollectionDifference<NSManagedObjectID>?)
}
@@ -0,0 +1,163 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
import SwiftUI
/// Allows customization of ``ConsoleView`` behavior on a per-task basis.
///
/// By default, the console uses the global ``UserSettings/shared`` to decide
/// how to render every network task. Provide a ``ConsoleDelegate`` to vary
/// the display options per task including injecting custom strings into
/// the header / footer via ``ConsoleListDisplaySettings/TaskField/custom(_:)``
/// or into the content via ``ConsoleListDisplaySettings/ContentSettings/customText``.
@MainActor
public protocol ConsoleDelegate: AnyObject {
/// Returns the display options to use when rendering the given network
/// task in the console list and inspector.
///
/// The default implementation returns ``UserSettings/shared``
/// `listDisplayOptions`.
func console(listDisplayOptionsFor task: NetworkTaskEntity) -> ConsoleListDisplaySettings
/// Returns additional context menu items to append to the task cell's
/// context menu, or `nil` to show only the built-in items.
///
/// The returned view is rendered inside the cell's context menu after the
/// built-in sections. Typical uses include app-specific actions like
/// "Replay", "Open in admin panel", or "Copy decoded payload".
///
/// ```swift
/// func console(contextMenuFor task: NetworkTaskEntity) -> AnyView? {
/// AnyView(
/// Section {
/// Button("Replay") { replay(task) }
/// }
/// )
/// }
/// ```
func console(contextMenuFor task: NetworkTaskEntity) -> AnyView?
/// Returns a SwiftUI view that replaces the cell's main content area
/// (the HTTP method + URL text) when rendering the given task, or `nil`
/// to use the default content rendering.
///
/// When non-nil, this supersedes ``ConsoleListDisplaySettings/ContentSettings``
/// entirely `showMethod`, `showTaskDescription`, `components`,
/// `customText`, `isMonospaced`, `lineLimit`, and `fontSize` are all
/// ignored for this task. The header and footer still render from
/// ``ConsoleListDisplaySettings`` as usual.
///
/// ```swift
/// func console(contentViewFor task: NetworkTaskEntity) -> AnyView? {
/// guard isGraphQL(task) else { return nil }
/// return AnyView(GraphQLOperationLabel(task: task))
/// }
/// ```
func console(contentViewFor task: NetworkTaskEntity) -> AnyView?
/// Returns a SwiftUI view that replaces the built-in response body
/// viewer for the given task, or `nil` to use the default rendering.
///
/// Use this hook to decode and display formats the built-in viewer
/// doesn't understand most notably protobuf, where the integrator
/// owns the schema / generated `SwiftProtobuf` types. The returned view
/// replaces the `FileViewer` entirely; wrap the decoded output in a
/// rich text viewer to reuse the standard search / line-number UI.
///
/// ```swift
/// func console(responseBodyViewFor task: NetworkTaskEntity) -> AnyView? {
/// guard task.response?.contentType?.isProtobuf == true,
/// let data = task.responseBody?.data else { return nil }
/// let decoded = try? MyProtobufMessage(serializedBytes: data)
/// return AnyView(ProtobufMessageView(message: decoded))
/// }
/// ```
func console(responseBodyViewFor task: NetworkTaskEntity) -> AnyView?
/// Returns a SwiftUI view to inject into the network inspector for the
/// given task, or `nil` to show only the built-in sections.
///
/// The returned view is rendered as an additional section after the
/// built-in response/metrics sections. Typical uses include decoded
/// GraphQL variables, parsed protobuf, or business-context metadata
/// that the built-in inspector doesn't know how to render.
///
/// ```swift
/// func console(inspectorViewFor task: NetworkTaskEntity) -> AnyView? {
/// guard isGraphQL(task) else { return nil }
/// return AnyView(
/// Section("GraphQL") {
/// GraphQLOperationView(task: task)
/// }
/// )
/// }
/// ```
func console(inspectorViewFor task: NetworkTaskEntity) -> AnyView?
/// Returns a redacted version of `value` for safe display, for example
/// to mask auth tokens or user identifiers before they are rendered in
/// the console list or the inspector header.
///
/// Called for URL, host, header, task-description, and caller-supplied
/// strings shown in the task cell. The default implementation returns
/// `value` unchanged.
///
/// ```swift
/// func console(redact value: String, field: ConsoleRedactionField, for task: NetworkTaskEntity) -> String {
/// switch field {
/// case .requestHeader("Authorization"): return "Bearer ***"
/// case .url: return value.replacingOccurrences(of: #/token=[^&]+/#, with: "token=***")
/// default: return value
/// }
/// }
/// ```
func console(redact value: String, field: ConsoleRedactionField, for task: NetworkTaskEntity) -> String
}
extension ConsoleDelegate {
public func console(listDisplayOptionsFor task: NetworkTaskEntity) -> ConsoleListDisplaySettings {
UserSettings.shared.listDisplayOptions
}
public func console(contextMenuFor task: NetworkTaskEntity) -> AnyView? {
nil
}
public func console(inspectorViewFor task: NetworkTaskEntity) -> AnyView? {
nil
}
public func console(responseBodyViewFor task: NetworkTaskEntity) -> AnyView? {
nil
}
public func console(contentViewFor task: NetworkTaskEntity) -> AnyView? {
nil
}
public func console(redact value: String, field: ConsoleRedactionField, for task: NetworkTaskEntity) -> String {
value
}
}
/// Identifies which string the console is about to render, passed to
/// ``ConsoleDelegate/console(redact:field:for:)`` so integrators can target
/// specific fields for redaction.
public enum ConsoleRedactionField: Sendable, Hashable {
/// The URL or a URL component displayed as cell content or in a field.
case url
/// The `host` component displayed in a field.
case host
/// A request header value (e.g., `Authorization`).
case requestHeader(String)
/// A response header value (e.g., `Set-Cookie`).
case responseHeader(String)
/// The `URLSessionTask.taskDescription` rendered as cell content or in a field.
case taskDescription
/// A caller-supplied custom string (``ConsoleListDisplaySettings/TaskField/custom(_:)``
/// or ``ConsoleListDisplaySettings/ContentSettings/customText``).
case custom
}
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import CoreData
import Pulse
@@ -11,23 +11,28 @@ import SwiftUI
///
/// - warning: It's marked with `ObservableObject` to make it possible to be used
/// with `@StateObject` and `@EnvironmentObject`, but it never changes.
final class ConsoleEnvironment: ObservableObject {
let title: String
let store: LoggerStore
let index: LoggerStoreIndex
package final class ConsoleEnvironment: ObservableObject {
package let title: String
package let store: LoggerStoreProtocol
package let index: LoggerStoreIndex
let filters: ConsoleFiltersViewModel
let logCountObserver: ManagedObjectsCountObserver
let taskCountObserver: ManagedObjectsCountObserver
package let filters: ConsoleFiltersViewModel
package let logCountObserver: ManagedObjectsCountObserver
package let taskCountObserver: ManagedObjectsCountObserver
let router = ConsoleRouter()
package let router = ConsoleRouter()
let initialMode: ConsoleMode
package let initialMode: ConsoleMode
@Published var mode: ConsoleMode
@Published var listOptions: ConsoleListOptions = .init()
/// A delegate that allows integrators to customize how individual tasks
/// are rendered. The console keeps a strong reference, so simple
/// configuration objects can be passed in without holding them elsewhere.
package var delegate: (any ConsoleDelegate)?
var bindingForNetworkMode: Binding<Bool> {
@Published package var mode: ConsoleMode
@Published package var listOptions: ConsoleListOptions = .init()
package var bindingForNetworkMode: Binding<Bool> {
Binding(get: {
self.mode == .network
}, set: {
@@ -37,8 +42,13 @@ final class ConsoleEnvironment: ObservableObject {
private var cancellables: [AnyCancellable] = []
init(store: LoggerStore, mode: ConsoleMode = .all) {
package init(
store: LoggerStoreProtocol,
mode: ConsoleMode = .all,
delegate: (any ConsoleDelegate)? = nil
) {
self.store = store
self.delegate = delegate
switch mode {
case .all: self.title = "Console"
case .logs: self.title = "Logs"
@@ -52,25 +62,29 @@ final class ConsoleEnvironment: ObservableObject {
case .network: self.mode = .network
}
func makeDefaultOptions() -> ConsoleDataSource.PredicateOptions {
var options = ConsoleDataSource.PredicateOptions()
options.filters.shared.sessions.selection = [store.session.id]
func makeDefaultOptions() -> ConsoleListPredicateOptions {
var options = ConsoleListPredicateOptions()
if let sessionID = store.currentSessionID {
options.sessions = [sessionID]
}
return options
}
self.index = LoggerStoreIndex(store: store)
if let store = store as? LoggerStore {
self.index = LoggerStoreIndex(store: store)
} else {
self.index = LoggerStoreIndex(context: store.backgroundContext)
}
self.filters = ConsoleFiltersViewModel(options: makeDefaultOptions())
self.logCountObserver = ManagedObjectsCountObserver(
entity: LoggerMessageEntity.self,
context: store.viewContext,
sortDescriptior: NSSortDescriptor(keyPath: \LoggerMessageEntity.createdAt, ascending: false)
context: store.viewContext
)
self.taskCountObserver = ManagedObjectsCountObserver(
entity: NetworkTaskEntity.self,
context: store.viewContext,
sortDescriptior: NSSortDescriptor(keyPath: \NetworkTaskEntity.createdAt, ascending: false)
context: store.viewContext
)
bind()
@@ -90,7 +104,7 @@ final class ConsoleEnvironment: ObservableObject {
}.store(in: &cancellables)
}
private func refreshCountObservers(_ options: ConsoleDataSource.PredicateOptions) {
private func refreshCountObservers(_ options: ConsoleListPredicateOptions) {
func makePredicate(for mode: ConsoleMode) -> NSPredicate? {
ConsoleDataSource.makePredicate(mode: mode, options: options)
}
@@ -98,7 +112,80 @@ final class ConsoleEnvironment: ObservableObject {
taskCountObserver.setPredicate(makePredicate(for: .network))
}
func removeAllLogs() {
/// Returns the display options to apply to the given network task,
/// consulting ``delegate`` and falling back to ``UserSettings/shared``.
@MainActor
package func listDisplayOptions(for task: NetworkTaskEntity) -> ConsoleListDisplaySettings {
delegate?.console(listDisplayOptionsFor: task) ?? UserSettings.shared.listDisplayOptions
}
/// Returns the text for a header/footer field, redacted through the
/// ``delegate`` when available. Numeric / enum fields (sizes, durations,
/// status codes, etc.) are passed through unchanged.
@MainActor
package func makeInfoText(for field: ConsoleListDisplaySettings.TaskField, task: NetworkTaskEntity) -> String? {
guard let value = task.makeInfoText(for: field) else { return nil }
return redact(value, field: field, task: task)
}
@MainActor
package func makeInfoItem(for field: ConsoleListDisplaySettings.TaskField, task: NetworkTaskEntity) -> NetworkTaskEntity.InfoItem? {
guard let value = makeInfoText(for: field, task: task) else { return nil }
return NetworkTaskEntity.InfoItem(field: field, value: value)
}
/// Returns the inspector's short title for a task, redacted through the
/// ``delegate`` when available.
@MainActor
package func shortTitle(for task: NetworkTaskEntity) -> String {
let options = listDisplayOptions(for: task)
let value = task.getShortTitle(options: options)
guard let delegate, !value.isEmpty else { return value }
let field: ConsoleRedactionField = (options.content.showTaskDescription &&
!(task.taskDescription ?? "").isEmpty) ? .taskDescription : .url
return delegate.console(redact: value, field: field, for: task)
}
/// Returns the main cell content string, redacted through the ``delegate``
/// when available.
@MainActor
package func formattedContent(for task: NetworkTaskEntity, settings: ConsoleListDisplaySettings.ContentSettings) -> String? {
guard let value = task.getFormattedContent(settings: settings) else { return nil }
guard let delegate else { return value }
let redactionField: ConsoleRedactionField
if settings.customText != nil {
redactionField = .custom
} else if settings.showTaskDescription,
let description = task.taskDescription, !description.isEmpty {
redactionField = .taskDescription
} else {
redactionField = .url
}
return delegate.console(redact: value, field: redactionField, for: task)
}
@MainActor
private func redact(_ value: String, field: ConsoleListDisplaySettings.TaskField, task: NetworkTaskEntity) -> String {
guard let delegate else { return value }
let redactionField: ConsoleRedactionField
switch field {
case .url: redactionField = .url
case .host: redactionField = .host
case .requestHeaderField(let name): redactionField = .requestHeader(name)
case .responseHeaderField(let name): redactionField = .responseHeader(name)
case .taskDescription: redactionField = .taskDescription
case .custom: redactionField = .custom
case .method, .requestSize, .responseSize, .responseContentType,
.duration, .statusCode, .taskType:
return value
}
return delegate.console(redact: value, field: redactionField, for: task)
}
package func removeAllLogs() {
guard !store.isReadonly else {
return
}
store.removeAll()
index.clear()
@@ -108,7 +195,7 @@ final class ConsoleEnvironment: ObservableObject {
}
}
public enum ConsoleMode: String {
public enum ConsoleMode: String, Sendable {
/// Displays both messages and network tasks with the ability
/// to switch between the two modes.
case all
@@ -119,12 +206,29 @@ public enum ConsoleMode: String {
package var hasLogs: Bool { self == .all || self == .logs }
package var hasNetwork: Bool { self == .all || self == .network }
package func formattedCount(_ count: Int) -> String {
let unit: String
switch self {
case .network: unit = count == 1 ? "Task" : "Tasks"
case .logs: unit = count == 1 ? "Log" : "Logs"
case .all: unit = count == 1 ? "Item" : "Items"
}
return "\(count) \(unit)"
}
package var entityName: String {
switch self {
case .all, .logs: return "\(LoggerMessageEntity.self)"
case .network: return "\(NetworkTaskEntity.self)"
}
}
}
// MARK: Environment
private struct LoggerStoreKey: EnvironmentKey {
static let defaultValue: LoggerStore = .shared
static let defaultValue: LoggerStoreProtocol = LoggerStore.shared
}
private struct ConsoleRouterKey: EnvironmentKey {
@@ -132,7 +236,7 @@ private struct ConsoleRouterKey: EnvironmentKey {
}
extension EnvironmentValues {
package var store: LoggerStore {
package var store: LoggerStoreProtocol {
get { self[LoggerStoreKey.self] }
set { self[LoggerStoreKey.self] = newValue }
}
@@ -144,15 +248,16 @@ extension EnvironmentValues {
}
extension View {
func injecting(_ environment: ConsoleEnvironment) -> some View {
self.background(ConsoleRouterView()) // important: order
.environmentObject(environment)
.environmentObject(environment.router)
.environmentObject(environment.index)
.environmentObject(environment.filters)
.environmentObject(UserSettings.shared)
.environment(\.router, environment.router)
.environment(\.store, environment.store)
.environment(\.managedObjectContext, environment.store.viewContext)
package func injecting(_ environment: ConsoleEnvironment) -> some View {
self.background(
ConsoleRouterView(router: environment.router)
)
// important: order
.environmentObject(environment)
.environmentObject(environment.filters)
.environmentObject(UserSettings.shared)
.environment(\.router, environment.router)
.environment(\.store, environment.store)
.environment(\.managedObjectContext, environment.store.viewContext)
}
}
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(visionOS)
@@ -8,7 +8,9 @@ import SwiftUI
import CoreData
import Pulse
import Combine
#if os(iOS)
import WatchConnectivity
#endif
public struct ConsoleView: View {
@StateObject private var environment: ConsoleEnvironment // Never reloads
@@ -20,29 +22,27 @@ public struct ConsoleView: View {
}
public var body: some View {
if #available(iOS 16, *) {
if #available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *) {
contents
} else {
PlaceholderView(imageName: "xmark.octagon", title: "Unsupported", subtitle: "Pulse requires iOS 16 or later").padding()
PlaceholderView(imageName: "xmark.octagon", title: "Unsupported", subtitle: "Pulse requires iOS 18 or later").padding()
}
}
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private var contents: some View {
ConsoleListView()
.navigationTitle(environment.title)
#if os(iOS) || os(visionOS)
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
if !isCloseButtonHidden && presentationMode.wrappedValue.isPresented {
Button("Close") {
makeButton(role: .close) {
presentationMode.wrappedValue.dismiss()
}
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
trailingNavigationBarItems
}
}
#endif
.injecting(environment)
}
@@ -52,31 +52,17 @@ public struct ConsoleView: View {
copy.isCloseButtonHidden = isHidden
return copy
}
@available(iOS 16, visionOS 1, *)
@ViewBuilder private var trailingNavigationBarItems: some View {
Button(action: { environment.router.isShowingShareStore = true }) {
Image(systemName: "square.and.arrow.up")
}
Button(action: { environment.router.isShowingFilters = true }) {
Image(systemName: "line.horizontal.3.decrease.circle")
}
ConsoleContextMenu()
}
}
#if DEBUG
struct ConsoleView_Previews: PreviewProvider {
static var previews: some View {
Group {
NavigationView {
ConsoleView(environment: .init(store: .mock))
}.previewDisplayName("Console")
NavigationView {
ConsoleView(store: .mock, mode: .network)
}.previewDisplayName("Network")
}
@available(iOS 18, macOS 15, visionOS 1, *)
#Preview("Console") {
NavigationStack {
ConsoleView(store: LoggerStore.mock, delegate: MockConsoleDelegate.shared)
}
#if os(macOS)
.frame(width: 500, height: 600)
#endif
}
#endif
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(tvOS)
@@ -19,6 +19,15 @@ public struct ConsoleView: View {
}
public var body: some View {
if #available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *) {
contents
} else {
PlaceholderView(imageName: "xmark.octagon", title: "Unsupported", subtitle: "Pulse requires iOS 18 or later").padding()
}
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private var contents: some View {
GeometryReader { _ in
HStack {
List {
@@ -37,16 +46,18 @@ public struct ConsoleView: View {
.onAppear { listViewModel.isViewVisible = true }
.onDisappear { listViewModel.isViewVisible = false }
}
.disableScrollClip()
.scrollClipDisabled()
.injecting(environment)
.environmentObject(listViewModel)
}
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private struct ConsoleMenuView: View {
@EnvironmentObject private var viewModel: ConsoleFiltersViewModel
@EnvironmentObject private var environment: ConsoleEnvironment
@Environment(\.store) private var store
@Environment(\.router) private var router
var body: some View {
Section {
@@ -60,7 +71,12 @@ private struct ConsoleMenuView: View {
Label(environment.bindingForNetworkMode.wrappedValue ? "Network Filters" : "Message Filters", systemImage: "line.3.horizontal.decrease.circle")
}
} header: { Text("Quick Filters") }
if !(store.options.contains(.readonly)) {
Section {
Button(action: { router.isShowingSessions = true }) {
Label("Sessions", systemImage: "list.clipboard")
}
} header: { Text("Sessions") }
if !store.isReadonly {
Section {
if #available(iOS 16, tvOS 16, *) {
NavigationLink {
@@ -84,8 +100,11 @@ private struct ConsoleMenuView: View {
} header: { Text("Settings") }
}
@ViewBuilder
private var destinationSettings: some View {
SettingsView(store: store).padding()
if let store = store as? LoggerStore {
SettingsView(store: store).padding()
}
}
private var destinationFilters: some View {
@@ -105,11 +124,10 @@ extension View {
}
#if DEBUG
struct ConsoleView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ConsoleView(store: .mock)
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview {
NavigationView {
ConsoleView(store: .mock)
}
}
#endif
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(watchOS)
@@ -9,25 +9,39 @@ import Pulse
public struct ConsoleView: View {
@StateObject private var environment: ConsoleEnvironment
@StateObject private var listViewModel: IgnoringUpdates<ConsoleListViewModel>
@StateObject private var listViewModel: ConsoleListViewModel
init(environment: ConsoleEnvironment) {
_environment = StateObject(wrappedValue: environment)
let listViewModel = ConsoleListViewModel(environment: environment, filters: environment.filters)
_listViewModel = StateObject(wrappedValue: .init(listViewModel))
_listViewModel = StateObject(wrappedValue: listViewModel)
}
public var body: some View {
if #available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *) {
contents
} else {
PlaceholderView(imageName: "xmark.octagon", title: "Unsupported", subtitle: "Pulse requires iOS 18 or later").padding()
}
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private var contents: some View {
List {
ConsoleToolbarView(environment: environment)
ConsoleListContentView()
.environmentObject(listViewModel.value)
.environmentObject(listViewModel)
}
.navigationTitle(environment.title)
.onAppear { listViewModel.value.isViewVisible = true }
.onDisappear { listViewModel.value.isViewVisible = false }
.navigationTitle(environment.mode.formattedCount(listViewModel.entities.count))
.onAppear { listViewModel.isViewVisible = true }
.onDisappear { listViewModel.isViewVisible = false }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
ToolbarItem(placement: .automatic) {
Button(action: { environment.router.isShowingSessions = true }) {
Label("Sessions", systemImage: "list.clipboard")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button(action: { environment.router.isShowingSettings = true }) {
Image(systemName: "gearshape").font(.title3)
}
@@ -65,7 +79,8 @@ private struct ConsoleToolbarView: View {
}
.background(viewModel.isDefaultFilters(for: environment.mode) ? nil : Rectangle().foregroundColor(.blue).cornerRadius(8))
}
.font(.title3)
.imageScale(.large)
.font(.footnote)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowBackground(Color.clear)
.buttonStyle(.bordered)
@@ -74,13 +89,12 @@ private struct ConsoleToolbarView: View {
}
#if DEBUG
struct ConsoleView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ConsoleView(store: .mock)
}
.navigationViewStyle(.stack)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview {
NavigationView {
ConsoleView(store: .mock)
}
.navigationViewStyle(.stack)
}
#endif
@@ -1,29 +1,32 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
#if !os(macOS)
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import CoreData
import Pulse
import Combine
#if !os(macOS)
extension ConsoleView {
/// Initializes the console view.
///
///
/// - parameters:
/// - store: The store to display. By default, `LoggerStore/shared`.
/// - mode: The initial console mode. By default, ``ConsoleMode/all``. If you change
/// the mode to ``ConsoleMode/network``, the console will display the
/// network messages up on appearance.
/// - delegate: The delegate that allows you to customize multiple aspects
/// of the console view.
/// - delegate: An optional ``ConsoleDelegate`` that can customize how
/// individual tasks are rendered e.g., vary
/// ``ConsoleListDisplaySettings`` per task. By default, the console
/// uses ``UserSettings/shared``.
public init(
store: LoggerStore = .shared,
mode: ConsoleMode = .all
mode: ConsoleMode = .all,
delegate: (any ConsoleDelegate)? = nil
) {
self.init(environment: .init(store: store, mode: mode))
self.init(environment: .init(store: store, mode: mode, delegate: delegate))
}
}
@@ -0,0 +1,47 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
/// Persists section names the user has explicitly collapsed per
/// `(mode, groupBy)` in a single JSON file shared across stores.
final class CollapsedSectionsCache {
static let shared = CollapsedSectionsCache()
private let fileURL: URL
private var cache: [String: Set<String>]
init(fileURL: URL = URL.temp.appending(filename: "pulse-collapsed-sections.json")) {
self.fileURL = fileURL
self.cache = Self.load(from: fileURL)
}
func sections(forKey key: String) -> Set<String> {
cache[key] ?? []
}
func setSections(_ sections: Set<String>, forKey key: String) {
if sections.isEmpty {
guard cache.removeValue(forKey: key) != nil else { return }
} else {
guard cache[key] != sections else { return }
cache[key] = sections
}
save()
}
private static func load(from url: URL) -> [String: Set<String>] {
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([String: Set<String>].self, from: data) else {
return [:]
}
return decoded
}
private func save() {
guard let data = try? JSONEncoder().encode(cache) else { return }
try? data.write(to: fileURL, options: .atomic)
}
}
@@ -0,0 +1,280 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import CoreData
import Pulse
import Combine
import SwiftUI
package protocol ConsoleDataSourceDelegate: AnyObject {
/// The data source reloaded the entire dataset.
func dataSourceDidRefresh(_ dataSource: ConsoleDataSource)
/// An incremental update. If the diff is nil, it means the app is displaying
/// a grouped view that doesn't support diffing.
func dataSource(_ dataSource: ConsoleDataSource, didUpdateWith diff: CollectionDifference<NSManagedObjectID>?)
}
package final class ConsoleDataSource: NSObject, NSFetchedResultsControllerDelegate {
package weak var delegate: ConsoleDataSourceDelegate?
/// - warning: Incompatible with the "group by" option.
package var sortDescriptors: [NSSortDescriptor] = [] {
didSet { controller.fetchRequest.sortDescriptors = sortDescriptors }
}
package var predicate: ConsoleListPredicateOptions = .init() {
didSet { refreshPredicate() }
}
package var filter: NSPredicate? {
didSet { refreshPredicate() }
}
package static let fetchBatchSize = 100
package let store: LoggerStoreProtocol
package let mode: ConsoleMode
private let options: ConsoleListOptions
private let controller: NSFetchedResultsController<NSManagedObject>
private var controllerDelegate: NSFetchedResultsControllerDelegate?
private var cancellables: [AnyCancellable] = []
package init(store: LoggerStoreProtocol, mode: ConsoleMode, options: ConsoleListOptions = .init()) {
self.store = store
self.mode = mode
self.options = options
let sortKey: String
let grouping: ConsoleListGroupBy
switch mode {
case .all, .logs:
sortKey = options.messageSortBy.key
grouping = options.messageGroupBy
case .network:
sortKey = options.taskSortBy.key
grouping = options.taskGroupBy
}
let entityName = mode.entityName
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
request.sortDescriptors = [
grouping.key.flatMap {
guard $0 != "session" else { return nil }
return NSSortDescriptor(key: $0, ascending: grouping.isAscending)
},
NSSortDescriptor(key: sortKey, ascending: options.order == .ascending)
].compactMap { $0 }
request.fetchBatchSize = ConsoleDataSource.fetchBatchSize
if mode != .network {
request.relationshipKeyPathsForPrefetching = ["request"]
}
controller = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: store.viewContext,
sectionNameKeyPath: grouping.key,
cacheName: nil
)
super.init()
controllerDelegate = {
if grouping.key == nil {
let delegate = ConsoleFetchDelegate()
delegate.delegate = self
return delegate
} else {
let delegate = ConsoleGroupedFetchDelegate()
delegate.delegate = self
return delegate
}
}()
controller.delegate = controllerDelegate
}
package func bind(_ filters: ConsoleFiltersViewModel) {
cancellables = []
filters.$options.sink { [weak self] in
self?.predicate = $0
}.store(in: &cancellables)
}
package func refresh() {
try? controller.performFetch()
delegate?.dataSourceDidRefresh(self)
}
// MARK: Accessing Entities
package var numberOfObjects: Int {
controller.fetchedObjects?.count ?? 0
}
package func object(at indexPath: IndexPath) -> NSManagedObject {
controller.object(at: indexPath)
}
package var entities: [NSManagedObject] {
controller.fetchedObjects ?? []
}
package var sections: [NSFetchedResultsSectionInfo]? {
controller.sectionNameKeyPath == nil ? nil : controller.sections
}
#if os(iOS) || os(macOS) || os(visionOS)
// MARK: Search
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
package func makeSearchSession(
parameters: ConsoleSearchParameters,
extendedPredicate: NSPredicate? = nil,
extendedFetchLimit: Int = 1000
) -> ConsoleSearchSession {
ConsoleSearchSession(
store: store,
mode: mode,
primaryPredicate: controller.fetchRequest.predicate,
extendedPredicate: extendedPredicate,
extendedFetchLimit: extendedFetchLimit,
sortDescriptors: controller.fetchRequest.sortDescriptors ?? [],
parameters: parameters
)
}
#endif
// MARK: NSFetchedResultsControllerDelegate
package func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
delegate?.dataSource(self, didUpdateWith: nil)
}
package func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith diff: CollectionDifference<NSManagedObjectID>) {
delegate?.dataSource(self, didUpdateWith: diff)
}
// MARK: Predicate
private func refreshPredicate() {
let predicate = ConsoleDataSource.makePredicate(mode: mode, options: predicate, filter: filter)
controller.fetchRequest.predicate = predicate
refresh()
}
package static func makePredicate(mode: ConsoleMode, options: ConsoleListPredicateOptions, filter: NSPredicate? = nil) -> NSPredicate? {
let predicates = [
_makePredicate(mode, options),
options.predicate,
options.focus,
filter
].compactMap { $0 }
switch predicates.count {
case 0: return nil
case 1: return predicates[0]
default: return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
}
package func name(for section: NSFetchedResultsSectionInfo) -> String {
makeName(for: section, mode: mode, options: options)
}
}
// MARK: - Predicates
private func _makePredicate(_ mode: ConsoleMode, _ options: ConsoleListPredicateOptions) -> NSPredicate? {
let filters = options.filters
let sessions = options.sessions
let isOnlyErrors = options.isOnlyErrors
func makeMessagesPredicate(isMessageOnly: Bool) -> NSPredicate? {
var predicates: [NSPredicate] = []
if isMessageOnly {
predicates.append(NSPredicate(format: "task == NULL"))
}
if let predicate = ConsoleFilters.makeMessagePredicates(criteria: filters, sessions: sessions, isOnlyErrors: isOnlyErrors) {
predicates.append(predicate)
}
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
switch mode {
case .all:
return makeMessagesPredicate(isMessageOnly: false)
case .logs:
return makeMessagesPredicate(isMessageOnly: true)
case .network:
return ConsoleFilters.makeNetworkPredicates(criteria: filters, sessions: sessions, isOnlyErrors: isOnlyErrors)
}
}
// MARK: - Section Names
private func makeName(for section: NSFetchedResultsSectionInfo, mode: ConsoleMode, options: ConsoleListOptions) -> String {
switch mode {
case .all, .logs:
switch options.messageGroupBy {
case .level:
let rawValue = Int16(Int(section.name) ?? 0)
return (LoggerStore.Level(rawValue: rawValue) ?? .debug).name.capitalized
case .session:
let date = (section.objects?.last as? LoggerMessageEntity)?.createdAt
return date.map(sessionDateFormatter.string) ?? ""
default:
break
}
case .network:
switch options.taskGroupBy {
case .taskType:
let rawValue = Int16(Int(section.name) ?? 0)
return NetworkLogger.TaskType(rawValue: rawValue)?.urlSessionTaskClassName ?? section.name
case .statusCode:
let rawValue = Int32(section.name) ?? 0
return StatusCodeFormatter.string(for: rawValue)
case .requestState:
let rawValue = Int16(Int(section.name) ?? 0)
guard let state = NetworkTaskEntity.State(rawValue: rawValue) else {
return "Unknown State"
}
switch state {
case .pending: return "Pending"
case .success: return "Success"
case .failure: return "Failure"
}
case .session:
let date = (section.objects?.last as? NetworkTaskEntity)?.createdAt
return date.map(sessionDateFormatter.string) ?? ""
default:
break
}
}
let name = section.name
return name.isEmpty ? "" : name
}
private let sessionDateFormatter = DateFormatter(dateStyle: .medium, timeStyle: .medium, isRelative: true)
// MARK: - Delegates
// Using a separate class because the diff API is not supported for a fetch
// controller with sections, and it prints an error message in logs if the
// delegate implements it, which we want to avoid.
private final class ConsoleFetchDelegate: NSObject, NSFetchedResultsControllerDelegate {
weak var delegate: NSFetchedResultsControllerDelegate?
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith diff: CollectionDifference<NSManagedObjectID>) {
delegate?.controller?(controller, didChangeContentWith: diff)
}
}
private final class ConsoleGroupedFetchDelegate: NSObject, NSFetchedResultsControllerDelegate {
weak var delegate: NSFetchedResultsControllerDelegate?
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
delegate?.controllerDidChangeContent?(controller)
}
}
@@ -1,20 +1,56 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) || os(watchOS)
import CoreData
import Pulse
import Combine
import SwiftUI
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleListContentView: View {
var hidesPreviousSessionButton: Bool = false
@EnvironmentObject var viewModel: ConsoleListViewModel
var body: some View {
#if os(iOS) || os(visionOS)
if let sections = viewModel.sections, !sections.isEmpty {
ForEach(sections, id: \.name) { section in
let isCollapsed = viewModel.collapsedSections.contains(section.name)
Section {
if !isCollapsed {
ForEach((section.objects as? [NSManagedObject]) ?? [], id: \.objectID) { entity in
ConsoleEntityCell(entity: entity)
.id(entity.objectID)
.listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16))
}
}
} header: {
Button(action: { withAnimation { viewModel.toggleSection(section.name) } }) {
HStack {
Text(viewModel.name(for: section))
Text("\(section.numberOfObjects)")
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "chevron.down")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isCollapsed ? -90 : 0))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
} else {
plainView
}
#else
plainView
#endif
}
@ViewBuilder
@@ -23,7 +59,9 @@ struct ConsoleListContentView: View {
Text("Empty")
.font(.subheadline)
.foregroundColor(.secondary)
#if !os(watchOS)
.listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16))
#endif
} else {
ForEach(viewModel.visibleEntities, id: \.objectID) { entity in
let objectID = entity.objectID
@@ -39,31 +77,37 @@ struct ConsoleListContentView: View {
}
}
footerView
.listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16))
}
@ViewBuilder
private var footerView: some View {
if let session = viewModel.previousSession {
Button(action: { viewModel.buttonShowPreviousSessionTapped(for: session) }) {
Text("Show Previous Session")
.font(.subheadline)
.foregroundColor(.accentColor)
Spacer()
Text(session.formattedDate(isCompact: false))
.font(.subheadline)
.foregroundColor(.secondary)
#if os(iOS) && os(macOS) || os(visionOS)
if !hidesPreviousSessionButton, let session = viewModel.previousSession {
Button(action: {
withAnimation {
viewModel.buttonShowPreviousSessionTapped(for: session)
}
}) {
HStack(spacing: 10) {
Spacer()
VStack(alignment: .leading, spacing: 2) {
Text("Show Previous Session")
.font(.subheadline)
.foregroundColor(.accentColor)
}
Spacer()
}
}
.buttonStyle(.plain)
#if os(iOS) || os(visionOS)
.frame(height: 24)
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
.listRowSeparator(.hidden, edges: .bottom)
#endif
}
#endif
}
}
#if os(iOS) || os(visionOS)
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
package struct ConsoleStaticList: View {
package let entities: [NSManagedObject]
@@ -1,39 +1,50 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
struct ConsoleListOptions: Equatable {
var messageSortBy: MessageSortBy = .dateCreated
var taskSortBy: TaskSortBy = .dateCreated
var order: Ordering = .descending
package struct ConsoleListOptions: Equatable {
package var messageSortBy: MessageSortBy = .dateCreated
package var taskSortBy: TaskSortBy = .dateCreated
#if os(macOS)
package var order: Ordering = .ascending
#else
package var order: Ordering = .descending
#endif
enum Ordering: String, CaseIterable {
package var messageGroupBy: MessageGroupBy = .noGrouping
package var taskGroupBy: TaskGroupBy = .noGrouping
package init() {}
package enum Ordering: String, CaseIterable {
case descending = "Descending"
case ascending = "Ascending"
}
enum MessageSortBy: String, CaseIterable {
package enum MessageSortBy: String, CaseIterable {
case dateCreated = "Date"
case level = "Level"
var key: String {
package var key: String {
switch self {
case .dateCreated: return "createdAt"
case .level: return "level"
}
}
package var pillTitle: String { rawValue }
}
enum TaskSortBy: String, CaseIterable {
package enum TaskSortBy: String, CaseIterable {
case dateCreated = "Date"
case duration = "Duration"
case requestSize = "Request Size"
case responseSize = "Response Size"
var key: String {
package var key: String {
switch self {
case .dateCreated: return "createdAt"
case .duration: return "duration"
@@ -41,5 +52,76 @@ struct ConsoleListOptions: Equatable {
case .responseSize: return "responseBodySize"
}
}
package var pillTitle: String {
switch self {
case .dateCreated: return "Date"
case .duration: return "Duration"
case .requestSize: return "Size"
case .responseSize: return "Size"
}
}
}
package enum MessageGroupBy: String, CaseIterable, ConsoleListGroupBy {
case noGrouping = "No Grouping"
case label = "Label"
case level = "Level"
case file = "File"
case session = "Session"
package var key: String? {
switch self {
case .noGrouping: return nil
case .label: return "label"
case .level: return "level"
case .file: return "file"
case .session: return "session"
}
}
package var isAscending: Bool {
switch self {
case .noGrouping, .label, .file: return true
case .level, .session: return false
}
}
}
package enum TaskGroupBy: String, CaseIterable, ConsoleListGroupBy {
case noGrouping = "No Grouping"
case url = "URL"
case host = "Host"
case method = "Method"
case taskType = "Task Type"
case statusCode = "Status Code"
case errorCode = "Error Code"
case requestState = "State"
case responseContentType = "Content Type"
case session = "Session"
package var key: String? {
switch self {
case .noGrouping: return nil
case .url: return "url"
case .host: return "host"
case .method: return "httpMethod"
case .taskType: return "taskType"
case .statusCode: return "statusCode"
case .errorCode: return "errorCode"
case .requestState: return "requestState"
case .responseContentType: return "responseContentType"
case .session: return "session"
}
}
package var isAscending: Bool {
self != .errorCode && self != .session
}
}
}
package protocol ConsoleListGroupBy {
var key: String? { get }
var isAscending: Bool { get }
}
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(visionOS)
@@ -9,72 +9,166 @@ import CoreData
import Pulse
import Combine
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleListView: View {
var initialSearchText: String?
@EnvironmentObject var environment: ConsoleEnvironment
@EnvironmentObject var filters: ConsoleFiltersViewModel
var body: some View {
_InternalConsoleListView(environment: environment, filters: filters)
_ConsoleListView(environment: environment, filters: filters, initialSearchText: initialSearchText)
}
}
@available(iOS 16, visionOS 1, *)
private struct _InternalConsoleListView: View {
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private struct _ConsoleListView: View {
private let environment: ConsoleEnvironment
@StateObject private var listViewModel: IgnoringUpdates<ConsoleListViewModel>
@StateObject private var listViewModel: ConsoleListViewModel
@StateObject private var searchBarViewModel: ConsoleSearchBarViewModel
@StateObject private var searchViewModel: IgnoringUpdates<ConsoleSearchViewModel>
@StateObject private var searchViewModel: ConsoleSearchViewModel
init(environment: ConsoleEnvironment, filters: ConsoleFiltersViewModel) {
@Environment(\.store) private var store
@Environment(\.router) private var router
@State private var editMode: EditMode = .inactive
@State private var selection = Set<NSManagedObjectID>()
@State private var shareItems: ShareItems?
init(environment: ConsoleEnvironment, filters: ConsoleFiltersViewModel, initialSearchText: String? = nil) {
self.environment = environment
let listViewModel = ConsoleListViewModel(environment: environment, filters: filters)
let searchBarViewModel = ConsoleSearchBarViewModel()
let searchViewModel = ConsoleSearchViewModel(environment: environment, source: listViewModel, searchBar: searchBarViewModel)
if let initialSearchText {
searchBarViewModel.text = initialSearchText
}
let searchViewModel = ConsoleSearchViewModel(environment: environment, searchBar: searchBarViewModel)
searchViewModel.isSearching = initialSearchText != nil
_listViewModel = StateObject(wrappedValue: IgnoringUpdates(listViewModel))
_listViewModel = StateObject(wrappedValue: listViewModel)
_searchBarViewModel = StateObject(wrappedValue: searchBarViewModel)
_searchViewModel = StateObject(wrappedValue: IgnoringUpdates(searchViewModel))
_searchViewModel = StateObject(wrappedValue: searchViewModel)
}
var body: some View {
contents
.environmentObject(listViewModel.value)
.environmentObject(searchViewModel.value)
list
.environmentObject(listViewModel)
.environmentObject(searchViewModel)
.environmentObject(searchBarViewModel)
.onAppear { listViewModel.value.isViewVisible = true }
.onDisappear { listViewModel.value.isViewVisible = false }
.onAppear { listViewModel.isViewVisible = true }
.onDisappear { listViewModel.isViewVisible = false }
}
@ViewBuilder private var contents: some View {
_ConsoleListView()
.environment(\.defaultMinListRowHeight, 8)
.searchable(text: $searchBarViewModel.text)
.textInputAutocapitalization(.never)
.onSubmit(of: .search, searchViewModel.value.onSubmitSearch)
.disableAutocorrection(true)
}
}
@available(iOS 16, visionOS 1, *)
private struct _ConsoleListView: View {
@Environment(\.isSearching) private var isSearching
@Environment(\.store) private var store
var body: some View {
List {
if isSearching {
private var list: some View {
List(selection: editMode.isEditing ? $selection : nil) {
ConsoleToolbarView()
.listRowSeparator(.hidden, edges: .all)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 9, trailing: 16))
if searchViewModel.isSearching {
ConsoleSearchListContentView()
} else {
ConsoleToolbarView()
.listRowSeparator(.hidden, edges: .all)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 8, trailing: 16))
ConsoleListContentView()
}
}
.listStyle(.plain)
.listSectionSpacing(0)
.navigationBarTitleDisplayMode(.inline)
.environment(\.defaultMinListRowHeight, 8)
.environment(\.editMode, $editMode)
.animation(.default, value: editMode)
.animation(.default, value: searchViewModel.isSearching)
.searchable(text: $searchBarViewModel.text, isPresented: $searchViewModel.isSearching)
.searchPresentationToolbarBehavior(.avoidHidingContent)
.textInputAutocapitalization(.never)
.onSubmit(of: .search, searchViewModel.onSubmitSearch)
.disableAutocorrection(true)
.toolbar { toolbar }
.onChange(of: editMode) {
if !$0.isEditing {
selection.removeAll()
}
}
.sheet(item: $searchViewModel.editingFilterState) { state in
ConsoleCustomFilterEditSheet(filter: state.filter, fieldGroups: state.filter.availableFieldGroups) {
searchViewModel.applyEditedFilter($0)
}
}
.sheet(item: $shareItems, content: ShareView.init)
}
// MARK: Toolbar
@ToolbarContentBuilder private var toolbar: some ToolbarContent {
ToolbarItem(placement: .principal) {
if editMode.isEditing {
Text(selectionTitle)
.font(.headline)
} else {
ConsoleNavigationTitleView()
}
}
if editMode.isEditing {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
editMode = .inactive
}
}
ToolbarItem(placement: .bottomBar) {
Spacer()
}
ToolbarItem(placement: .bottomBar) {
shareMenu
}
} else {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(action: { router.isShowingShareStore = true }) {
Image(systemName: "square.and.arrow.up")
}
ConsoleContextMenu(editMode: $editMode)
}
}
}
// MARK: Selection
private var selectionTitle: String {
switch selection.count {
case 0: return "Select Items"
case 1: return "1 Selected"
default: return "\(selection.count) Selected"
}
}
private var shareMenu: some View {
Menu {
Button(action: { shareSelectedEntities(as: .pdf) }) {
Label("PDF", systemImage: "square.and.arrow.up")
}
Button(action: { shareSelectedEntities(as: .html) }) {
Label("HTML", systemImage: "square.and.arrow.up")
}
Button(action: { shareSelectedEntities(as: .plainText) }) {
Label("Text", systemImage: "square.and.arrow.up")
}
} label: {
Label("Share...", systemImage: "square.and.arrow.up")
}
.disabled(selection.isEmpty)
}
private func shareSelectedEntities(as output: ShareOutput) {
let entities = selection.compactMap { objectID in
listViewModel.entities.first(where: { $0.objectID == objectID })
}
guard !entities.isEmpty, let store = store as? LoggerStore else { return }
Task {
if let items = try? await ShareService.share(entities, store: store, as: output) {
shareItems = items
}
}
}
}
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import CoreData
@@ -8,7 +8,7 @@ import Pulse
import Combine
import SwiftUI
final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, ConsoleEntitiesSource {
final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject {
#if os(iOS) || os(visionOS)
@Published private(set) var visibleEntities: ArraySlice<NSManagedObject> = []
#else
@@ -17,8 +17,15 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
@Published private(set) var entities: [NSManagedObject] = []
@Published private(set) var sections: [NSFetchedResultsSectionInfo]?
/// Names of grouped sections the user has collapsed. Restored per
/// `(mode, groupBy)` from on-disk cache when the data source is rebuilt.
@Published var collapsedSections: Set<String> = []
@Published private(set) var mode: ConsoleMode
private var currentGroupByKey: String?
private let collapsedSectionsCache = CollapsedSectionsCache.shared
var isViewVisible = false {
didSet {
guard oldValue != isViewVisible else { return }
@@ -31,6 +38,7 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
}
@Published private(set) var previousSession: LoggerSessionEntity?
@Published private(set) var allSessionsCount: Int = 0
let events = PassthroughSubject<ConsoleUpdateEvent, Never>()
@@ -41,11 +49,11 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
private var visibleObjectIDs: Set<NSManagedObjectID> = []
#endif
private let store: LoggerStore
private let store: LoggerStoreProtocol
private let environment: ConsoleEnvironment
private let filters: ConsoleFiltersViewModel
private let sessions: ManagedObjectsObserver<LoggerSessionEntity>
private var dataSource: ConsoleDataSource?
@Published package private(set) var dataSource: ConsoleDataSource?
private var cancellables: [AnyCancellable] = []
private var filtersCancellable: AnyCancellable?
@@ -55,12 +63,14 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
self.mode = environment.mode
self.filters = filters
self.sessions = .sessions(for: store.viewContext)
self.allSessionsCount = self.sessions.objects.count
$entities.sink { [weak self] in
self?.filters.entities.send($0)
}.store(in: &cancellables)
sessions.$objects.dropFirst().sink { [weak self] in
self?.allSessionsCount = $0.count
self?.refreshPreviousSessionButton(sessions: $0)
}.store(in: &cancellables)
@@ -83,18 +93,55 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
private func resetDataSource(options: ConsoleListOptions) {
dataSource = ConsoleDataSource(store: store, mode: mode, options: options)
dataSource?.delegate = self
let groupByKey = Self.groupByKey(mode: mode, options: options)
currentGroupByKey = groupByKey
collapsedSections = collapsedSectionsCache.sections(forKey: groupByKey)
filtersCancellable = filters.$options.sink { [weak self] in
self?.dataSource?.predicate = $0
}
}
// MARK: Collapsible Sections
func toggleSection(_ name: String) {
if collapsedSections.contains(name) {
collapsedSections.remove(name)
} else {
collapsedSections.insert(name)
}
if let key = currentGroupByKey {
collapsedSectionsCache.setSections(collapsedSections, forKey: key)
}
}
func collapseAllSections() {
collapsedSections = Set((sections ?? []).map(\.name))
}
func expandAllSections() {
collapsedSections.removeAll()
}
private static func groupByKey(mode: ConsoleMode, options: ConsoleListOptions) -> String {
let groupBy: String
switch mode {
case .all, .logs: groupBy = options.messageGroupBy.rawValue
case .network: groupBy = options.taskGroupBy.rawValue
}
return "\(mode.rawValue):\(groupBy)"
}
func buttonShowPreviousSessionTapped(for session: LoggerSessionEntity) {
filters.criteria.shared.sessions.selection.insert(session.id)
filters.sessions.insert(session.id)
refreshPreviousSessionButton(sessions: self.sessions.objects)
}
private func refreshPreviousSessionButton(sessions: [LoggerSessionEntity]) {
let selection = filters.criteria.shared.sessions.selection
let selection = filters.sessions
guard !selection.isEmpty else {
previousSession = nil
return
}
let isDisplayingPrefix = sessions.prefix(selection.count).allSatisfy {
selection.contains($0.id)
}
@@ -112,6 +159,8 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
guard isViewVisible else { return }
entities = dataSource.entities
sections = dataSource.sections
refreshPreviousSessionButton(sessions: sessions.objects)
#if os(iOS) || os(visionOS)
refreshVisibleEntities()
#endif
@@ -120,6 +169,7 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
func dataSource(_ dataSource: ConsoleDataSource, didUpdateWith diff: CollectionDifference<NSManagedObjectID>?) {
entities = dataSource.entities
sections = dataSource.sections
#if os(iOS) || os(visionOS)
if scrollPosition == .nearTop {
refreshVisibleEntities()
@@ -128,6 +178,10 @@ final class ConsoleListViewModel: ConsoleDataSourceDelegate, ObservableObject, C
events.send(.update(diff))
}
func name(for section: NSFetchedResultsSectionInfo) -> String {
dataSource?.name(for: section) ?? ""
}
// MARK: Visible Entities
#if os(iOS) || os(visionOS)
@@ -0,0 +1,15 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
package struct ConsoleListPredicateOptions: @unchecked Sendable {
package var filters = ConsoleFilters()
package var sessions: Set<UUID> = []
package var isOnlyErrors = false
package var predicate: NSPredicate?
package var focus: NSPredicate?
package init() {}
}
@@ -1,28 +1,52 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(visionOS)
#if os(iOS) || os(macOS) || os(visionOS)
import SwiftUI
import CoreData
import Pulse
import Combine
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleContextMenu: View {
#if os(iOS) || os(visionOS)
@Binding var editMode: EditMode
#endif
@EnvironmentObject private var environment: ConsoleEnvironment
@Environment(\.router) private var router
var body: some View {
Menu {
#if os(iOS) || os(visionOS)
Section {
Button(action: { editMode = .active }) {
Label("Select", systemImage: "checkmark.circle")
}
}
#endif
Section {
Button(action: { router.isShowingFilters = true }) {
Label("Filters", systemImage: "line.3.horizontal.decrease.circle")
}
Button(action: { router.isShowingSessions = true }) {
Label("Sessions", systemImage: "list.bullet.clipboard")
}
}
Section {
ConsoleSortByMenu()
Menu {
ConsoleSortByMenuContent()
} label: {
Label("Sort By", systemImage: "arrow.up.arrow.down")
}
Menu {
ConsoleGroupByMenuContent()
ConsoleRemoveGroupingButton()
} label: {
Label("Group By", systemImage: "rectangle.3.group")
}
}
Section {
if !UserDefaults.standard.bool(forKey: "pulse-disable-settings-prompts") {
@@ -30,8 +54,8 @@ struct ConsoleContextMenu: View {
Label("Settings", systemImage: "gear")
}
}
if !environment.store.options.contains(.readonly) {
if !environment.store.isReadonly {
Button(role: .destructive, action: environment.removeAllLogs) {
Label("Remove Logs", systemImage: "trash")
}
@@ -42,6 +66,9 @@ struct ConsoleContextMenu: View {
Button(action: buttonGetPulseProTapped) {
Label("Get Pulse Pro", systemImage: "link")
}
Button(action: buttonSponsorTapped) {
Label("Sponsor", systemImage: "heart")
}
}
if !UserDefaults.standard.bool(forKey: "pulse-disable-report-issue-prompts") {
Button(action: buttonSendFeedbackTapped) {
@@ -50,7 +77,7 @@ struct ConsoleContextMenu: View {
}
}
} label: {
Image(systemName: "ellipsis.circle")
Image(systemName: "ellipsis")
}
}
@@ -58,40 +85,21 @@ struct ConsoleContextMenu: View {
URL(string: "https://pulselogger.com").map(openURL)
}
private func buttonSponsorTapped() {
URL(string: "https://github.com/sponsors/kean").map(openURL)
}
private func buttonSendFeedbackTapped() {
URL(string: "https://github.com/kean/Pulse/issues").map(openURL)
}
private func openURL(_ url: URL) {
#if os(macOS)
NSWorkspace.shared.open(url)
#else
UIApplication.shared.open(url)
}
}
private struct ConsoleSortByMenu: View {
@EnvironmentObject private var environment: ConsoleEnvironment
var body: some View {
Menu(content: {
if environment.mode == .network {
Picker("Sort By", selection: $environment.listOptions.taskSortBy) {
ForEach(ConsoleListOptions.TaskSortBy.allCases, id: \.self) {
Text($0.rawValue).tag($0)
}
}
} else {
Picker("Sort By", selection: $environment.listOptions.messageSortBy) {
ForEach(ConsoleListOptions.MessageSortBy.allCases, id: \.self) {
Text($0.rawValue).tag($0)
}
}
}
Picker("Ordering", selection: $environment.listOptions.order) {
Text("Descending").tag(ConsoleListOptions.Ordering.descending)
Text("Ascending").tag(ConsoleListOptions.Ordering.ascending)
}
}, label: {
Label("Sort By", systemImage: "arrow.up.arrow.down")
})
}
}
#endif
}
}
#endif
@@ -1,29 +1,43 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS)
#if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) || os(watchOS)
import Foundation
import SwiftUI
import Pulse
import CoreData
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleEntityCell: View {
let entity: NSManagedObject
var urlMatch: ConsoleSearchMatch?
init(entity: NSManagedObject) {
self.entity = entity
}
consuming func urlMatch(_ match: ConsoleSearchMatch?) -> ConsoleEntityCell {
self.urlMatch = match
return self
}
var body: some View {
switch LoggerEntity(entity) {
case .message(let message):
_ConsoleMessageCell(message: message)
case .task(let task):
_ConsoleTaskCell(task: task)
if entity.isDeleted {
EmptyView()
} else {
switch LoggerEntity(entity) {
case .message(let message):
_ConsoleMessageCell(message: message)
case .task(let task):
_ConsoleTaskCell(task: task, urlMatch: urlMatch)
}
}
}
}
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private struct _ConsoleMessageCell: View {
let message: LoggerMessageEntity
@@ -59,9 +73,10 @@ private struct _ConsoleMessageCell: View {
}
}
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private struct _ConsoleTaskCell: View {
let task: NetworkTaskEntity
var urlMatch: ConsoleSearchMatch?
@State private var shareItems: ShareItems?
@State private var sharedTask: NetworkTaskEntity?
@Environment(\.store) private var store
@@ -69,11 +84,11 @@ private struct _ConsoleTaskCell: View {
var body: some View {
#if os(iOS) || os(visionOS)
let cell = ConsoleTaskCell(task: task, isDisclosureNeeded: true)
let cell = ConsoleTaskCell(task: task, isDisclosureNeeded: true).urlMatch(urlMatch)
.background(NavigationLink("", destination: inspector).opacity(0))
#else
let cell = NavigationLink(destination: inspector) {
ConsoleTaskCell(task: task)
ConsoleTaskCell(task: task).urlMatch(urlMatch)
}
#endif
@@ -95,6 +110,9 @@ private struct _ConsoleTaskCell: View {
#else
ContextMenu.NetworkTaskContextMenuItems(task: task, sharedTask: $sharedTask)
#endif
if let custom = environment.delegate?.console(contextMenuFor: task) {
custom
}
}
#if os(iOS) || os(visionOS)
.sheet(item: $shareItems, content: ShareView.init)
@@ -1,15 +1,15 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
struct ConsoleTimestampView: View {
let date: Date
let timestamp: String
var body: some View {
Text(ConsoleMessageCell.timeFormatter.string(from: date))
Text(timestamp)
#if os(tvOS)
.font(.system(size: 21))
#else
@@ -22,8 +22,9 @@ struct ConsoleTimestampView: View {
}
}
struct MockBadgeView: View {
var body: some View {
package struct MockBadgeView: View {
package init() {}
package var body: some View {
Text("MOCK")
.foregroundStyle(.background)
.font(.caption2.weight(.semibold))
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
@@ -23,7 +23,7 @@ public struct ConsoleListDisplaySettings: Hashable, Codable {
/// The line limit for messages in the console. By default, `1`.
public var lineLimit: Int
/// Additinoal fields to display below the in the header.
/// Additional fields to display below the in the header.
public var fields: [TaskField]
public init(fontSize: Int? = nil, lineLimit: Int = 1, fields: [TaskField]? = nil) {
@@ -65,18 +65,26 @@ public struct ConsoleListDisplaySettings: Hashable, Codable {
/// If enabled, use monospaced font to display the content.
public var isMonospaced = false
/// A caller-supplied string rendered verbatim in place of the default
/// method + URL content. When set, ``showMethod`` and ``components``
/// are ignored. Typically produced by a ``ConsoleDelegate`` that
/// derives per-task content (e.g., a GraphQL operation name).
public var customText: String?
public init(
showTaskDescription: Bool = false,
showMethod: Bool = true,
components: Set<URLComponent> = [.path],
fontSize: Int? = nil,
lineLimit: Int = 3
lineLimit: Int = 3,
customText: String? = nil
) {
self.showTaskDescription = showTaskDescription
self.showMethod = showMethod
self.components = components
self.fontSize = fontSize ?? ConsoleListDisplaySettings.defaultContentFontSize
self.lineLimit = lineLimit
self.customText = customText
}
}
@@ -103,7 +111,7 @@ public struct ConsoleListDisplaySettings: Hashable, Codable {
}
}
public enum URLComponent: String, CaseIterable, Codable {
public enum URLComponent: String, Sendable, CaseIterable, Codable {
case scheme, user, password, host, port, path, query, fragment
}
@@ -119,6 +127,13 @@ public struct ConsoleListDisplaySettings: Hashable, Codable {
case taskDescription
case requestHeaderField(String)
case responseHeaderField(String)
/// A specific URL component (e.g., `.path`, `.host`). When `nil`, the
/// full URL string is rendered.
case url(components: Set<URLComponent>? = nil)
/// A caller-supplied string rendered verbatim. Typically produced by a
/// ``ConsoleDelegate`` that derives per-task information (e.g., a
/// GraphQL operation name) and embeds it in the header or footer.
case custom(String)
}
public init() {}
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
@@ -46,7 +46,7 @@ package struct ConsoleMessageCell: View {
if message.isPinned {
BookmarkIconView()
}
ConsoleTimestampView(date: message.createdAt)
ConsoleTimestampView(timestamp: message.formattedTimestamp)
.padding(.trailing, 3)
#endif
}
@@ -122,13 +122,10 @@ extension Color {
}
#if DEBUG
@available(iOS 16, visionOS 1, *)
struct ConsoleMessageCell_Previews: PreviewProvider {
static var previews: some View {
ConsoleMessageCell(message: try! LoggerStore.mock.messages()[0])
.injecting(ConsoleEnvironment(store: .mock))
.padding()
.previewLayout(.sizeThatFits)
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview(traits: .sizeThatFitsLayout) {
ConsoleMessageCell(message: try! LoggerStore.mock.messages()[0])
.injecting(ConsoleEnvironment(store: LoggerStore.mock))
.padding()
}
#endif
@@ -0,0 +1,73 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
#if os(iOS) || os(macOS) || os(visionOS)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleNavigationTitleView: View {
@EnvironmentObject private var environment: ConsoleEnvironment
@EnvironmentObject private var listViewModel: ConsoleListViewModel
@EnvironmentObject private var searchViewModel: ConsoleSearchViewModel
var body: some View {
Menu {
modeButton(.all, title: "All")
modeButton(.logs, title: "Logs")
modeButton(.network, title: "Network")
} label: {
headerView
}
}
@ViewBuilder
private var headerView: some View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
VStack(alignment: .center, spacing: 0) {
Text(modeTitle)
.font(.headline)
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Image(systemName: "chevron.down.circle.fill")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary, Color(.tertiarySystemFill))
.offset(y: -1)
}
}
private func modeButton(_ mode: ConsoleMode, title: String) -> some View {
Button {
environment.mode = mode
} label: {
if environment.mode == mode {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
}
}
private var modeTitle: String {
switch environment.mode {
case .all: return "Console"
case .logs: return "Logs"
case .network: return "Network"
}
}
private var subtitle: String {
let total = environment.mode.formattedCount(listViewModel.entities.count)
if searchViewModel.isSearching, searchViewModel.searchBar.text.isEmpty == false {
return "\(searchViewModel.results.count)\(searchViewModel.hasMore ? "+" : "") / \(total)"
}
return total
}
}
#endif
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import CoreData
@@ -9,7 +9,7 @@ import Combine
package final class ConsoleRouter: ObservableObject {
#if os(macOS)
@Published package var selection: ConsoleSelectedItem?
@Published package var _selection: (any Hashable)?
#endif
@Published package var shareItems: ShareItems?
@Published package var isShowingFilters = false
@@ -21,25 +21,35 @@ package final class ConsoleRouter: ObservableObject {
}
#if os(macOS)
@available(macOS 15, *)
package enum ConsoleSelectedItem: Hashable {
case entity(NSManagedObjectID)
case occurrence(NSManagedObjectID, ConsoleSearchOccurrence)
}
@available(macOS 15, *)
extension ConsoleRouter {
// Selection for macOS split-view navigation
package var selection: ConsoleSelectedItem? {
get { _selection as? ConsoleSelectedItem }
set { _selection = newValue }
}
}
#endif
struct ConsoleRouterView: View {
@EnvironmentObject var environment: ConsoleEnvironment
@EnvironmentObject var router: ConsoleRouter
@ObservedObject var router: ConsoleRouter
var body: some View {
if #available(iOS 16, macOS 13, *) {
if #available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *) {
contents
}
}
}
#if os(iOS) || os(visionOS)
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
extension ConsoleRouterView {
var contents: some View {
Text("").invisible()
@@ -54,11 +64,16 @@ extension ConsoleRouterView {
NavigationView {
ConsoleFiltersView()
.inlineNavigationTitle("Filters")
.navigationBarItems(trailing: Button("Done") {
router.isShowingFilters = false
})
.toolbar {
ToolbarItem(placement: .confirmationAction) {
makeButton(role: .confirm) {
router.isShowingFilters = false
}
}
}
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
}
.presentationDetents([.medium, .large])
}
@ViewBuilder
@@ -69,22 +84,25 @@ extension ConsoleRouterView {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
Button(action: { router.isShowingSessions = false }) {
Text("Close")
makeButton(role: .close) {
router.isShowingSessions = false
}
}
}
}
}
@ViewBuilder
private var destinationSettings: some View {
NavigationView {
SettingsView(store: environment.store)
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing: Button(action: { router.isShowingSettings = false }) {
Text("Done")
})
if let store = environment.store as? LoggerStore {
NavigationView {
SettingsView(store: store)
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing: Button(action: { router.isShowingSettings = false }) {
Text("Done")
})
}
}
}
@@ -97,47 +115,109 @@ extension ConsoleRouterView {
#elseif os(watchOS)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
extension ConsoleRouterView {
var contents: some View {
Text("").invisible()
.sheet(isPresented: $router.isShowingSettings) {
NavigationView {
SettingsView(store: environment.store)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: {
router.isShowingSettings = false
}, label: {
Image(systemName: "xmark")
})
}
.sheet(isPresented: $router.isShowingSettings) { destinationSettings }
.sheet(isPresented: $router.isShowingFilters) { destinationFilters }
.sheet(isPresented: $router.isShowingSessions) { destinationSessions }
}
@ViewBuilder
private var destinationSettings: some View {
if let store = environment.store as? LoggerStore {
NavigationView {
SettingsView(store: store)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: {
router.isShowingSettings = false
}, label: {
Image(systemName: "xmark")
})
}
}
}
}
.sheet(isPresented: $router.isShowingFilters) {
NavigationView {
ConsoleFiltersView()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: {
router.isShowingFilters = false
}, label: {
Image(systemName: "xmark")
})
}
}
}
private var destinationFilters: some View {
NavigationView {
ConsoleFiltersView()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: {
router.isShowingFilters = false
}, label: {
Image(systemName: "xmark")
})
}
}
}
}
private var destinationSessions: some View {
NavigationView {
SessionsView()
.navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: {
router.isShowingSessions = false
}, label: {
Image(systemName: "xmark")
})
}
}
}
}
}
#elseif os(tvOS)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
extension ConsoleRouterView {
var contents: some View {
Text("").invisible()
.sheet(isPresented: $router.isShowingSessions) { destinationSessions }
}
private var destinationSessions: some View {
NavigationView {
SessionsView()
.navigationTitle("Sessions")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: { router.isShowingSessions = false }) {
Image(systemName: "xmark")
}
}
}
}
}
}
}
#else
@available(macOS 13, *)
@available(macOS 15, *)
extension ConsoleRouterView {
var contents: some View {
Text("").invisible()
.sheet(isPresented: $router.isShowingFilters) { destinationFilters }
.sheet(isPresented: $router.isShowingSettings) { destinationSettings }
.sheet(isPresented: $router.isShowingShareStore) { destinationShareStore }
}
private var destinationFilters: some View {
ConsoleFiltersView()
.frame(width: 320, height: 500)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { router.isShowingFilters = false }
}
}
}
private var destinationSettings: some View {
@@ -146,12 +226,15 @@ extension ConsoleRouterView {
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(action: { router.isShowingSettings = false }) {
Text("Close")
}
Button("Close") { router.isShowingSettings = false }
}
}
}
private var destinationShareStore: some View {
ShareStoreView(onDismiss: { router.isShowingShareStore = false })
.frame(width: 340, height: 400)
}
}
#endif
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
@@ -13,7 +13,8 @@ package struct ConsoleTaskCell: View {
@ScaledMetric(relativeTo: .body) private var fontMultiplier = 1.0
@ObservedObject private var settings: UserSettings = .shared
@Environment(\.store) private var store: LoggerStore
@EnvironmentObject private var environment: ConsoleEnvironment
@Environment(\.store) private var store
package enum EditableArea {
case header, content, footer
@@ -21,19 +22,33 @@ package struct ConsoleTaskCell: View {
package var highlightedArea: EditableArea?
package var urlMatch: ConsoleSearchMatch?
package init(task: NetworkTaskEntity, isDisclosureNeeded: Bool = false, highlightedArea: EditableArea? = nil) {
self.task = task
self.isDisclosureNeeded = isDisclosureNeeded
self.highlightedArea = highlightedArea
}
package consuming func urlMatch(_ match: ConsoleSearchMatch?) -> ConsoleTaskCell {
self.urlMatch = match
return self
}
package var body: some View {
let displayOptions = environment.listDisplayOptions(for: task)
VStack(alignment: .leading, spacing: 4) {
header
makeContent(settings: settings.listDisplayOptions.content)
.highlighted(highlightedArea == .content)
makeHeader(settings: displayOptions.header)
Group {
if let custom = environment.delegate?.console(contentViewFor: task) {
custom
} else {
makeContent(settings: displayOptions.content)
}
}
.highlighted(highlightedArea == .content)
#if os(iOS) || os(watchOS)
makeFooter(settings: settings.listDisplayOptions.footer)
makeFooter(settings: displayOptions.footer)
.highlighted(highlightedArea == .footer)
#endif
}
@@ -42,25 +57,25 @@ package struct ConsoleTaskCell: View {
// MARK: Header
#if os(watchOS)
private var header: some View {
private func makeHeader(settings: ConsoleListDisplaySettings.HeaderSettings) -> some View {
HStack {
StatusIndicatorView(state: task.state(in: store))
info
makeInfo(settings: settings)
}
}
#else
private var header: some View {
private func makeHeader(settings: ConsoleListDisplaySettings.HeaderSettings) -> some View {
HStack(spacing: 6) {
if task.isMocked {
MockBadgeView()
}
info.highlighted(highlightedArea == .header)
makeInfo(settings: settings).highlighted(highlightedArea == .header)
Spacer()
if task.isPinned {
BookmarkIconView()
}
ConsoleTimestampView(date: task.createdAt)
ConsoleTimestampView(timestamp: task.formattedTimestamp)
.padding(.trailing, 3)
}
.overlay(alignment: .leading) {
@@ -80,28 +95,25 @@ package struct ConsoleTaskCell: View {
}
#endif
private var info: some View {
let status: Text = Text(ConsoleFormatter.status(for: task, store: store))
.font(makeFont(size: settings.listDisplayOptions.header.fontSize).weight(.medium))
.foregroundColor(task.state == .failure ? .red : .primary)
private func makeInfo(settings: ConsoleListDisplaySettings.HeaderSettings) -> some View {
let font = makeFont(size: settings.fontSize)
var attributed = AttributedString(ConsoleFormatter.status(for: task, store: store))
attributed.font = font.weight(.medium)
attributed.foregroundColor = task.state == .failure ? .red : .primary
#if os(watchOS)
return status // Not enough space for anything else
return Text(attributed) // Not enough space for anything else
#else
var text: Text {
let details = settings.listDisplayOptions.header.fields
.compactMap(task.makeInfoText)
.joined(separator: " · ")
guard !details.isEmpty else {
return status
}
return status + Text(" · \(details)")
.font(makeFont(size: settings.listDisplayOptions.header.fontSize))
let details = settings.fields
.compactMap { environment.makeInfoText(for: $0, task: task) }
.joined(separator: " · ")
if !details.isEmpty {
var detailsAttr = AttributedString(" · \(details)")
detailsAttr.font = font
attributed.append(detailsAttr)
}
return text
.tracking(-0.1)
.lineLimit(settings.listDisplayOptions.header.lineLimit)
return Text(attributed)
.lineLimit(settings.lineLimit)
.foregroundStyle(.secondary)
#endif
}
@@ -110,29 +122,23 @@ package struct ConsoleTaskCell: View {
private func makeContent(settings: ConsoleListDisplaySettings.ContentSettings) -> some View {
let design: Font.Design? = settings.isMonospaced ? .monospaced : nil
var method: Text? {
guard settings.showMethod, let method = task.httpMethod else {
return nil
}
return Text(method.appending(" "))
.font(makeFont(size: settings.fontSize, design: design).weight(.medium).smallCaps())
.tracking(-0.2)
let font = makeFont(size: settings.fontSize, design: design)
let content = environment.formattedContent(for: task, settings: settings) ?? ""
var main = makeHighlightedContent(content)
main.font = font
let attributed: AttributedString
if settings.showMethod, let method = task.httpMethod {
var methodAttr = AttributedString(method.appending(" "))
methodAttr.font = font.weight(.medium).smallCaps()
methodAttr.append(main)
attributed = methodAttr
} else {
attributed = main
}
var main: Text {
Text(task.getFormattedContent(settings: settings) ?? "")
.font(makeFont(size: settings.fontSize, design: design))
}
var text: Text {
if let method {
method + main
} else {
main
}
}
return text
return Text(attributed)
.lineLimit(settings.lineLimit)
}
@@ -141,14 +147,14 @@ package struct ConsoleTaskCell: View {
@ViewBuilder
private func makeFooter(settings: ConsoleListDisplaySettings.FooterSettings) -> some View {
let design: Font.Design? = settings.isMonospaced ? .monospaced : nil
let fields = settings.fields.compactMap(task.makeInfoText)
let fields = settings.fields.compactMap { environment.makeInfoText(for: $0, task: task) }
if !fields.isEmpty {
Text(fields.joined(separator: " · "))
.lineLimit(settings.lineLimit)
.font(makeFont(size: settings.fontSize, design: design))
.foregroundStyle(.secondary)
}
let additional = settings.additionalFields.compactMap(task.makeInfoItem)
let additional = settings.additionalFields.compactMap { environment.makeInfoItem(for: $0, task: task) }
if !additional.isEmpty {
Divider().opacity(0.5).padding(.vertical, 2)
VStack(alignment: .leading, spacing: 4) {
@@ -164,6 +170,18 @@ package struct ConsoleTaskCell: View {
// MARK: - Helpers
private func makeHighlightedContent(_ content: String) -> AttributedString {
var attributed = AttributedString(content)
guard let urlMatch else { return attributed }
let matchedText = String(urlMatch.line[urlMatch.range])
guard !matchedText.isEmpty, let range = attributed.range(of: matchedText) else {
return attributed
}
attributed.foregroundColor = .secondary
attributed[range].foregroundColor = .primary
return attributed
}
private func makeFont(size: Int, weight: Font.Weight? = nil, design: Font.Design? = nil) -> Font {
if #available(iOS 16, tvOS 16, *) {
return Font.system(size: CGFloat(design == .monospaced ? size - 1 : size) * fontMultiplier, weight: weight, design: design)
@@ -195,12 +213,10 @@ private extension View {
#endif
#if DEBUG
@available(iOS 16, visionOS 1, *)
struct ConsoleTaskCell_Previews: PreviewProvider {
static var previews: some View {
ConsoleTaskCell(task: LoggerStore.preview.entity(for: .login))
.padding()
.previewLayout(.sizeThatFits)
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview(traits: .sizeThatFitsLayout) {
ConsoleTaskCell(task: LoggerStore.preview.entity(for: .login))
.padding()
.injecting(ConsoleEnvironment(store: LoggerStore.preview))
}
#endif
@@ -1,154 +1,420 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(visionOS)
#if os(iOS) || os(macOS) || os(visionOS)
import SwiftUI
import Pulse
import CoreData
import Combine
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleToolbarView: View {
@EnvironmentObject private var environment: ConsoleEnvironment
@EnvironmentObject private var filters: ConsoleFiltersViewModel
@EnvironmentObject private var searchViewModel: ConsoleSearchViewModel
@Environment(\.isSearching) private var isSearching
var body: some View {
ViewThatFits {
horizontal
vertical
HStack(spacing: 0) {
HStack(spacing: 9) {
ConsoleSessionsPill()
ConsoleFilterPill(isSearching: isSearching)
}
Spacer(minLength: 9)
HStack(spacing: 9) {
ConsoleSortByPill()
ConsoleGroupByPill()
ConsoleOnlyErrorsButton(isEnabled: $filters.options.isOnlyErrors)
if isSearching {
ConsoleSearchContextMenu(viewModel: searchViewModel)
.transition(.scale(scale: 0.3, anchor: .trailing).combined(with: .opacity))
}
}
}
.buttonStyle(.plain)
.dynamicTypeSize(...DynamicTypeSize.accessibility2)
}
private var horizontal: some View {
HStack(alignment: .center, spacing: 0) {
contents(isVertical: false)
}
.buttonStyle(.plain)
}
// Fallback for larger dynamic font sizes.
private var vertical: some View {
VStack(alignment: .leading, spacing: 16) {
contents(isVertical: true)
}
.buttonStyle(.plain)
}
@ViewBuilder
private func contents(isVertical: Bool) -> some View {
switch environment.initialMode {
case .all:
ConsoleModePicker(environment: environment)
case .logs, .network:
ConsoleToolbarTitle()
}
if !isVertical {
Spacer()
}
HStack(spacing: 14) {
ConsoleListOptionsView()
}.padding(.trailing, isVertical ? 0 : -2)
.animation(.snappy, value: filters.options.isOnlyErrors)
.animation(.snappy, value: filters.options.filters)
.animation(.snappy, value: environment.listOptions)
.animation(.snappy, value: searchViewModel.options)
.animation(.snappy, value: searchViewModel.scopes)
.animation(.snappy, value: isSearching)
}
}
struct ConsoleModePicker: View {
@ObservedObject private var environment: ConsoleEnvironment
private let consolePillResetXmarkWidth: CGFloat = 24
@ObservedObject private var logsCounter: ManagedObjectsCountObserver
@ObservedObject private var tasksCounter: ManagedObjectsCountObserver
init(environment: ConsoleEnvironment) {
self.environment = environment
self.logsCounter = environment.logCountObserver
self.tasksCounter = environment.taskCountObserver
}
@available(iOS 18, tvOS 18, macOS 15, visionOS 1, *)
private struct ConsolePillResetXmark: View {
let action: () -> Void
var body: some View {
HStack(spacing: 7) {
ConsoleModeButton(title: "Network", details: CountFormatter.string(from: tasksCounter.count), isSelected: environment.mode == .network) {
environment.mode = .network
Button(action: action) {
Image(systemName: "xmark.circle.fill")
.font(.subheadline)
.foregroundStyle(Color.white.opacity(0.85))
.padding(.leading, 2)
.padding(.trailing, 8)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
/// Wraps a Button-style trigger in the standard console pill background,
/// optionally appending a reset xmark button when `isActive` and `resetAction` is set.
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private struct ConsoleTogglePill<Trigger: View>: View {
let isActive: Bool
var activeColor: Color = .accentColor
let resetAction: (() -> Void)?
@ViewBuilder let trigger: () -> Trigger
private var hasReset: Bool { isActive && resetAction != nil }
var body: some View {
HStack(spacing: 0) {
trigger()
.padding(.leading, 9)
.padding(.vertical, 8)
.padding(.trailing, hasReset ? 4 : 8)
if let resetAction, isActive {
ConsolePillResetXmark(action: resetAction)
}
ConsoleModeButton(title: "Logs", details: CountFormatter.string(from: logsCounter.count), isSelected: environment.mode == .logs) {
environment.mode = .logs
}
.frame(minWidth: 34) // Never narrower than tall keep at least square
.toolbarPillBackground(isActive: isActive, activeColor: activeColor)
.animation(.snappy, value: isActive)
}
}
/// A pill whose entire surface is the label of a Menu, so tapping anywhere on the
/// pill opens the menu. When `resetAction` is non-nil and `isActive`, an xmark
/// reset button is overlaid on the trailing edge with its own tap area.
@available(iOS 18, tvOS 18, macOS 15, visionOS 1, *)
private struct ConsoleMenuPill<MenuContent: View>: View {
let systemImage: String
var title: String? = nil
let isActive: Bool
let resetAction: (() -> Void)?
@ViewBuilder let menuContent: () -> MenuContent
private var hasReset: Bool { isActive && resetAction != nil }
var body: some View {
Menu {
menuContent()
} label: {
HStack(spacing: 0) {
ToolbarPillLabel(title: title, systemImage: systemImage, isActive: isActive)
.padding(.leading, 9)
.padding(.vertical, 8)
.padding(.trailing, hasReset ? 4 : 8)
if hasReset {
// Reserve space for the overlayed xmark button so the menu
// label visually leaves room for it.
Color.clear
.frame(width: consolePillResetXmarkWidth, height: 1)
}
}
ConsoleModeButton(title: "All", details: CountFormatter.string(from: logsCounter.count + tasksCounter.count), isSelected: environment.mode == .all) {
environment.mode = .all
.frame(minWidth: 34) // Never narrower than tall keep at least square
.toolbarPillBackground(isActive: isActive)
.animation(.snappy, value: isActive)
}
.menuStyle(.button)
.buttonStyle(.plain)
.overlay(alignment: .trailing) {
if let resetAction, isActive {
ConsolePillResetXmark(action: resetAction)
}
}
}
}
private struct ConsoleToolbarTitle: View {
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private struct ConsoleFilterPill: View {
let isSearching: Bool
@EnvironmentObject private var environment: ConsoleEnvironment
@EnvironmentObject private var listViewModel: ConsoleListViewModel
@EnvironmentObject private var filters: ConsoleFiltersViewModel
var body: some View {
Text(title)
.foregroundColor(.secondary)
.font(.subheadline.weight(.medium))
private var isActive: Bool {
!filters.isDefaultFilters(for: filters.mode)
}
private var title: String {
let kind = environment.initialMode == .network ? "Requests" : "Logs"
return "\(listViewModel.entities.count) \(kind)"
var body: some View {
ConsoleTogglePill(isActive: isActive, resetAction: resetFilters) {
Button(action: openFilters) {
ToolbarPillLabel(
title: "Filters",
systemImage: "line.3.horizontal.decrease",
isActive: isActive
)
}
.buttonStyle(.plain)
}
}
private func openFilters() {
environment.router.isShowingFilters = true
}
private func resetFilters() {
filters.resetAll()
}
}
package struct ConsoleModeButton: View {
package let title: String
package var details: String?
package let isSelected: Bool
package let action: () -> Void
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
package struct ConsoleOnlyErrorsButton: View {
@Binding package var isEnabled: Bool
package init(title: String, details: String? = nil, isSelected: Bool, action: @escaping () -> Void) {
self.title = title
self.details = details
self.isSelected = isSelected
self.action = action
package init(isEnabled: Binding<Bool>) {
self._isEnabled = isEnabled
}
package var body: some View {
Button(action: action) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(title)
.foregroundColor(isSelected ? Color.white : Color.secondary)
.font(.subheadline.weight(.medium))
.lineLimit(1)
.allowsTightening(true)
if let details = details {
Text("\(details)")
.foregroundColor(isSelected ? Color.white.opacity(0.7) : Color.secondary.opacity(0.7))
.font(.footnote)
.monospacedDigit()
.lineLimit(1)
.allowsTightening(true)
}
ConsoleTogglePill(isActive: isEnabled, activeColor: .red, resetAction: nil) {
Button(action: { isEnabled.toggle() }) {
ToolbarPillLabel(
systemImage: isEnabled ? "exclamationmark.octagon.fill" : "exclamationmark.octagon",
isActive: isEnabled
)
}
.padding(EdgeInsets(top: 8, leading: 9, bottom: 8, trailing: 8))
.background(isSelected ? Color.accentColor : Color(.secondarySystemFill).opacity(0.8))
.clipShape(RoundedRectangle(cornerRadius: 10))
.buttonStyle(.plain)
}
.buttonStyle(.plain)
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
}
}
@available(iOS 16, visionOS 1, *)
struct ConsoleListOptionsView: View {
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private struct ConsoleSessionsPill: View {
@EnvironmentObject private var environment: ConsoleEnvironment
@EnvironmentObject private var filters: ConsoleFiltersViewModel
var body: some View {
Button(action: { filters.options.isOnlyErrors.toggle() }) {
Text(Image(systemName: filters.options.isOnlyErrors ? "exclamationmark.octagon.fill" : "exclamationmark.octagon"))
.font(.body)
.foregroundColor(filters.options.isOnlyErrors ? .white : .secondary)
private var isActive: Bool {
let selected = filters.options.sessions
if let sessionID = environment.store.currentSessionID {
return selected != [sessionID]
}
return !selected.isEmpty
}
var body: some View {
ConsoleTogglePill(isActive: isActive, resetAction: nil) {
Button(action: { environment.router.isShowingSessions = true }) {
ToolbarPillLabel(
systemImage: "list.bullet.clipboard",
isActive: isActive
)
}
.buttonStyle(.plain)
}
}
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private struct ConsoleSortByPill: View {
@EnvironmentObject private var environment: ConsoleEnvironment
private var isActive: Bool {
if environment.mode == .network {
return environment.listOptions.taskSortBy != .dateCreated
|| environment.listOptions.order != .descending
} else {
return environment.listOptions.messageSortBy != .dateCreated
|| environment.listOptions.order != .descending
}
}
private var fieldLabel: String {
environment.mode == .network
? environment.listOptions.taskSortBy.pillTitle
: environment.listOptions.messageSortBy.pillTitle
}
var body: some View {
ConsoleMenuPill(
systemImage: environment.listOptions.order == .ascending ? "arrow.up" : "arrow.down",
title: fieldLabel,
isActive: isActive,
resetAction: reset
) {
ConsoleSortByMenuContent()
}
}
private func reset() {
environment.listOptions.order = .descending
if environment.mode == .network {
environment.listOptions.taskSortBy = .dateCreated
} else {
environment.listOptions.messageSortBy = .dateCreated
}
}
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleSortByMenuContent: View {
@EnvironmentObject private var environment: ConsoleEnvironment
var body: some View {
if environment.mode == .network {
Picker("Sort By", selection: $environment.listOptions.taskSortBy) {
ForEach(ConsoleListOptions.TaskSortBy.allCases, id: \.self) {
Text($0.rawValue).tag($0)
}
}
} else {
Picker("Sort By", selection: $environment.listOptions.messageSortBy) {
ForEach(ConsoleListOptions.MessageSortBy.allCases, id: \.self) {
Text($0.rawValue).tag($0)
}
}
}
Picker("Ordering", selection: $environment.listOptions.order) {
Text("Descending").tag(ConsoleListOptions.Ordering.descending)
Text("Ascending").tag(ConsoleListOptions.Ordering.ascending)
}
}
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
package struct ConsoleSearchContextMenu<ViewModel: ConsoleSearchOptionsHost>: View {
@ObservedObject var viewModel: ViewModel
@State private var isPresented = false
private var isOptionsActive: Bool {
viewModel.options != .default
}
private var isScopesActive: Bool {
viewModel.scopes != viewModel.savedDefaultScopes
}
private var isActive: Bool {
isOptionsActive || isScopesActive
}
package init(viewModel: ViewModel) {
self.viewModel = viewModel
}
package var body: some View {
ConsoleTogglePill(isActive: isActive, resetAction: isActive ? reset : nil) {
Button {
isPresented = true
} label: {
ToolbarPillLabel(
systemImage: "gearshape",
isActive: isActive
)
}
.buttonStyle(.plain)
}
#if os(iOS) || os(visionOS)
.sheet(isPresented: $isPresented) {
ConsoleSearchOptionsSheet(viewModel: viewModel)
.presentationDetents([.medium, .large])
}
#endif
}
private func reset() {
viewModel.options = .default
viewModel.resetScopesToDefault()
}
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
private struct ConsoleGroupByPill: View {
@EnvironmentObject private var environment: ConsoleEnvironment
@EnvironmentObject private var listViewModel: ConsoleListViewModel
private var isActive: Bool {
if environment.mode == .network {
return environment.listOptions.taskGroupBy != .noGrouping
} else {
return environment.listOptions.messageGroupBy != .noGrouping
}
}
var body: some View {
if isActive {
ConsoleMenuPill(
systemImage: "rectangle.3.group",
isActive: true,
resetAction: nil
) {
Section {
Button(action: listViewModel.collapseAllSections) {
Label("Collapse All", systemImage: "rectangle.compress.vertical")
}
Button(action: listViewModel.expandAllSections) {
Label("Expand All", systemImage: "rectangle.expand.vertical")
}
}
Section {
ConsoleRemoveGroupingButton()
}
Section {
ConsoleGroupByMenuContent()
}
}
.transition(.scale.combined(with: .opacity))
}
}
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleGroupByMenuContent: View {
@EnvironmentObject private var environment: ConsoleEnvironment
var body: some View {
if environment.mode == .network {
Picker("Group By", selection: $environment.listOptions.taskGroupBy) {
ForEach(ConsoleListOptions.TaskGroupBy.allCases.filter { $0 != .noGrouping }, id: \.self) {
Text($0.rawValue).tag($0)
}
}
} else {
Picker("Group By", selection: $environment.listOptions.messageGroupBy) {
ForEach(ConsoleListOptions.MessageGroupBy.allCases.filter { $0 != .noGrouping }, id: \.self) {
Text($0.rawValue).tag($0)
}
}
}
}
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleRemoveGroupingButton: View {
@EnvironmentObject private var environment: ConsoleEnvironment
private var isActive: Bool {
if environment.mode == .network {
return environment.listOptions.taskGroupBy != .noGrouping
} else {
return environment.listOptions.messageGroupBy != .noGrouping
}
}
var body: some View {
if isActive {
Button(role: .destructive, action: remove) {
Label("Remove Grouping", systemImage: "list.bullet")
}
}
}
private func remove() {
if environment.mode == .network {
environment.listOptions.taskGroupBy = .noGrouping
} else {
environment.listOptions.messageGroupBy = .noGrouping
}
.cornerRadius(4)
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
.padding(7)
.background(filters.options.isOnlyErrors ? .red : Color(.secondarySystemFill).opacity(0.8))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
@@ -1,12 +1,13 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
#if os(iOS) || os(tvOS) || os(macOS) || os(watchOS) || os(visionOS)
import SwiftUI
import Pulse
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct FileViewer: View {
@ObservedObject var viewModel: FileViewerViewModel
@@ -34,6 +35,9 @@ struct FileViewer: View {
case .pdf(let document):
PDFKitRepresentedView(document: document)
.edgesIgnoringSafeArea(.all)
#elseif os(macOS)
case .pdf:
PlaceholderView(imageName: "doc.richtext", title: "PDF Preview", subtitle: "PDF preview is not available")
#endif
case .other(let viewModel):
RichTextView(viewModel: viewModel)
@@ -44,29 +48,38 @@ struct FileViewer: View {
// MARK: - Preview
#if DEBUG
struct FileViewer_Previews: PreviewProvider {
static var previews: some View {
Group {
PreviewContainer {
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/json", originalSize: 1200), data: { MockJSON.allPossibleValues }))
}.previewDisplayName("JSON")
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("JSON") {
PreviewContainer {
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/json", originalSize: 1200), data: { MockJSON.allPossibleValues }))
}
}
PreviewContainer {
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "image/png", originalSize: 219543), data: { MockTask.octocat.responseBody }))
}.previewDisplayName("Image")
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("Image") {
PreviewContainer {
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "image/png", originalSize: 219543), data: { MockTask.octocat.responseBody }))
}
}
PreviewContainer {
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/html", originalSize: 1200), data: { MockTask.profile.responseBody }))
}.previewDisplayName("HTML")
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("HTML") {
PreviewContainer {
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/html", originalSize: 1200), data: { MockTask.profile.responseBody }))
}
}
PreviewContainer {
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/x-www-form-urlencoded", originalSize: 1200), data: { MockTask.patchRepo.originalRequest.httpBody ?? Data() }))
}.previewDisplayName("Query Items")
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("Query Items") {
PreviewContainer {
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/x-www-form-urlencoded", originalSize: 1200), data: { MockTask.patchRepo.originalRequest.httpBody ?? Data() }))
}
}
PreviewContainer {
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/pdf", originalSize: 1000), data: { mockPDF }))
}.previewDisplayName("PDF")
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("PDF") {
PreviewContainer {
FileViewer(viewModel: .init(title: "Response", context: .init(contentType: "application/pdf", originalSize: 1000), data: { mockPDF }))
}
}
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import CoreData
@@ -37,10 +37,14 @@ struct ScrollableTextView: UIViewRepresentable {
}
}
struct RichTextView: View {
public struct RichTextView: View {
let viewModel: RichTextViewModel
var body: some View {
public init(viewModel: RichTextViewModel) {
self.viewModel = viewModel
}
public var body: some View {
if let attributedText = viewModel.attributedString {
ScrollableTextView(attributedText: attributedText)
} else {
@@ -1,16 +1,20 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
#if os(watchOS) || os(macOS)
struct RichTextView: View {
public struct RichTextView: View {
let viewModel: RichTextViewModel
var body: some View {
public init(viewModel: RichTextViewModel) {
self.viewModel = viewModel
}
public var body: some View {
ScrollView {
if let string = viewModel.attributedString {
Text(string)
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(visionOS)
@@ -9,7 +9,8 @@ import CoreData
import Pulse
import Combine
struct RichTextView: View {
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
public struct RichTextView: View {
@ObservedObject var viewModel: RichTextViewModel
var isTextViewBarItemsHidden = false
@@ -18,13 +19,17 @@ struct RichTextView: View {
@Environment(\.textViewSearchContext) private var searchContext
func textViewBarItemsHidden(_ isHidden: Bool) -> RichTextView {
public init(viewModel: RichTextViewModel) {
self.viewModel = viewModel
}
public func textViewBarItemsHidden(_ isHidden: Bool) -> RichTextView {
var copy = self
copy.isTextViewBarItemsHidden = isHidden
return copy
}
var body: some View {
public var body: some View {
contents
.onAppear { viewModel.prepare(searchContext) }
.navigationBarItems(trailing: navigationBarTrailingItems)
@@ -98,12 +103,11 @@ struct RichTextView: View {
}
#if DEBUG
struct RichTextView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
RichTextView(viewModel: makePreviewViewModel())
.inlineNavigationTitle("Rich Text View")
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview {
NavigationView {
RichTextView(viewModel: makePreviewViewModel())
.inlineNavigationTitle("Rich Text View")
}
}
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
@@ -8,7 +8,7 @@ import Combine
#if os(iOS) || os(visionOS)
final class RichTextViewModel: ObservableObject {
public final class RichTextViewModel: ObservableObject {
// Search
@Published var searchOptions: StringSearchOptions = .default
@Published private(set) var selectedMatchIndex: Int = 0
@@ -16,15 +16,15 @@ final class RichTextViewModel: ObservableObject {
@Published var searchTerm: String = ""
// Configuration
@Published var isLinkDetectionEnabled = true
@Published public var isLinkDetectionEnabled = true
var isToolbarHidden = false
let contentType: NetworkLogger.ContentType?
let originalText: NSAttributedString
public let contentType: NetworkLogger.ContentType?
public let originalText: NSAttributedString
var onLinkTapped: ((URL) -> Bool)?
public var onLinkTapped: ((URL) -> Bool)?
var isEmpty: Bool { originalText.length == 0 }
public var isEmpty: Bool { originalText.length == 0 }
weak var textView: UXTextView? // Not proper MVVM
var textStorage: NSTextStorage { textView?.textStorage ?? NSTextStorage(string: "") }
@@ -41,11 +41,11 @@ final class RichTextViewModel: ObservableObject {
let originalBackgroundColor: UXColor?
}
convenience init(string: NSAttributedString = NSAttributedString()) {
public convenience init(string: NSAttributedString = NSAttributedString()) {
self.init(string: string, contentType: nil)
}
init(string: NSAttributedString, contentType: NetworkLogger.ContentType?) {
public init(string: NSAttributedString, contentType: NetworkLogger.ContentType?) {
self.originalText = string
self.contentType = contentType
@@ -229,19 +229,19 @@ package struct TextViewSearchContext {
}
#if os(watchOS) || os(tvOS) || os(macOS)
final class RichTextViewModel: ObservableObject {
let text: String
let attributedString: AttributedString?
public final class RichTextViewModel: ObservableObject {
public let text: String
public let attributedString: AttributedString?
var isLinkDetectionEnabled = true
var isEmpty: Bool { text.isEmpty }
public var isLinkDetectionEnabled = true
public var isEmpty: Bool { text.isEmpty }
init(string: String) {
public init(string: String) {
self.text = string
self.attributedString = nil
}
init(string: NSAttributedString, contentType: NetworkLogger.ContentType? = nil) {
public init(string: NSAttributedString, contentType: NetworkLogger.ContentType? = nil) {
#if os(macOS)
self.attributedString = try? AttributedString(string, including: \.appKit)
#else
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(visionOS)
@@ -1,12 +1,13 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(visionOS)
import SwiftUI
import Combine
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct WrappedTextView: UIViewRepresentable {
let viewModel: RichTextViewModel
@@ -16,6 +17,22 @@ struct WrappedTextView: UIViewRepresentable {
var onLinkTapped: ((URL) -> Bool)?
var cancellables: [AnyCancellable] = []
func textView(_ textView: UITextView, primaryActionFor textItem: UITextItem, defaultAction: UIAction) -> UIAction? {
guard case .link(let URL) = textItem.content else { return defaultAction }
if let onLinkTapped = onLinkTapped, onLinkTapped(URL) {
return nil
}
if let (title, message) = parseTooltip(URL) {
return UIAction { _ in
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(.init(title: "Done", style: .cancel))
UIApplication.keyWindow?.rootViewController?.present(alert, animated: true)
}
}
return defaultAction
}
@available(iOS, deprecated: 17, message: "Use primaryActionFor instead")
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
if let onLinkTapped = onLinkTapped, onLinkTapped(URL) {
return false
@@ -32,13 +49,8 @@ struct WrappedTextView: UIViewRepresentable {
}
func makeUIView(context: Context) -> UXTextView {
let textView: UITextView
if #available(iOS 16, *) {
// Disables the new TextKit 2 which is extremely slow on iOS 16
textView = UITextView(usingTextLayoutManager: false)
} else {
textView = UITextView()
}
// Disables the new TextKit 2 which is extremely slow on iOS 16
let textView = UITextView(usingTextLayoutManager: false)
configureTextView(textView)
textView.delegate = context.coordinator
textView.attributedText = viewModel.originalText
@@ -0,0 +1,40 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
struct ConsoleSearchContentTypeCell: View {
@Binding var selection: ConsoleFilters.ContentType.ContentType
var body: some View {
Picker("Content Type", selection: $selection) {
Section {
Text("Any").tag(ContentType.any)
Text("JSON").tag(ContentType.json)
Text("Text").tag(ContentType.plain)
}
Section {
Text("HTML").tag(ContentType.html)
Text("CSS").tag(ContentType.css)
Text("CSV").tag(ContentType.csv)
Text("JS").tag(ContentType.javascript)
Text("XML").tag(ContentType.xml)
Text("PDF").tag(ContentType.pdf)
}
Section {
Text("Image").tag(ContentType.anyImage)
Text("JPEG").tag(ContentType.jpeg)
Text("PNG").tag(ContentType.png)
Text("GIF").tag(ContentType.gif)
Text("WebP").tag(ContentType.webp)
}
Section {
Text("Video").tag(ContentType.anyVideo)
}
}
}
private typealias ContentType = ConsoleFilters.ContentType.ContentType
}
@@ -0,0 +1,29 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
struct ConsoleSearchDurationCell: View {
@Binding var selection: ConsoleFilters.Duration
var body: some View {
VStack(spacing: 16) {
HStack(spacing: 0) {
Text("Duration").lineLimit(1)
Spacer(minLength: 16)
Picker("Unit", selection: $selection.unit) {
ForEach(ConsoleFilters.Duration.Unit.allCases) {
Text($0.title).tag($0)
}
}
.labelsHidden()
.padding(.trailing, 12)
RangePicker(range: $selection.range)
}
.frame(height: 18) // Ensure cells have consistent height
}
}
}
@@ -0,0 +1,19 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
struct ConsoleSearchHTTPMethodCell: View {
@Binding var selection: ConsoleFilters.Request.HTTPMethodFilter
var body: some View {
Picker("HTTP Method", selection: $selection) {
Text("Any").tag(ConsoleFilters.Request.HTTPMethodFilter.any)
ForEach(HTTPMethod.allCases, id: \.self) {
Text($0.rawValue).tag(ConsoleFilters.Request.HTTPMethodFilter.some($0))
}
}
}
}
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
@@ -0,0 +1,27 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
struct ConsoleSearchRequestSizeCell: View {
@Binding var selection: ConsoleFilters.ResponseSize
var body: some View {
HStack(spacing: 0) {
Text("Body Size").lineLimit(1)
Spacer(minLength: 16)
Picker("Unit", selection: $selection.unit) {
ForEach(ConsoleFilters.ResponseSize.MeasurementUnit.allCases) {
Text($0.title).tag($0)
}
}
.labelsHidden()
.padding(.trailing, 12)
RangePicker(range: $selection.range)
}
.frame(height: 18)
}
}
@@ -0,0 +1,18 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
struct ConsoleSearchRequestStateCell: View {
@Binding var selection: ConsoleFilters.Networking.RequestState
var body: some View {
Picker("Request State", selection: $selection) {
ForEach(ConsoleFilters.Networking.RequestState.allCases, id: \.self) {
Text($0.title).tag($0)
}
}
}
}
@@ -0,0 +1,27 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
struct ConsoleSearchResponseSizeCell: View {
@Binding var selection: ConsoleFilters.ResponseSize
var body: some View {
HStack(spacing: 0) {
Text("Body Size").lineLimit(1)
Spacer(minLength: 16)
Picker("Unit", selection: $selection.unit) {
ForEach(ConsoleFilters.ResponseSize.MeasurementUnit.allCases) {
Text($0.title).tag($0)
}
}
.labelsHidden()
.padding(.trailing, 12)
RangePicker(range: $selection.range)
}
.frame(height: 18) // Ensure cells have consistent height
}
}
@@ -0,0 +1,18 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
struct ConsoleSearchResponseSourceCell: View {
@Binding var selection: ConsoleFilters.Networking.Source
var body: some View {
Picker("Response Source", selection: $selection) {
ForEach(ConsoleFilters.Networking.Source.allCases, id: \.self) {
Text($0.title).tag($0)
}
}
}
}
@@ -0,0 +1,83 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleSearchStatusCodeCell: View {
@Binding var selection: ValuesRange<String>
#if os(watchOS)
var body: some View {
Picker("Status Code", selection: statusCodeOption) {
ForEach(StatusCodeOption.allCases) { option in
Text(option.title).tag(option)
}
}
}
private var statusCodeOption: Binding<StatusCodeOption> {
Binding(
get: { StatusCodeOption(range: selection) },
set: { selection = $0.range }
)
}
private enum StatusCodeOption: Hashable, CaseIterable, Identifiable {
case any, success, redirect, clientError, serverError, allErrors
var id: Self { self }
var title: String {
switch self {
case .any: "Any"
case .success: "Success (2xx)"
case .redirect: "Redirects (3xx)"
case .clientError: "Client Errors (4xx)"
case .serverError: "Server Errors (5xx)"
case .allErrors: "All Errors"
}
}
var range: ValuesRange<String> {
switch self {
case .any: .empty
case .success: ValuesRange(lowerBound: "200", upperBound: "299")
case .redirect: ValuesRange(lowerBound: "300", upperBound: "399")
case .clientError: ValuesRange(lowerBound: "400", upperBound: "499")
case .serverError: ValuesRange(lowerBound: "500", upperBound: "599")
case .allErrors: ValuesRange(lowerBound: "400", upperBound: "599")
}
}
init(range: ValuesRange<String>) {
self = Self.allCases.first { $0.range == range } ?? .any
}
}
#else
var body: some View {
VStack(spacing: 16) {
HStack {
Text("Status Code").lineLimit(1)
Spacer()
RangePicker(range: $selection)
}
.frame(height: 18) // Ensure cells have consistent height
quickFilters
}
}
private var quickFilters: some View {
SuggestionPills {
SuggestionPill("Success (2xx)") { selection = ValuesRange(lowerBound: "200", upperBound: "299") }
SuggestionPill("Redirects (3xx)") { selection = ValuesRange(lowerBound: "300", upperBound: "399") }
SuggestionPill("Client Errors (4xx)") { selection = ValuesRange(lowerBound: "400", upperBound: "499") }
SuggestionPill("Server Errors (5xx)") { selection = ValuesRange(lowerBound: "500", upperBound: "599") }
SuggestionPill("All Errors") { selection = ValuesRange(lowerBound: "400", upperBound: "599") }
}
}
#endif
}
@@ -0,0 +1,21 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
struct ConsoleSearchTaskTypeCell: View {
@Binding var selection: ConsoleFilters.Networking.TaskType
var body: some View {
Picker("Task Type", selection: $selection) {
Text("Any").tag(ConsoleFilters.Networking.TaskType.any)
Text("Data").tag(ConsoleFilters.Networking.TaskType.some(.dataTask))
Text("Download").tag(ConsoleFilters.Networking.TaskType.some(.downloadTask))
Text("Upload").tag(ConsoleFilters.Networking.TaskType.some(.uploadTask))
Text("Stream").tag(ConsoleFilters.Networking.TaskType.some(.streamTask))
Text("WebSocket").tag(ConsoleFilters.Networking.TaskType.some(.webSocketTask))
}
}
}
@@ -1,34 +1,31 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(macOS) || os(visionOS)
import SwiftUI
import Pulse
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleSearchTimePeriodCell: View {
@Binding var selection: ConsoleFilters.Dates
var body: some View {
DateRangePicker(title: "Start", date: $selection.startDate)
DateRangePicker(title: "End", date: $selection.endDate)
quickFilters
VStack(spacing: 16) {
DateRangePicker(title: "End", date: $selection.endDate)
quickFilters
}
}
@ViewBuilder
private var quickFilters: some View {
HStack(alignment: .center, spacing: 8) {
Text("Quick Filters")
.lineLimit(1)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
Button("Recent") { selection = .recent }
Button("Today") { selection = .today }
SuggestionPills {
SuggestionPill("30 min") { selection = .last30Minutes }
SuggestionPill("1 hour") { selection = .lastHour }
SuggestionPill("Today") { selection = .today }
SuggestionPill("Yesterday") { selection = .yesterday }
}
.buttonStyle(.plain)
.padding(.top, 4)
.foregroundColor(.accentColor)
}
}
@@ -1,6 +1,6 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
@@ -1,34 +1,54 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import CoreData
import Pulse
import Combine
import SwiftUI
final class ConsoleFiltersViewModel: ObservableObject {
@Published var mode: ConsoleMode = .all
@Published var options = ConsoleDataSource.PredicateOptions()
package final class ConsoleFiltersViewModel: ObservableObject {
@Published package var mode: ConsoleMode = .all
@Published package var options = ConsoleListPredicateOptions()
var criteria: ConsoleFilters {
package var criteria: ConsoleFilters {
get { options.filters }
set { options.filters = newValue }
}
let defaultCriteria: ConsoleFilters
package var sessions: Set<UUID> {
get { options.sessions }
set { options.sessions = newValue }
}
package let defaultCriteria: ConsoleFilters
// TODO: Refactor
let entities = CurrentValueSubject<[NSManagedObject], Never>([])
package let entities = CurrentValueSubject<[NSManagedObject], Never>([])
init(options: ConsoleDataSource.PredicateOptions) {
#if os(iOS) || os(macOS) || os(visionOS)
/// One store per mode recents in network mode shouldn't appear when the
/// user is in logs mode.
private var recentStores: [ConsoleMode: ConsoleRecentFiltersStore] = [:]
package func recentFiltersStore(for mode: ConsoleMode) -> ConsoleRecentFiltersStore {
if let existing = recentStores[mode] {
return existing
}
let store = ConsoleRecentFiltersStore(mode: mode)
recentStores[mode] = store
return store
}
#endif
package init(options: ConsoleListPredicateOptions) {
self.options = options
self.defaultCriteria = options.filters
}
// MARK: Helpers
func isDefaultFilters(for mode: ConsoleMode) -> Bool {
package func isDefaultFilters(for mode: ConsoleMode) -> Bool {
guard criteria.shared == defaultCriteria.shared else { return false }
if mode == .network {
return criteria.network == defaultCriteria.network
@@ -37,11 +57,23 @@ final class ConsoleFiltersViewModel: ObservableObject {
}
}
func select(sessions: Set<UUID>) {
self.criteria.shared.sessions.selection = sessions
}
func resetAll() {
package func resetAll() {
criteria = defaultCriteria
}
#if os(iOS) || os(macOS) || os(visionOS)
/// Snapshots the current filters into the per-mode recents store. Called
/// when the filters sheet dismisses. No-ops on default/empty filter sets
/// (handled by the store itself).
package func snapshotRecentFilters() {
guard !isDefaultFilters(for: mode) else { return }
recentFiltersStore(for: mode).save(criteria)
}
/// Applies a recent filter set to the current criteria. Session selection
/// is unaffected because it lives outside of `ConsoleFilters`.
package func apply(_ entry: ConsoleRecentFilter) {
criteria = entry.filters
}
#endif
}
@@ -1,69 +1,111 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import SwiftUI
import Pulse
import Combine
#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
@available(iOS 16, visionOS 1, *)
struct ConsoleFiltersView: View {
@EnvironmentObject var environment: ConsoleEnvironment // important: reloads mode
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
package struct ConsoleFiltersView: View {
@EnvironmentObject var viewModel: ConsoleFiltersViewModel
@EnvironmentObject private var environment: ConsoleEnvironment
var body: some View {
@Environment(\.dismiss) var dismiss
package init() {}
package var body: some View {
#if os(iOS) || os(watchOS) || os(tvOS) || os(visionOS)
Form {
form
}
.animation(.snappy, value: viewModel.criteria)
#if os(iOS) || os(visionOS)
.navigationBarItems(leading: buttonReset)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
buttonRecents
}
}
.onDisappear { viewModel.snapshotRecentFilters() }
#endif
#else
VStack(spacing: 0) {
ScrollView {
form
}
HStack {
Text(viewModel.mode == .network ? "Network Filters" : "Message Filters")
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
}
.padding(.horizontal, 10)
.frame(height: 34, alignment: .center)
}
.animation(.snappy, value: viewModel.criteria)
.onDisappear { viewModel.snapshotRecentFilters() }
#endif
}
#if os(iOS) || os(visionOS)
@State private var isShowingRecents = false
private var buttonRecents: some View {
let store = viewModel.recentFiltersStore(for: viewModel.mode)
return Button(action: { isShowingRecents = true }) {
Text("Recents")
}
.disabled(store.recents.isEmpty)
.sheet(isPresented: $isShowingRecents) {
ConsoleRecentFiltersListView(store: store, mode: viewModel.mode) {
viewModel.apply($0)
}
.presentationDetents([.medium, .large])
}
}
#endif
@ViewBuilder
private var form: some View {
#if os(tvOS) || os(watchOS)
buttonReset
#endif
sessionsSection
if environment.mode == .network {
if viewModel.mode == .network {
responseSection
requestSection
customNetworkFiltersSection
domainsSection
networkingSection
} else {
customMessageFiltersSection
logLevelsSection
labelsSection
}
#if os(iOS) || os(visionOS)
#if os(iOS) || os(macOS) || os(visionOS)
timePeriodSection
#endif
buttonReset
}
private var buttonReset: some View {
Button(role: .destructive, action: viewModel.resetAll) { Text("Reset") }
.disabled(viewModel.isDefaultFilters(for: environment.mode))
Button("Reset All", role: .destructive, action: viewModel.resetAll)
.disabled(viewModel.isDefaultFilters(for: viewModel.mode))
.frame(maxWidth: .infinity, alignment: .center)
}
}
// MARK: - ConsoleFiltersView (Sections)
@available(iOS 16, visionOS 1, *)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
extension ConsoleFiltersView {
var sessionsSection: some View {
ConsoleSection(isDividerHidden: true, header: {
ConsoleSectionHeader(icon: "list.clipboard", title: "Sessions", filter: $viewModel.criteria.shared.sessions, default: viewModel.defaultCriteria.shared.sessions)
}, content: {
ConsoleSessionsPickerView(selection: $viewModel.criteria.shared.sessions.selection)
})
}
#if os(iOS) || os(visionOS)
#if os(iOS) || os(macOS) || os(visionOS)
var timePeriodSection: some View {
ConsoleSection(header: {
ConsoleSectionHeader(icon: "calendar", title: "Time Period", filter: $viewModel.criteria.shared.dates)
ConsoleSearchSectionHeader(icon: "calendar", title: "Time Period", filter: $viewModel.criteria.shared.dates)
}, content: {
ConsoleSearchTimePeriodCell(selection: $viewModel.criteria.shared.dates)
})
@@ -72,7 +114,7 @@ extension ConsoleFiltersView {
var logLevelsSection: some View {
ConsoleSection(header: {
ConsoleSectionHeader(icon: "flag", title: "Levels", filter: $viewModel.criteria.messages.logLevels)
ConsoleSearchSectionHeader(icon: "flag", title: "Levels", filter: $viewModel.criteria.messages.logLevels)
}, content: {
ConsoleSearchLogLevelsCell(selection: $viewModel.criteria.messages.logLevels.levels)
})
@@ -80,17 +122,86 @@ extension ConsoleFiltersView {
var labelsSection: some View {
ConsoleSection(header: {
ConsoleSectionHeader(icon: "tag", title: "Labels", filter: $viewModel.criteria.messages.labels)
ConsoleSearchSectionHeader(icon: "tag", title: "Labels", filter: $viewModel.criteria.messages.labels)
}, content: {
ConsoleLabelsSelectionView(viewModel: viewModel)
ConsoleLabelsSelectionView(viewModel: viewModel, index: environment.index)
})
}
var domainsSection: some View {
ConsoleSection(header: {
ConsoleSectionHeader(icon: "server.rack", title: "Hosts", filter: $viewModel.criteria.network.host)
ConsoleSearchSectionHeader(icon: "server.rack", title: "Hosts", filter: $viewModel.criteria.network.host)
}, content: {
ConsoleDomainsSelectionView(viewModel: viewModel)
ConsoleDomainsSelectionView(viewModel: viewModel, index: environment.index)
})
}
var customMessageFiltersSection: some View {
ConsoleSection(header: {
ConsoleSearchSectionHeader(icon: "line.horizontal.3.decrease.circle", title: "Filters", filter: $viewModel.criteria.messages.custom) {
ConsoleFilterLogicalOperatorPicker(
selection: $viewModel.criteria.messages.custom.logicalOperator,
activeFilterCount: viewModel.criteria.messages.custom.filters.filter({ !$0.value.isEmpty }).count
)
}
}, content: {
ConsoleSearchCustomFiltersSection(
filters: $viewModel.criteria.messages.custom.filters,
fieldGroups: ConsoleCustomFilter.messageFieldGroups,
defaultFilter: .defaultMessageFilter()
)
})
}
var customNetworkFiltersSection: some View {
ConsoleSection(header: {
ConsoleSearchSectionHeader(icon: "line.horizontal.3.decrease.circle", title: "Filters", filter: $viewModel.criteria.network.custom) {
ConsoleFilterLogicalOperatorPicker(
selection: $viewModel.criteria.network.custom.logicalOperator,
activeFilterCount: viewModel.criteria.network.custom.filters.filter({ !$0.value.isEmpty }).count
)
}
}, content: {
ConsoleSearchCustomFiltersSection(
filters: $viewModel.criteria.network.custom.filters,
fieldGroups: ConsoleCustomFilter.networkFieldGroups,
defaultFilter: .defaultNetworkFilter()
)
})
}
var responseSection: some View {
ConsoleSection(header: {
ConsoleSearchSectionHeader(icon: "arrow.down.circle", title: "Response", filter: $viewModel.criteria.network.response)
}, content: {
ConsoleSearchStatusCodeCell(selection: $viewModel.criteria.network.response.statusCode.range)
#if !os(watchOS)
ConsoleSearchDurationCell(selection: $viewModel.criteria.network.response.duration)
ConsoleSearchResponseSizeCell(selection: $viewModel.criteria.network.response.responseSize)
#endif
ConsoleSearchContentTypeCell(selection: $viewModel.criteria.network.response.contentType.contentType)
})
}
var requestSection: some View {
ConsoleSection(header: {
ConsoleSearchSectionHeader(icon: "arrow.up.circle", title: "Request", filter: $viewModel.criteria.network.request)
}, content: {
ConsoleSearchHTTPMethodCell(selection: $viewModel.criteria.network.request.httpMethod)
#if !os(watchOS)
ConsoleSearchRequestSizeCell(selection: $viewModel.criteria.network.request.requestSize)
#endif
})
}
var networkingSection: some View {
ConsoleSection(header: {
ConsoleSearchSectionHeader(icon: "arrowshape.zigzag.right", title: "Advanced", filter: $viewModel.criteria.network.networking)
}, content: {
ConsoleSearchTaskTypeCell(selection: $viewModel.criteria.network.networking.taskType)
ConsoleSearchRequestStateCell(selection: $viewModel.criteria.network.networking.requestState)
ConsoleSearchResponseSourceCell(selection: $viewModel.criteria.network.networking.source)
ConsoleSearchToggleCell(title: "Redirect", isOn: $viewModel.criteria.network.networking.isRedirect)
})
}
}
@@ -98,37 +209,47 @@ extension ConsoleFiltersView {
#if DEBUG
import CoreData
@available(iOS 16, visionOS 1, *)
struct ConsoleFiltersView_Previews: PreviewProvider {
static var previews: some View {
Group {
NavigationView {
makePreview(isOnlyNetwork: false)
}
.navigationViewStyle(.stack)
.previewDisplayName("Messages")
NavigationView {
makePreview(isOnlyNetwork: true)
}
.navigationViewStyle(.stack)
.previewDisplayName("Network")
}
.injecting(.init(store: .mock))
}
}
@available(iOS 16, macOS 13, visionOS 1, *)
private func makePreview(isOnlyNetwork: Bool) -> some View {
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("Messages", traits: .fixedLayout(width: 280, height: 900)) {
let store = LoggerStore.mock
let entities: [NSManagedObject] = try! isOnlyNetwork ? store.tasks() : store.messages()
let viewModel = ConsoleFiltersViewModel(options: .init())
let entities: [NSManagedObject] = try! store.messages()
let environment = ConsoleEnvironment(store: store)
let viewModel = environment.filters
viewModel.entities.send(entities)
viewModel.mode = isOnlyNetwork ? .network : .all
return ConsoleFiltersView()
.injecting(ConsoleEnvironment(store: store))
viewModel.mode = .logs
let content = ConsoleFiltersView()
.environmentObject(viewModel)
.environmentObject(environment)
#if os(macOS)
return content
#else
return NavigationView {
content
}
.navigationViewStyle(.stack)
#endif
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("Network", traits: .fixedLayout(width: 280, height: 900)) {
let store = LoggerStore.mock
let entities: [NSManagedObject] = try! store.tasks()
let environment = ConsoleEnvironment(store: store)
let viewModel = environment.filters
viewModel.entities.send(entities)
viewModel.mode = .network
let content = ConsoleFiltersView()
.environmentObject(viewModel)
.environmentObject(environment)
#if os(macOS)
return content
#else
return NavigationView {
content
}
.navigationViewStyle(.stack)
#endif
}
#endif
#endif
@@ -0,0 +1,96 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(macOS) || os(visionOS)
import Foundation
import SwiftUI
import Combine
/// A persisted entry in the recent filters list.
package struct ConsoleRecentFilter: Identifiable, Hashable, Codable {
package let id: UUID
package var filters: ConsoleFilters
package var lastUsedDate: Date
package init(id: UUID = UUID(), filters: ConsoleFilters, lastUsedDate: Date = Date()) {
self.id = id
self.filters = filters
self.lastUsedDate = lastUsedDate
}
}
/// Persists recently-used filter sets per ``ConsoleMode``. Mirrors
/// ``ConsoleSearchRecentSearchesStore`` in shape JSON-encoded into
/// `UserDefaults`, capped, with most-recent-first ordering.
package final class ConsoleRecentFiltersStore: ObservableObject {
private let mode: ConsoleMode
/// Most-recent first.
@Published package private(set) var recents: [ConsoleRecentFilter] = []
private static let limit = 10
package init(mode: ConsoleMode) {
self.mode = mode
self.recents = decode([ConsoleRecentFilter].self, from: UserDefaults.standard.string(forKey: storeKey) ?? "[]") ?? []
}
/// Creates a store pre-populated with the given entries. Does **not** read
/// from or write to `UserDefaults` intended for previews and tests.
init(mode: ConsoleMode, recents: [ConsoleRecentFilter]) {
self.mode = mode
self.recents = recents
}
private var storeKey: String { "\(mode.rawValue)-recent-filters" }
/// Saves the given filters as the most recent entry. No-ops when the
/// filters are equivalent to the empty default, or when they would be a
/// duplicate of an existing entry.
package func save(_ filters: ConsoleFilters) {
if filters.isDefault { return }
// Move to front if an equivalent entry already exists, refreshing timestamp.
if let existingIndex = recents.firstIndex(where: { $0.filters == filters }) {
var existing = recents.remove(at: existingIndex)
existing.lastUsedDate = Date()
recents.insert(existing, at: 0)
} else {
recents.insert(ConsoleRecentFilter(filters: filters), at: 0)
}
persist()
}
package func remove(_ entry: ConsoleRecentFilter) {
recents.removeAll { $0.id == entry.id }
persist()
}
package func clear() {
recents = []
persist()
}
private func persist() {
while recents.count > Self.limit {
recents.removeLast()
}
UserDefaults.standard.set(encode(recents) ?? "[]", forKey: storeKey)
}
}
private func encode<T: Encodable>(_ value: T) -> String? {
(try? JSONEncoder().encode(value)).flatMap {
String(data: $0, encoding: .utf8)
}
}
private func decode<T: Decodable>(_ type: T.Type, from string: String) -> T? {
string.data(using: .utf8).flatMap {
try? JSONDecoder().decode(type, from: $0)
}
}
#endif
@@ -0,0 +1,105 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(visionOS)
import SwiftUI
import Pulse
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
struct ConsoleRecentFiltersListView: View {
@ObservedObject var store: ConsoleRecentFiltersStore
let mode: ConsoleMode
var onSelect: (ConsoleRecentFilter) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
content
.navigationTitle("Recent Filters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
ButtonClose()
}
ToolbarItem(placement: .topBarTrailing) {
Button("Clear All", role: .destructive) {
store.clear()
dismiss()
}
.disabled(store.recents.isEmpty)
}
}
}
}
@ViewBuilder
private var content: some View {
if store.recents.isEmpty {
ContentUnavailableView(
"No Recent Filters",
systemImage: "line.3.horizontal.decrease.circle",
description: Text("Filters you apply in the console will appear here.")
)
} else {
List {
ForEach(store.recents) { entry in
Button(action: { apply(entry) }) {
row(for: entry)
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
store.remove(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
.contextMenu {
Button(role: .destructive) {
store.remove(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
}
}
private func row(for entry: ConsoleRecentFilter) -> some View {
HStack(spacing: 10) {
Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(entry.filters.summary(for: mode) ?? "Filters")
.foregroundStyle(.primary)
.lineLimit(1)
Text(subtitle(for: entry))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
.contentShape(Rectangle())
}
private func subtitle(for entry: ConsoleRecentFilter) -> String {
let count = entry.filters.activeFilterCount(for: mode)
let filters = count == 1 ? "1 filter" : "\(count) filters"
let date = entry.lastUsedDate.formatted(.relative(presentation: .named))
return "\(filters) · \(date)"
}
private func apply(_ entry: ConsoleRecentFilter) {
onSelect(entry)
dismiss()
}
}
#endif
@@ -1,71 +0,0 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
/// Filter the logs displayed in the console.
struct ConsoleFilters: Hashable {
var shared = Shared()
var messages = Messages()
var network = Network()
struct Shared: Hashable {
var sessions = Sessions()
var dates = Dates()
}
struct Messages: Hashable {
var logLevels = LogLevels()
var labels = Labels()
}
struct Network: Hashable {
var host = Host()
var url = URL()
}
}
protocol ConsoleFilterProtocol: Hashable {
init() // Initializes with the default values
}
extension ConsoleFilters {
struct Sessions: Hashable, ConsoleFilterProtocol {
var selection: Set<UUID> = []
}
struct Dates: Hashable, ConsoleFilterProtocol {
var startDate: Date?
var endDate: Date?
static var today: Dates {
Dates(startDate: Calendar.current.startOfDay(for: Date()))
}
static var recent: Dates {
Dates(startDate: Date().addingTimeInterval(-1200))
}
}
struct LogLevels: ConsoleFilterProtocol {
var levels: Set<LoggerStore.Level> = Set(LoggerStore.Level.allCases)
.subtracting([LoggerStore.Level.trace])
}
struct Labels: ConsoleFilterProtocol {
var hidden: Set<String> = []
var focused: String?
}
struct Host: ConsoleFilterProtocol {
var hidden: Set<String> = []
var focused: String?
}
struct URL: ConsoleFilterProtocol {
var hidden: Set<String> = []
var focused: String?
}
}
@@ -1,86 +0,0 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
import CoreData
extension ConsoleFilters {
static func makeMessagePredicates(
criteria: ConsoleFilters,
isOnlyErrors: Bool
) -> NSPredicate? {
var predicates = [NSPredicate]()
if isOnlyErrors {
predicates.append(NSPredicate(format: "level IN %@", [LoggerStore.Level.critical, .error].map { $0.rawValue }))
}
predicates += makePredicates(for: criteria.shared)
predicates += makePredicates(for: criteria.messages)
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
static func makeNetworkPredicates(
criteria: ConsoleFilters,
isOnlyErrors: Bool
) -> NSPredicate? {
var predicates = [NSPredicate]()
if isOnlyErrors {
predicates.append(NSPredicate(format: "requestState == %d", NetworkTaskEntity.State.failure.rawValue))
}
predicates += makePredicates(for: criteria.shared, isNetwork: true)
predicates += makePredicates(for: criteria.network)
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
}
private func makePredicates(for criteria: ConsoleFilters.Shared, isNetwork: Bool = false) -> [NSPredicate] {
var predicates = [NSPredicate]()
if !criteria.sessions.selection.isEmpty {
predicates.append(NSPredicate(format: "session IN %@", criteria.sessions.selection))
}
if let startDate = criteria.dates.startDate {
predicates.append(NSPredicate(format: "createdAt >= %@", startDate as NSDate))
}
if let endDate = criteria.dates.endDate {
predicates.append(NSPredicate(format: "createdAt <= %@", endDate as NSDate))
}
return predicates
}
private func makePredicates(for criteria: ConsoleFilters.Messages) -> [NSPredicate] {
var predicates = [NSPredicate]()
if criteria.logLevels.levels.count != LoggerStore.Level.allCases.count {
predicates.append(NSPredicate(format: "level IN %@", Array(criteria.logLevels.levels.map { $0.rawValue })))
}
if let focusedLabel = criteria.labels.focused {
predicates.append(NSPredicate(format: "label == %@", focusedLabel))
} else if !criteria.labels.hidden.isEmpty {
predicates.append(NSPredicate(format: "NOT label IN %@", Array(criteria.labels.hidden)))
}
return predicates
}
private func makePredicates(for criteria: ConsoleFilters.Network) -> [NSPredicate] {
var predicates = [NSPredicate]()
if let focusedHost = criteria.host.focused {
predicates.append(NSPredicate(format: "host == %@", focusedHost))
} else if !criteria.host.hidden.isEmpty {
predicates.append(NSPredicate(format: "NOT host IN %@", Array(criteria.host.hidden)))
}
if let focusedURL = criteria.url.focused {
predicates.append(NSPredicate(format: "url == %@", focusedURL))
} else if !criteria.url.hidden.isEmpty {
predicates.append(NSPredicate(format: "NOT url IN %@", Array(criteria.url.hidden)))
}
return predicates
}
@@ -0,0 +1,207 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
/// A user-defined filter that matches console entries (log messages or network
/// tasks) against a text value using configurable search options.
///
/// Each filter targets a specific ``Field``, which maps to a Core Data attribute
/// on `LoggerMessageEntity` or `NetworkTaskEntity`. Use ``makePredicate()`` to
/// produce an `NSPredicate` for use in a fetch request, or ``matches(string:)``
/// for in-memory filtering.
package struct ConsoleCustomFilter: Hashable, Identifiable {
package let id = UUID()
/// The entity field to match against (e.g. `.message`, `.url`).
package var field: Field
/// How the ``value`` is compared to the field (kind, case sensitivity, rule).
package var match: StringSearchOptions
/// When `true`, the predicate is inverted the filter passes entries that
/// do *not* match the value.
package var isNegated: Bool = false
/// The text to search for.
package var value: String
/// Whether this filter participates in the active filter set.
package var isEnabled: Bool = true
package init(field: Field, match: StringSearchOptions = .default, isNegated: Bool = false, value: String, isEnabled: Bool = true) {
self.field = field
self.match = match
self.isNegated = isNegated
self.value = value
self.isEnabled = isEnabled
}
/// A short human-readable label for the targeted field (e.g. `"URL"`).
package var fieldTitle: String { field.title }
/// A short human-readable description of the match rule, optionally
/// prefixed with `"Not "` when the filter is negated.
package var matchTitle: String { (isNegated ? "Not " : "") + match.title }
/// Returns a copy of this filter with a new ``id``.
package func duplicated() -> ConsoleCustomFilter {
ConsoleCustomFilter(field: field, match: match, isNegated: isNegated, value: value, isEnabled: isEnabled)
}
package static func == (lhs: ConsoleCustomFilter, rhs: ConsoleCustomFilter) -> Bool {
(lhs.field, lhs.match, lhs.isNegated, lhs.value, lhs.isEnabled) == (rhs.field, rhs.match, rhs.isNegated, rhs.value, rhs.isEnabled)
}
package func hash(into hasher: inout Hasher) {
field.hash(into: &hasher)
match.hash(into: &hasher)
isNegated.hash(into: &hasher)
value.hash(into: &hasher)
isEnabled.hash(into: &hasher)
}
/// Returns an `NSPredicate` suitable for a Core Data fetch request on the
/// entity that owns ``field``. When ``isNegated`` is `true`, the predicate
/// is wrapped in `NSCompoundPredicate(notPredicateWithSubpredicate:)`.
package func makePredicate() -> NSPredicate {
let predicate = match.predicate(key: field.key, value: value)
return isNegated ? NSCompoundPredicate(notPredicateWithSubpredicate: predicate) : predicate
}
/// Returns `true` when `string` satisfies the filter's match options.
/// Applies negation when ``isNegated`` is `true`.
///
/// Use this for in-memory matching where a Core Data fetch is not involved.
package func matches(string: String) -> Bool {
let result = match.matches(string, value: value)
return isNegated ? !result : result
}
}
// MARK: - Codable
// `id` is a per-instance UUID used only for SwiftUI list identity. It must not
// round-trip through JSON, otherwise re-applying a recent filter would clash
// with the live one. Custom Codable regenerates `id` on decode.
extension ConsoleCustomFilter: Codable {
private enum CodingKeys: String, CodingKey {
case field, match, isNegated, value, isEnabled
}
package init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
field: try container.decode(Field.self, forKey: .field),
match: try container.decode(StringSearchOptions.self, forKey: .match),
isNegated: try container.decode(Bool.self, forKey: .isNegated),
value: try container.decode(String.self, forKey: .value),
isEnabled: try container.decode(Bool.self, forKey: .isEnabled)
)
}
package func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(field, forKey: .field)
try container.encode(match, forKey: .match)
try container.encode(isNegated, forKey: .isNegated)
try container.encode(value, forKey: .value)
try container.encode(isEnabled, forKey: .isEnabled)
}
}
// MARK: - Field
extension ConsoleCustomFilter {
/// Identifies which entity attribute a ``ConsoleCustomFilter`` targets.
///
/// ``title`` is shown in the UI; ``key`` is the Core Data attribute name
/// passed to `StringSearchOptions.predicate(key:value:)`.
package struct Field: Hashable, Codable {
package var title: String
package var key: String
package init(title: String, key: String) {
self.title = title
self.key = key
}
}
/// A titled group of fields for display in a picker with visual separators.
package struct FieldGroup: Hashable {
package var title: String?
package var fields: [Field]
package init(title: String? = nil, fields: [Field]) {
self.title = title
self.fields = fields
}
}
}
// MARK: - Message Fields
extension ConsoleCustomFilter {
/// Ordered list of fields available when filtering `LoggerMessageEntity`.
package static let messageFields: [Field] = [.level, .label, .message, .metadata, .file]
/// Message fields wrapped in a single group (no section header needed).
package static let messageFieldGroups: [FieldGroup] = [
FieldGroup(fields: messageFields)
]
package var availableFieldGroups: [FieldGroup] {
if Self.messageFields.contains(field) {
return Self.messageFieldGroups
} else {
return Self.networkFieldGroups
}
}
package static func defaultMessageFilter() -> ConsoleCustomFilter {
ConsoleCustomFilter(field: .message, match: .default, value: "")
}
}
extension ConsoleCustomFilter.Field {
/// Matches `LoggerMessageEntity.level` (stored as a raw `Int16`).
package static let level = ConsoleCustomFilter.Field(title: "Level", key: "level")
package static let label = ConsoleCustomFilter.Field(title: "Label", key: "label")
/// Matches `LoggerMessageEntity.text`.
package static let message = ConsoleCustomFilter.Field(title: "Message", key: "text")
/// Matches `LoggerMessageEntity.rawMetadata` (newline-separated `key: value` pairs).
package static let metadata = ConsoleCustomFilter.Field(title: "Metadata", key: "rawMetadata")
package static let file = ConsoleCustomFilter.Field(title: "File", key: "file")
}
// MARK: - Network Fields
extension ConsoleCustomFilter {
/// Network fields organized into groups for the field picker.
package static let networkFieldGroups: [FieldGroup] = [
FieldGroup(title: "URL", fields: [.url, .host, .path]),
FieldGroup(fields: [.method, .statusCode, .errorCode, .errorDomain]),
FieldGroup(title: "Advanced", fields: [.taskDescription, .requestHeader, .responseHeader])
]
/// All network fields (flattened from groups).
package static let allNetworkFields: [Field] = networkFieldGroups.flatMap(\.fields)
package static func defaultNetworkFilter() -> ConsoleCustomFilter {
ConsoleCustomFilter(field: .url, match: .default, value: "")
}
}
extension ConsoleCustomFilter.Field {
package static let url = ConsoleCustomFilter.Field(title: "URL", key: "url")
package static let host = ConsoleCustomFilter.Field(title: "Host", key: "host")
/// Matches `NetworkTaskEntity.path`.
package static let path = ConsoleCustomFilter.Field(title: "Path", key: "path")
package static let method = ConsoleCustomFilter.Field(title: "Method", key: "httpMethod")
/// Matches `NetworkTaskEntity.statusCode` (stored as `Int32`).
package static let statusCode = ConsoleCustomFilter.Field(title: "Status Code", key: "statusCode")
/// Matches `NetworkTaskEntity.errorCode` (stored as `Int32`).
package static let errorCode = ConsoleCustomFilter.Field(title: "Error Code", key: "errorCode")
package static let errorDomain = ConsoleCustomFilter.Field(title: "Error Domain", key: "errorDomain")
/// Matches `NetworkTaskEntity.taskDescription`.
package static let taskDescription = ConsoleCustomFilter.Field(title: "Task Description", key: "taskDescription")
/// Matches headers on `NetworkRequestEntity` via a keypath predicate.
package static let requestHeader = ConsoleCustomFilter.Field(title: "Request Headers", key: "originalRequest.httpHeaders")
/// Matches headers on `NetworkResponseEntity` via a keypath predicate.
package static let responseHeader = ConsoleCustomFilter.Field(title: "Response Headers", key: "response.httpHeaders")
}
@@ -0,0 +1,197 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
import CoreData
extension ConsoleFilters {
package static func makeMessagePredicates(
criteria: ConsoleFilters,
sessions: Set<UUID>,
isOnlyErrors: Bool
) -> NSPredicate? {
var predicates = [NSPredicate]()
if isOnlyErrors {
predicates.append(NSPredicate(format: "level IN %@", [LoggerStore.Level.critical, .error].map { $0.rawValue }))
}
predicates += makePredicates(for: criteria.shared, sessions: sessions)
predicates += makePredicates(for: criteria.messages)
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
package static func makeNetworkPredicates(
criteria: ConsoleFilters,
sessions: Set<UUID>,
isOnlyErrors: Bool
) -> NSPredicate? {
var predicates = [NSPredicate]()
if isOnlyErrors {
predicates.append(NSPredicate(format: "requestState == %d", NetworkTaskEntity.State.failure.rawValue))
}
predicates += makePredicates(for: criteria.shared, sessions: sessions, isNetwork: true)
predicates += makePredicates(for: criteria.network)
return predicates.isEmpty ? nil : NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
}
private func makePredicates(for criteria: ConsoleFilters.Shared, sessions: Set<UUID>, isNetwork: Bool = false) -> [NSPredicate] {
var predicates = [NSPredicate]()
if !sessions.isEmpty {
predicates.append(NSPredicate(format: "session IN %@", sessions))
}
if criteria.dates.isEnabled {
if let startDate = criteria.dates.startDate {
predicates.append(NSPredicate(format: "createdAt >= %@", startDate as NSDate))
}
if let endDate = criteria.dates.endDate {
predicates.append(NSPredicate(format: "createdAt <= %@", endDate as NSDate))
}
}
return predicates
}
private func makePredicates(for criteria: ConsoleFilters.Messages) -> [NSPredicate] {
var predicates = [NSPredicate]()
if criteria.logLevels.isEnabled {
if criteria.logLevels.levels.count != LoggerStore.Level.allCases.count {
predicates.append(NSPredicate(format: "level IN %@", Array(criteria.logLevels.levels.map { $0.rawValue })))
}
}
if criteria.labels.isEnabled {
if let focusedLabel = criteria.labels.focused {
predicates.append(NSPredicate(format: "label == %@", focusedLabel))
} else if !criteria.labels.hidden.isEmpty {
predicates.append(NSPredicate(format: "NOT label IN %@", Array(criteria.labels.hidden)))
}
}
return predicates + makeStandalonePredicates(for: criteria)
}
private func makePredicates(for criteria: ConsoleFilters.Network) -> [NSPredicate] {
var predicates = [NSPredicate]()
if criteria.host.isEnabled {
if let focusedHost = criteria.host.focused {
predicates.append(NSPredicate(format: "host == %@", focusedHost))
} else if !criteria.host.hidden.isEmpty {
predicates.append(NSPredicate(format: "NOT host IN %@", Array(criteria.host.hidden)))
}
}
if criteria.url.isEnabled {
if let focusedURL = criteria.url.focused {
predicates.append(NSPredicate(format: "url == %@", focusedURL))
} else if !criteria.url.hidden.isEmpty {
predicates.append(NSPredicate(format: "NOT url IN %@", Array(criteria.url.hidden)))
}
}
return predicates + makeStandalonePredicates(for: criteria)
}
private func makeStandalonePredicates(for criteria: ConsoleFilters.Messages) -> [NSPredicate] {
if criteria.custom.isEnabled {
let filterPredicates = criteria.custom.filters
.filter { !$0.value.isEmpty && $0.isEnabled }
.map { $0.makePredicate() }
if !filterPredicates.isEmpty {
switch criteria.custom.logicalOperator {
case .and:
return filterPredicates
case .or:
return [NSCompoundPredicate(orPredicateWithSubpredicates: filterPredicates)]
}
}
}
return []
}
private func makeStandalonePredicates(for criteria: ConsoleFilters.Network) -> [NSPredicate] {
var predicates: [NSPredicate] = []
if criteria.response.isEnabled {
if let value = criteria.response.responseSize.byteCountRange.lowerBound {
predicates.append(NSPredicate(format: "responseBodySize >= %d", value))
}
if let value = criteria.response.responseSize.byteCountRange.upperBound {
predicates.append(NSPredicate(format: "responseBodySize <= %d", value))
}
if let value = Int(criteria.response.statusCode.range.lowerBound) {
predicates.append(NSPredicate(format: "statusCode >= %d", value))
}
if let value = Int(criteria.response.statusCode.range.upperBound) {
predicates.append(NSPredicate(format: "statusCode <= %d", value))
}
if let value = criteria.response.duration.durationRange.lowerBound {
predicates.append(NSPredicate(format: "duration >= %f", value))
}
if let value = criteria.response.duration.durationRange.upperBound {
predicates.append(NSPredicate(format: "duration <= %f", value))
}
switch criteria.response.contentType.contentType {
case .any: break
default: predicates.append(NSPredicate(format: "responseContentType CONTAINS %@", criteria.response.contentType.contentType.rawValue))
}
}
if criteria.request.isEnabled {
if case .some(let method) = criteria.request.httpMethod {
predicates.append(NSPredicate(format: "httpMethod ==[c] %@", method.rawValue))
}
if let value = criteria.request.requestSize.byteCountRange.lowerBound {
predicates.append(NSPredicate(format: "requestBodySize >= %d", value))
}
if let value = criteria.request.requestSize.byteCountRange.upperBound {
predicates.append(NSPredicate(format: "requestBodySize <= %d", value))
}
}
if criteria.networking.isEnabled {
if criteria.networking.isRedirect {
predicates.append(NSPredicate(format: "redirectCount >= 1"))
}
switch criteria.networking.source {
case .any:
break
case .network:
predicates.append(NSPredicate(format: "isFromCache == NO"))
case .cache:
predicates.append(NSPredicate(format: "isFromCache == YES"))
}
if case .some(let taskType) = criteria.networking.taskType {
predicates.append(NSPredicate(format: "taskType == %i", taskType.rawValue))
}
switch criteria.networking.requestState {
case .any:
break
case .pending:
predicates.append(NSPredicate(format: "requestState == %d", NetworkTaskEntity.State.pending.rawValue))
case .success:
predicates.append(NSPredicate(format: "requestState == %d", NetworkTaskEntity.State.success.rawValue))
case .failure:
predicates.append(NSPredicate(format: "requestState == %d", NetworkTaskEntity.State.failure.rawValue))
}
}
if criteria.custom.isEnabled {
let filterPredicates = criteria.custom.filters
.filter { !$0.value.isEmpty && $0.isEnabled }
.map { $0.makePredicate() }
if !filterPredicates.isEmpty {
switch criteria.custom.logicalOperator {
case .and:
predicates.append(contentsOf: filterPredicates)
case .or:
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: filterPredicates))
}
}
}
return predicates
}
@@ -0,0 +1,518 @@
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
import CoreData
import Combine
/// Filter the logs displayed in the console.
package struct ConsoleFilters: Hashable, Codable {
package var shared = Shared()
package var messages = Messages()
package var network = Network()
package init() {}
package struct Shared: Hashable, Codable {
package var dates = Dates()
package init() {}
}
package struct Messages: Hashable, Codable {
package var logLevels = LogLevels()
package var labels = Labels()
package var custom = CustomMessageFilters()
package init() {}
}
package struct Network: Hashable, Codable {
package var host = Host()
package var url = URL()
package var custom = CustomNetworkFilters()
package var response = Response()
package var request = Request()
package var networking = Networking()
package init() {}
}
}
package protocol ConsoleFilterProtocol: Hashable, Codable {
init()
var isDefault: Bool { get }
var title: String { get }
var description: String? { get }
}
extension ConsoleFilterProtocol {
package var isDefault: Bool { self == Self() }
}
package protocol ConsoleFilterGroupProtocol: ConsoleFilterProtocol {
var isEnabled: Bool { get set }
}
extension ConsoleFilters {
package struct Dates: Hashable, Codable, ConsoleFilterGroupProtocol {
package var isEnabled = true
package var startDate: Date?
package var endDate: Date?
package init() {}
private init(startDate: Date, endDate: Date? = nil) {
self.startDate = startDate
self.endDate = endDate
}
package static var last30Minutes: Dates {
Dates(startDate: Date().addingTimeInterval(-1800))
}
package static var lastHour: Dates {
Dates(startDate: Date().addingTimeInterval(-3600))
}
package static var today: Dates {
Dates(startDate: Calendar.current.startOfDay(for: Date()))
}
package static var yesterday: Dates {
let calendar = Calendar.current
let startOfToday = calendar.startOfDay(for: Date())
let startOfYesterday = calendar.date(byAdding: .day, value: -1, to: startOfToday)!
return Dates(startDate: startOfYesterday, endDate: startOfToday)
}
}
package struct LogLevels: Codable, ConsoleFilterGroupProtocol {
package var isEnabled = true
package var levels: Set<LoggerStore.Level> = Set(LoggerStore.Level.allCases)
.subtracting([LoggerStore.Level.trace])
package init() {}
}
package struct Labels: Codable, ConsoleFilterGroupProtocol {
package var isEnabled = true
package var hidden: Set<String> = []
package var focused: String?
package init() {}
}
package struct Host: Codable, ConsoleFilterGroupProtocol {
package var isEnabled = true
package var hidden: Set<String> = []
package var focused: String?
package init() {}
}
package struct URL: Codable, ConsoleFilterGroupProtocol {
package var isEnabled = true
package var hidden: Set<String> = []
package var focused: String?
package init() {}
}
}
package enum ConsoleFilterLogicalOperator: Hashable, Codable {
case and
case or
}
extension ConsoleFilters {
package struct CustomMessageFilters: Codable, ConsoleFilterGroupProtocol {
package var isEnabled = true
package var filters: [ConsoleCustomFilter] = [.defaultMessageFilter()]
package var logicalOperator: ConsoleFilterLogicalOperator = .and
package init() {}
}
package struct CustomNetworkFilters: Codable, ConsoleFilterGroupProtocol {
package var isEnabled = true
package var filters: [ConsoleCustomFilter] = [.defaultNetworkFilter()]
package var logicalOperator: ConsoleFilterLogicalOperator = .and
package init() {}
}
package struct Response: Codable, ConsoleFilterGroupProtocol {
package var isEnabled = true
package var statusCode = StatusCode()
package var contentType = ContentType()
package var responseSize = ResponseSize()
package var duration = Duration()
package init() {}
}
package struct StatusCode: Hashable, Codable, ConsoleFilterProtocol {
package var range: ValuesRange<String> = .empty
package init() {}
}
package struct ResponseSize: Hashable, Codable, ConsoleFilterProtocol {
package var range: ValuesRange<String> = .empty
package var unit: MeasurementUnit = .kilobytes
package var byteCountRange: ValuesRange<Int64?> {
ValuesRange(lowerBound: byteCount(from: range.lowerBound),
upperBound: byteCount(from: range.upperBound))
}
private func byteCount(from string: String) -> Int64? {
Int64(string).map { $0 * unit.multiplier }
}
package enum MeasurementUnit: Identifiable, CaseIterable, Codable {
case bytes, kilobytes, megabytes
package var title: String {
switch self {
case .bytes: return "Bytes"
case .kilobytes: return "KB"
case .megabytes: return "MB"
}
}
package var multiplier: Int64 {
switch self {
case .bytes: return 1
case .kilobytes: return 1024
case .megabytes: return 1024 * 1024
}
}
package var id: MeasurementUnit { self }
}
package init() {}
}
package struct Duration: Hashable, Codable, ConsoleFilterProtocol {
package var range: ValuesRange<String> = .empty
package var unit: Unit = .seconds
package var durationRange: ValuesRange<TimeInterval?> {
ValuesRange(lowerBound: TimeInterval(range.lowerBound).map(unit.convert),
upperBound: TimeInterval(range.upperBound).map(unit.convert))
}
package enum Unit: Identifiable, CaseIterable, Codable {
case minutes
case seconds
case milliseconds
package var title: String {
switch self {
case .minutes: return "Min"
case .seconds: return "Sec"
case .milliseconds: return "ms"
}
}
package func convert(_ value: TimeInterval) -> TimeInterval {
switch self {
case .minutes: return value * 60
case .seconds: return value
case .milliseconds: return value / 1000
}
}
package var id: Unit { self }
}
package init() {}
}
package struct ContentType: Hashable, Codable, ConsoleFilterProtocol {
package var contentType = ContentType.any
package init() {}
package enum ContentType: String, CaseIterable, Codable {
// common
case any = ""
case json = "application/json"
case plain = "text/plain"
case html = "text/html"
// uncommon
case javascript = "application/javascript"
case css = "text/css"
case csv = "text/csv"
case xml = "text/xml"
case pdf = "application/pdf"
// image
case gif = "image/gif"
case jpeg = "image/jpeg"
case png = "image/png"
case webp = "image/webp"
case anyImage = "image/"
// video
case anyVideo = "video/"
}
}
package struct Request: Codable, ConsoleFilterGroupProtocol {
package var isEnabled = true
package var httpMethod: HTTPMethodFilter = .any
package var requestSize = ResponseSize()
package init() {}
package enum HTTPMethodFilter: Hashable, Codable {
case any
case some(HTTPMethod)
}
}
package struct Networking: Codable, ConsoleFilterGroupProtocol {
package var isEnabled = true
package var isRedirect = false
package var source: Source = .any
package var taskType: TaskType = .any
package var requestState: RequestState = .any
package init() {}
package enum Source: CaseIterable, Codable {
case any
case network
case cache
package var title: String {
switch self {
case .any: return "Any"
case .cache: return "Cache"
case .network: return "Network"
}
}
}
package enum TaskType: Hashable, Codable {
case any
case some(NetworkLogger.TaskType)
}
package enum RequestState: Hashable, CaseIterable, Codable {
case any
case pending
case success
case failure
package var title: String {
switch self {
case .any: return "Any"
case .pending: return "Pending"
case .success: return "Success"
case .failure: return "Failure"
}
}
}
}
}
// MARK: - Filter Titles & Descriptions
extension ConsoleFilters.Dates {
package var title: String { "Time Period" }
package var description: String? { nil }
}
extension ConsoleFilters.LogLevels {
package var title: String { "Log Levels" }
package var description: String? {
let defaultLevels = Self().levels
guard levels != defaultLevels else { return nil }
if levels.count == 1, let only = levels.first {
return "\(only.name) only"
} else if levels.isEmpty {
return "no levels"
}
return "\(levels.count) levels"
}
}
extension ConsoleFilters.Labels {
package var title: String { "Labels" }
package var description: String? {
if let label = focused, !label.isEmpty {
return label
} else if !hidden.isEmpty {
return "\(hidden.count) label\(hidden.count == 1 ? "" : "s")"
}
return nil
}
}
extension ConsoleFilters.Host {
package var title: String { "Hosts" }
package var description: String? {
if let host = focused, !host.isEmpty {
return host
} else if !hidden.isEmpty {
return "\(hidden.count) host\(hidden.count == 1 ? "" : "s")"
}
return nil
}
}
extension ConsoleFilters.URL {
package var title: String { "URL" }
package var description: String? {
if let url = focused, !url.isEmpty {
return url
} else if !hidden.isEmpty {
return "\(hidden.count) URL\(hidden.count == 1 ? "" : "s")"
}
return nil
}
}
extension ConsoleFilters.CustomMessageFilters {
package var title: String { "Custom Filters" }
package var description: String? {
let active = filters.filter { !$0.value.isEmpty }
guard let first = active.first else { return nil }
return "\(first.fieldTitle): \(first.value)"
}
}
extension ConsoleFilters.CustomNetworkFilters {
package var title: String { "Custom Filters" }
package var description: String? {
let active = filters.filter { !$0.value.isEmpty }
guard let first = active.first else { return nil }
return "\(first.fieldTitle): \(first.value)"
}
}
extension ConsoleFilters.StatusCode {
package var title: String { "Status Code" }
package var description: String? {
switch (range.lowerBound.isEmpty, range.upperBound.isEmpty) {
case (false, false): return "\(range.lowerBound)\(range.upperBound)"
case (false, true): return "\(range.lowerBound)"
case (true, false): return "\(range.upperBound)"
case (true, true): return nil
}
}
}
extension ConsoleFilters.ContentType {
package var title: String { "Content Type" }
package var description: String? {
contentType != .any ? contentType.rawValue : nil
}
}
extension ConsoleFilters.ResponseSize {
package var title: String { "Size" }
package var description: String? {
switch (range.lowerBound.isEmpty, range.upperBound.isEmpty) {
case (false, false): return "\(range.lowerBound)\(range.upperBound) \(unit.title)"
case (false, true): return "\(range.lowerBound) \(unit.title)"
case (true, false): return "\(range.upperBound) \(unit.title)"
case (true, true): return nil
}
}
}
extension ConsoleFilters.Duration {
package var title: String { "Duration" }
package var description: String? {
switch (range.lowerBound.isEmpty, range.upperBound.isEmpty) {
case (false, false): return "\(range.lowerBound)\(range.upperBound) \(unit.title)"
case (false, true): return "\(range.lowerBound) \(unit.title)"
case (true, false): return "\(range.upperBound) \(unit.title)"
case (true, true): return nil
}
}
}
extension ConsoleFilters.Response {
package var title: String { "Response" }
package var description: String? {
let parts = [statusCode.description, contentType.description, responseSize.description, duration.description].compactMap { $0 }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
}
extension ConsoleFilters.Request {
package var title: String { "Request" }
package var description: String? {
if case .some(let method) = httpMethod {
return method.rawValue
}
return nil
}
}
extension ConsoleFilters.Networking {
package var title: String { "Networking" }
package var description: String? {
if requestState != .any {
return requestState.title
}
return nil
}
}
// MARK: - Summary
extension ConsoleFilters {
/// The all-defaults instance, cached so callers comparing against the
/// empty state don't pay the allocation cost on every check.
private static let empty = ConsoleFilters()
/// `true` when every section equals the all-defaults instance.
package var isDefault: Bool { self == Self.empty }
/// The mode-relevant filter groups, excluding sessions (context-specific).
package func filters(for mode: ConsoleMode) -> [any ConsoleFilterProtocol] {
var result: [any ConsoleFilterProtocol]
if mode == .network {
result = [network.custom, network.response.statusCode, network.response.contentType, network.response.responseSize, network.response.duration, network.request, network.host, network.url, network.networking]
} else {
result = [messages.custom, messages.logLevels, messages.labels]
}
result.append(shared.dates)
return result
}
/// Number of filter sections that differ from defaults for the given mode.
package func activeFilterCount(for mode: ConsoleMode) -> Int {
filters(for: mode).filter { !$0.isDefault }.count
}
/// A short, mode-aware summary of the active filters, suitable for a recent
/// filter chip. Returns `nil` when nothing meaningful is active.
package func summary(for mode: ConsoleMode) -> String? {
let segments = filters(for: mode).compactMap { $0.description }
guard !segments.isEmpty else { return nil }
let head = segments.prefix(3).joined(separator: " · ")
let extra = segments.count > 3 ? " +\(segments.count - 2)" : ""
return head + extra
}
/// A longer-form description listing every active predicate, used as a
/// subtitle in the recent filters list. Returns `nil` when there are no
/// extra segments beyond the summary.
package func detail(for mode: ConsoleMode) -> String? {
let segments = filters(for: mode).compactMap { $0.description }
guard segments.count > 1 else { return nil }
return segments.joined(separator: " · ")
}
}

Some files were not shown because too many files have changed in this diff Show More