├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .scripts ├── build.sh ├── install_swiftlint.sh ├── lint.sh ├── test.sh └── validate.sh ├── CHANGELOG.md ├── Demo ├── Pulse.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ ├── Pulse Demo iOS.xcscheme │ │ ├── Pulse Demo tvOS.xcscheme │ │ └── Pulse Integration Examples iOS.xcscheme └── Sources │ ├── Integrations │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── Info.plist │ ├── IntegrationsExamples │ │ ├── AlamofireIntegration.swift │ │ ├── DebugAnalyticsView.swift │ │ ├── MoyaIntegration.swift │ │ ├── URLSessionAutomatedIntegration.swift │ │ └── URLSessionManualIntegration.swift │ ├── Package.swift │ ├── SceneDelegate.swift │ └── ViewController.swift │ ├── Package.swift │ ├── Pulse-Demo-iOS-Info.plist │ ├── Pulse-Demo-macOS-Info.plist │ ├── Resources │ └── repos.json │ ├── Shared │ ├── MockStore.swift │ └── MockTask.swift │ ├── iOS-paired │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── 1024.png │ │ │ └── Contents.json │ │ └── Contents.json │ └── Pulse_DemoApp.swift │ ├── iOS │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── 1024.png │ │ │ └── Contents.json │ │ └── Contents.json │ └── Pulse_Demo_iOSApp.swift │ └── tvOS │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── App Icon & Top Shelf Image.brandassets │ │ ├── App Icon - App Store.imagestack │ │ │ ├── Back.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── Front.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ └── Middle.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ ├── App Icon.imagestack │ │ │ ├── Back.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── Front.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ └── Middle.imagestacklayer │ │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Top Shelf Image Wide.imageset │ │ │ └── Contents.json │ │ └── Top Shelf Image.imageset │ │ │ └── Contents.json │ └── Contents.json │ ├── ContentView.swift │ ├── Info.plist │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── Pulse_tvOSApp.swift ├── LICENSE.md ├── Package.swift ├── PulseCore.podspec ├── PulseUI.podspec ├── README.md └── Sources ├── Pulse ├── Helpers │ ├── CoreData+Extensions.swift │ ├── Helpers.swift │ ├── ImageProcessor.swift │ ├── Keychain.swift │ ├── LoggerSession.swift │ ├── Mutex.swift │ ├── PulseDocument.swift │ └── Regex.swift ├── LoggerStore │ ├── LoggerStore+Configuration.swift │ ├── LoggerStore+Entities.swift │ ├── LoggerStore+Event.swift │ ├── LoggerStore+Info.swift │ ├── LoggerStore+Level.swift │ ├── LoggerStore+Model.swift │ ├── LoggerStore+Version.swift │ └── LoggerStore.swift ├── NetworkDebugger │ ├── MockingURLProtocol.swift │ └── NetworkDebugger.swift ├── NetworkLogger │ ├── NetworkLogger+Entities.swift │ ├── NetworkLogger+Redacting.swift │ └── NetworkLogger.swift ├── PrivacyInfo.xcprivacy ├── Pulse.docc │ ├── Articles │ │ ├── GettingStarted.md │ │ ├── NetworkLogging-Article.md │ │ └── NextSteps.md │ ├── Extensions │ │ └── LoggerStore-Extension.md │ ├── Pulse.md │ └── Resources │ │ ├── pulse-console.png │ │ ├── pulse-pro.png │ │ ├── pulseui-main.png │ │ └── remote-logging.png ├── RemoteLogger │ ├── RemoteLogger-Connection.swift │ ├── RemoteLogger-Protocol.swift │ └── RemoteLogger.swift └── URLSessionProxy │ ├── URLSessionProtocol.swift │ ├── URLSessionProxy.swift │ ├── URLSessionProxyDelegate+AutomaticRegistration.swift │ └── URLSessionProxyDelegate.swift ├── PulseProxy └── URLSessionSwizzler.swift └── PulseUI ├── Extensions ├── Foundation+Extensions.swift ├── NSAttributedString+Extensions.swift ├── Pulse+Extensions.swift └── SwiftUI+Extensions.swift ├── Features ├── Console │ ├── ConsoleDataSource.swift │ ├── ConsoleEnvironment.swift │ ├── ConsoleView-ios.swift │ ├── ConsoleView-tvos.swift │ ├── ConsoleView-watchos.swift │ ├── ConsoleView.swift │ ├── List │ │ ├── ConsoleListContentView.swift │ │ ├── ConsoleListOptions.swift │ │ ├── ConsoleListView.swift │ │ └── ConsoleListViewModel.swift │ └── Views │ │ ├── ConsoleContextMenu.swift │ │ ├── ConsoleEntityCell.swift │ │ ├── ConsoleHelperViews.swift │ │ ├── ConsoleListDisplaySettings.swift │ │ ├── ConsoleMessageCell.swift │ │ ├── ConsoleRouterView.swift │ │ ├── ConsoleTaskCell.swift │ │ └── ConsoleToolbarView.swift ├── FileViewer │ ├── FileViewer.swift │ ├── FileViewerViewModel.swift │ └── RichTextView │ │ ├── RichTextView-tvos.swift │ │ ├── RichTextView-watchos.swift │ │ ├── RichTextView.swift │ │ ├── RichTextViewModel.swift │ │ ├── RichTextViewSearchToobar-ios.swift │ │ └── WrappedTextView.swift ├── Filters │ ├── Cells │ │ ├── ConsoleSearchLogLevelsCell.swift │ │ ├── ConsoleSearchTimePeriodCell.swift │ │ └── ConsoleSearchToggleCell.swift │ ├── ConsoleFilterViewModel.swift │ ├── ConsoleFiltersView.swift │ ├── Filters │ │ ├── ConsoleFilters.swift │ │ └── ConsoleSearchCriteria+Predicates.swift │ └── Views │ │ ├── ConsoleDomainsSelectionView.swift │ │ ├── ConsoleLabelsSelectionView.swift │ │ ├── ConsoleSearchListSelectionView.swift │ │ ├── ConsoleSearchSectionHeader.swift │ │ ├── ConsoleSection.swift │ │ └── ConsoleSessionsPickerView.swift ├── Inspector │ ├── Cells │ │ ├── NetworkCURLCell.swift │ │ ├── NetworkCookiesCell.swift │ │ ├── NetworkHeadersCell.swift │ │ ├── NetworkMenuCell.swift │ │ ├── NetworkMetricsCell.swift │ │ ├── NetworkRequestBodyCell.swift │ │ ├── NetworkRequestInfoCell.swift │ │ ├── NetworkRequestStatusCell.swift │ │ ├── NetworkRequestStatusSectionView.swift │ │ └── NetworkResponseBodyCell.swift │ ├── NetworkDetailsView.swift │ ├── NetworkInspectorRequestBodyView.swift │ ├── NetworkInspectorResponseBodyView.swift │ ├── NetworkInspectorView-shared.swift │ ├── NetworkInspectorView-tvos.swift │ ├── NetworkInspectorView-watchos.swift │ └── NetworkInspectorView.swift ├── MessageDetails │ ├── ConsoleMessageDetailsView.swift │ └── ConsoleMessageMetadataView.swift ├── Remote │ ├── RemoteLoggerEnterPasswordView.swift │ ├── RemoteLoggerErrorView.swift │ ├── RemoteLoggerSelectedDeviceView.swift │ ├── RemoteLoggerSettingsView.swift │ └── RemoteLoggerSettingsViewModel.swift ├── Search │ ├── ConsoleSearchListContentView.swift │ ├── ConsoleSearchViewModel.swift │ ├── Services │ │ ├── ConsoleSearchOccurrence.swift │ │ ├── ConsoleSearchOperation.swift │ │ ├── ConsoleSearchRecentSearchesStore.swift │ │ ├── ConsoleSearchScope.swift │ │ └── ConsoleSearchTerm.swift │ └── Views │ │ ├── ConsoleSearchContextMenu.swift │ │ ├── ConsoleSearchResultsSectionView.swift │ │ ├── ConsoleSearchSuggestionView.swift │ │ ├── ConsoleSearchSuggestionsView.swift │ │ └── ConsoleSearchToolbar.swift ├── Sessions │ ├── SessionListView.swift │ ├── SessionPickerView.swift │ └── SessionsView.swift └── Settings │ ├── SettingsView-ios.swift │ ├── SettingsView-macos.swift │ ├── SettingsView-tvos.swift │ ├── SettingsView-watchos.swift │ └── StoreDetailsView.swift ├── Helpers ├── DecodingErrorsPreviews.swift ├── FileViewModelContext.swift ├── Formatters.swift ├── HARDocument.swift ├── LoggerStoreIndex.swift ├── ManagedObjectsCountObserver.swift ├── ManagedObjectsObserver.swift ├── Regex.swift ├── ShareItems.swift ├── ShareStoreTask.swift ├── StatusLabelViewModel.swift ├── StringSearchOptions.swift ├── TextHelper.swift ├── TextRenderer.swift ├── TextRendererHTML.swift ├── TextRendererJSON.swift ├── TextUtilities.swift ├── UIKit+Extensions.swift ├── UXKit.swift └── UserSettings.swift ├── Mocks ├── MockStore.swift └── MockTask.swift ├── PrivacyInfo.xcprivacy ├── PulseUI.docc ├── PulseUI.md └── Resources │ └── pulseui-main.png └── Views ├── Checkbox.swift ├── ContextMenus.swift ├── DateRangePicker.swift ├── ImageViewer.swift ├── InfoRow.swift ├── KeyValueSectionViewModel.swift ├── LoggerStoreSizeChart.swift ├── Metrics ├── NetworkInspectorMetricsView.swift ├── NetworkInspectorTransactionView.swift ├── NetworkInspectorTransferInfoView.swift └── TimingViewModel+Metrics.swift ├── PDFRepresentedView.swift ├── PlaceholderView.swift ├── PulseUI+UIKit.swift ├── SectionHeaderView.swift ├── ShareStoreView.swift ├── ShareStoreViewModel.swift ├── ShareView.swift ├── SpinnerView.swift ├── TextView.swift ├── TimingView.swift └── WebView.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: kean 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | .swiftpm/ 6 | Package.resolved -------------------------------------------------------------------------------- /.scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | scheme="Pulse" 4 | 5 | while getopts "d:" opt; do 6 | case $opt in 7 | d) destinations+=("$OPTARG");; 8 | #... 9 | esac 10 | done 11 | shift $((OPTIND -1)) 12 | 13 | echo "destinations = ${destinations[@]}" 14 | 15 | set -o pipefail 16 | xcodebuild -version 17 | 18 | for dest in "${destinations[@]}"; do 19 | echo "Building for destination: $dest" 20 | xcodebuild build -scheme $scheme -destination "$dest" | xcpretty; 21 | if [ $? -ne 0 ]; then 22 | exit $? 23 | fi 24 | done 25 | 26 | exit $? 27 | -------------------------------------------------------------------------------- /.scripts/install_swiftlint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # -L to enable redirects 4 | echo "Installing SwiftLint by downloading a pre-compiled binary" 5 | curl -L 'https://github.com/realm/SwiftLint/releases/download/0.39.1/portable_swiftlint.zip' -o swiftlint.zip 6 | mkdir temp 7 | unzip swiftlint.zip -d temp 8 | rm -f swiftlint.zip 9 | -------------------------------------------------------------------------------- /.scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if which swiftlint >/dev/null; then 4 | swiftlint 5 | fi -------------------------------------------------------------------------------- /.scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eo pipefail 4 | 5 | scheme="Pulse" 6 | 7 | while getopts "s:d:" opt; do 8 | case $opt in 9 | s) scheme=${OPTARG};; 10 | d) destinations+=("$OPTARG");; 11 | #... 12 | esac 13 | done 14 | shift $((OPTIND -1)) 15 | 16 | echo "scheme = ${scheme}" 17 | echo "destinations = ${destinations[@]}" 18 | 19 | xcodebuild -version 20 | 21 | xcodebuild build-for-testing -scheme "$scheme" -destination "${destinations[0]}" | xcpretty 22 | 23 | for destination in "${destinations[@]}"; 24 | do 25 | echo "\nRunning tests for destination: $destination" 26 | xcodebuild test-without-building -scheme "$scheme" -destination "$destination" | xcpretty --test 27 | done 28 | -------------------------------------------------------------------------------- /.scripts/validate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ./temp/swiftlint lint --strict 4 | -------------------------------------------------------------------------------- /Demo/Pulse.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/Pulse.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Demo/Pulse.xcodeproj/xcshareddata/xcschemes/Pulse Demo tvOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 59 | 60 | 66 | 68 | 74 | 75 | 76 | 77 | 79 | 80 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Integrations 4 | // 5 | // Created by Alexander Grebenyuk on 8/21/21. 6 | // Copyright © 2021 kean. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @main 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | // Override point for customization after application launch. 16 | return true 17 | } 18 | 19 | // MARK: UISceneSession Lifecycle 20 | 21 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 22 | // Called when a new scene session is being created. 23 | // Use this method to select a configuration to create the new scene with. 24 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 25 | } 26 | 27 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 28 | // Called when the user discards a scene session. 29 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 30 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "systemIndigoColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | UISceneStoryboardFile 37 | Main 38 | 39 | 40 | 41 | 42 | UIApplicationSupportsIndirectInputEvents 43 | 44 | UILaunchStoryboardName 45 | LaunchScreen 46 | UIMainStoryboardFile 47 | Main 48 | UIRequiredDeviceCapabilities 49 | 50 | armv7 51 | 52 | UISupportedInterfaceOrientations 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationLandscapeLeft 56 | UIInterfaceOrientationLandscapeRight 57 | 58 | UISupportedInterfaceOrientations~ipad 59 | 60 | UIInterfaceOrientationPortrait 61 | UIInterfaceOrientationPortraitUpsideDown 62 | UIInterfaceOrientationLandscapeLeft 63 | UIInterfaceOrientationLandscapeRight 64 | 65 | NSLocalNetworkUsageDescription 66 | Network usage required for debugging purposes 67 | NSBonjourServices 68 | 69 | _pulse._tcp 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/IntegrationsExamples/AlamofireIntegration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlamofireIntegration.swift 3 | // Pulse 4 | // 5 | // Created by Bagus andinata on 21/08/21. 6 | // Copyright © 2021 kean. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | import PulseLogHandler 12 | 13 | typealias Session = Alamofire.Session 14 | 15 | // MARK: - Example Provider 16 | 17 | final class ExampleProvider { 18 | private let eventMonitors: [EventMonitor] 19 | private let logger: NetworkLogger 20 | private let session: Session 21 | 22 | init() { 23 | logger = NetworkLogger() 24 | eventMonitors = [NetworkLoggerEventMonitor(logger: logger)] 25 | session = Alamofire.Session(eventMonitors: eventMonitors) 26 | } 27 | 28 | func request(_ request: URLRequestConvertible) -> DataRequest { 29 | return session.request(request) 30 | } 31 | } 32 | 33 | // MARK: - LOGGER EVENT 34 | 35 | struct NetworkLoggerEventMonitor: EventMonitor { 36 | let logger: NetworkLogger 37 | 38 | func request(_ request: Request, didCreateTask task: URLSessionTask) { 39 | logger.logTaskCreated(task) 40 | } 41 | 42 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { 43 | logger.logDataTask(dataTask, didReceive: data) 44 | 45 | guard let response = dataTask.response else { return } 46 | logger.logDataTask(dataTask, didReceive: response) 47 | } 48 | 49 | func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { 50 | logger.logTask(task, didFinishCollecting: metrics) 51 | } 52 | 53 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 54 | logger.logTask(task, didCompleteWithError: error) 55 | } 56 | 57 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse) { 58 | logger.logDataTask(dataTask, didReceive: proposedResponse.response) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/IntegrationsExamples/MoyaIntegration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoyaIntegration.swift 3 | // Pulse 4 | // 5 | // Created by Bagus andinata on 21/08/21. 6 | // Copyright © 2021 kean. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Moya 11 | import Alamofire 12 | import PulseLogHandler 13 | 14 | // MARK: - EXAMPLE PROVIDER WITH LOGGER 15 | let ExampleProvider: MoyaProvider = { 16 | let logger: NetworkLogger = NetworkLogger() 17 | let eventMonitors: [EventMonitor] = [NetworkLoggerEventMonitor(logger: logger)] 18 | let session = Alamofire.Session(eventMonitors: eventMonitors) 19 | return MoyaProvider(session: session) 20 | }() 21 | 22 | // MARK: - LOGGER EVENT 23 | 24 | struct NetworkLoggerEventMonitor: EventMonitor { 25 | let logger: NetworkLogger 26 | 27 | func request(_ request: Request, didCreateTask task: URLSessionTask) { 28 | logger.logTaskCreated(task) 29 | } 30 | 31 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { 32 | logger.logDataTask(dataTask, didReceive: data) 33 | 34 | guard let response = dataTask.response else { return } 35 | logger.logDataTask(dataTask, didReceive: response) 36 | } 37 | 38 | func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { 39 | logger.logTask(task, didFinishCollecting: metrics) 40 | } 41 | 42 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 43 | logger.logTask(task, didCompleteWithError: error) 44 | } 45 | 46 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse) { 47 | logger.logDataTask(dataTask, didReceive: proposedResponse.response) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | 3 | // Leave blank. This is only here so that Xcode doesn't display it. 4 | 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "client", 9 | products: [], 10 | targets: [] 11 | ) 12 | -------------------------------------------------------------------------------- /Demo/Sources/Integrations/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2023 Alexander Grebenyuk (github.com/kean). 4 | 5 | import UIKit 6 | 7 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 8 | 9 | var window: UIWindow? 10 | 11 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 12 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 13 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 14 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 15 | guard let _ = (scene as? UIWindowScene) else { return } 16 | } 17 | 18 | func sceneDidDisconnect(_ scene: UIScene) { 19 | // Called as the scene is being released by the system. 20 | // This occurs shortly after the scene enters the background, or when its session is discarded. 21 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 22 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 23 | } 24 | 25 | func sceneDidBecomeActive(_ scene: UIScene) { 26 | // Called when the scene has moved from an inactive state to an active state. 27 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 28 | } 29 | 30 | func sceneWillResignActive(_ scene: UIScene) { 31 | // Called when the scene will move from an active state to an inactive state. 32 | // This may occur due to temporary interruptions (ex. an incoming phone call). 33 | } 34 | 35 | func sceneWillEnterForeground(_ scene: UIScene) { 36 | // Called as the scene transitions from the background to the foreground. 37 | // Use this method to undo the changes made on entering the background. 38 | } 39 | 40 | func sceneDidEnterBackground(_ scene: UIScene) { 41 | // Called as the scene transitions from the foreground to the background. 42 | // Use this method to save data, release shared resources, and store enough scene-specific state information 43 | // to restore the scene back to its current state. 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /Demo/Sources/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | import PackageDescription 3 | 4 | let package = Package( 5 | platforms: [ ], 6 | products: [], 7 | targets: [], 8 | ) 9 | -------------------------------------------------------------------------------- /Demo/Sources/Pulse-Demo-iOS-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSBonjourServices 6 | 7 | _pulse._tcp 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Demo/Sources/Pulse-Demo-macOS-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSBonjourServices 6 | 7 | _pulse._tcp 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Demo/Sources/Shared/MockStore.swift: -------------------------------------------------------------------------------- 1 | ../../../Sources/PulseUI/Mocks/MockStore.swift -------------------------------------------------------------------------------- /Demo/Sources/Shared/MockTask.swift: -------------------------------------------------------------------------------- 1 | ../../../Sources/PulseUI/Mocks/MockTask.swift -------------------------------------------------------------------------------- /Demo/Sources/iOS-paired/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/Sources/iOS-paired/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kean/Pulse/67bff610613a91b83ce6d56aa92c9a103ce9757f/Demo/Sources/iOS-paired/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /Demo/Sources/iOS-paired/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Demo/Sources/iOS-paired/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/iOS-paired/Pulse_DemoApp.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | import PulseUI 8 | import WatchConnectivity 9 | import OSLog 10 | 11 | @main 12 | struct PulseDemo_iOS: App { 13 | @StateObject private var viewModel = AppViewModel() 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | NavigationView { 18 | ConsoleView(store: .demo) 19 | } 20 | } 21 | } 22 | } 23 | 24 | // MARK: - WatchOS Integration 25 | 26 | private final class AppViewModel: NSObject, ObservableObject, WCSessionDelegate { 27 | let log = OSLog(subsystem: "app", category: "AppViewModel") 28 | 29 | override init() { 30 | super.init() 31 | 32 | if WCSession.isSupported() { 33 | WCSession.default.delegate = self 34 | WCSession.default.activate() 35 | } 36 | 37 | // Uncomment to test `URLSessionProxyDelegate`: 38 | // testProxy() 39 | } 40 | 41 | func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { 42 | os_log("WCSession.activationDidCompleteWith(state: %{public}@, error: %{public}@)", log: log, "\(activationState)", String(describing: error)) 43 | } 44 | 45 | func sessionDidBecomeInactive(_ session: WCSession) { 46 | os_log("WCSession.sessionDidBecomeInactive()", log: log) 47 | } 48 | 49 | func sessionDidDeactivate(_ session: WCSession) { 50 | os_log("WCSession.sessionDidDeactivate()", log: log) 51 | } 52 | 53 | func session(_ session: WCSession, didReceive file: WCSessionFile) { 54 | os_log("WCSession.didReceiveFile(url: %{public}@, metadata: %{public}@", log: log, String(describing: file.fileURL), String(describing: file.metadata?.description)) 55 | 56 | LoggerStore.session(session, didReceive: file) 57 | } 58 | } 59 | 60 | extension WCSessionActivationState: CustomStringConvertible { 61 | public var description: String { 62 | switch self { 63 | case .notActivated: return ".notActivated" 64 | case .inactive: return ".inactive" 65 | case .activated: return ".activated" 66 | @unknown default: return "unknown" 67 | } 68 | } 69 | } 70 | 71 | // MARK: - URLSessionProxyDelegate Example 72 | 73 | private func testProxy() { 74 | // Experimental.URLSessionProxy.shared.isEnabled = true 75 | URLSessionProxyDelegate.enableAutomaticRegistration() 76 | 77 | let session = URLSession(configuration: .default, delegate: MockSessionDelegate(), delegateQueue: nil) 78 | 79 | DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { 80 | let task = session.dataTask(with: URLRequest(url: URL(string: "https://github.com/kean/Nuke/archive/refs/tags/11.0.0.zip")!)) 81 | task.resume() 82 | } 83 | } 84 | 85 | private final class MockSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate, URLSessionDownloadDelegate { 86 | var completion: ((URLSessionTask, Error?) -> Void)? 87 | 88 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 89 | completion?(task, error) 90 | } 91 | 92 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 93 | print("here") 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Demo/Sources/iOS/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "osx", 6 | "reference" : "systemBlueColor" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "platform" : "osx", 19 | "reference" : "systemBlueColor" 20 | }, 21 | "idiom" : "universal" 22 | } 23 | ], 24 | "info" : { 25 | "author" : "xcode", 26 | "version" : 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Demo/Sources/iOS/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kean/Pulse/67bff610613a91b83ce6d56aa92c9a103ce9757f/Demo/Sources/iOS/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /Demo/Sources/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Demo/Sources/iOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Back.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "layers" : [ 7 | { 8 | "filename" : "Front.imagestacklayer" 9 | }, 10 | { 11 | "filename" : "Middle.imagestacklayer" 12 | }, 13 | { 14 | "filename" : "Back.imagestacklayer" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Front.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - App Store.imagestack/Middle.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "author" : "xcode", 14 | "version" : 1 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "layers" : [ 7 | { 8 | "filename" : "Front.imagestacklayer" 9 | }, 10 | { 11 | "filename" : "Middle.imagestacklayer" 12 | }, 13 | { 14 | "filename" : "Back.imagestacklayer" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "author" : "xcode", 14 | "version" : 1 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | } 11 | ], 12 | "info" : { 13 | "author" : "xcode", 14 | "version" : 1 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "assets" : [ 3 | { 4 | "filename" : "App Icon - App Store.imagestack", 5 | "idiom" : "tv", 6 | "role" : "primary-app-icon", 7 | "size" : "1280x768" 8 | }, 9 | { 10 | "filename" : "App Icon.imagestack", 11 | "idiom" : "tv", 12 | "role" : "primary-app-icon", 13 | "size" : "400x240" 14 | }, 15 | { 16 | "filename" : "Top Shelf Image Wide.imageset", 17 | "idiom" : "tv", 18 | "role" : "top-shelf-image-wide", 19 | "size" : "2320x720" 20 | }, 21 | { 22 | "filename" : "Top Shelf Image.imageset", 23 | "idiom" : "tv", 24 | "role" : "top-shelf-image", 25 | "size" : "1920x720" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "tv-marketing", 13 | "scale" : "1x" 14 | }, 15 | { 16 | "idiom" : "tv-marketing", 17 | "scale" : "2x" 18 | } 19 | ], 20 | "info" : { 21 | "author" : "xcode", 22 | "version" : 1 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "tv", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "tv-marketing", 13 | "scale" : "1x" 14 | }, 15 | { 16 | "idiom" : "tv-marketing", 17 | "scale" : "2x" 18 | } 19 | ], 20 | "info" : { 21 | "author" : "xcode", 22 | "version" : 1 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Pulse tvOS 4 | // 5 | // Created by Alexander Grebenyuk on 07.03.2021. 6 | // Copyright © 2021 kean. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import PulseUI 11 | 12 | struct ContentView: View { 13 | var body: some View { 14 | NavigationView { 15 | ConsoleView(store: .demo) 16 | } 17 | } 18 | } 19 | 20 | struct ContentView_Previews: PreviewProvider { 21 | static var previews: some View { 22 | ContentView() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | NSBonjourServices 24 | 25 | _pulse._tcp 26 | 27 | NSLocalNetworkUsageDescription 28 | Network usage required for debugging purposes 29 | UILaunchScreen 30 | 31 | UIRequiredDeviceCapabilities 32 | 33 | arm64 34 | 35 | UIUserInterfaceStyle 36 | Automatic 37 | 38 | 39 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Sources/tvOS/Pulse_tvOSApp.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | 7 | @main 8 | struct Pulse_tvOSApp: App { 9 | var body: some Scene { 10 | WindowGroup { 11 | ContentView() 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) 2020-2024 Alexander Grebenyuk 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Pulse", 6 | platforms: [ 7 | .iOS(.v15), 8 | .tvOS(.v15), 9 | .macOS(.v13), 10 | .watchOS(.v9), 11 | .visionOS(.v1) 12 | ], 13 | products: [ 14 | .library(name: "Pulse", targets: ["Pulse"]), 15 | .library(name: "PulseProxy", targets: ["PulseProxy"]), 16 | .library(name: "PulseUI", targets: ["PulseUI"]) 17 | ], 18 | targets: [ 19 | .target(name: "Pulse"), 20 | .target(name: "PulseProxy", dependencies: ["Pulse"]), 21 | .target(name: "PulseUI", dependencies: ["Pulse"]), 22 | ], 23 | swiftLanguageVersions: [ 24 | .v5 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /PulseCore.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | 3 | s.name = "PulseCore" 4 | s.module_name = "Pulse" 5 | s.version = "4.2.7" 6 | s.summary = "Logging system for Apple platforms" 7 | s.swift_version = "5.7" 8 | 9 | s.description = <<-DESC 10 | Pulse is a powerful logging system for Apple Platforms. Native. Built with SwiftUI. 11 | DESC 12 | 13 | s.homepage = "https://github.com/kean/Pulse" 14 | s.license = "MIT" 15 | s.author = { "kean" => "https://github.com/kean" } 16 | s.authors = { "kean" => "https://github.com/kean" } 17 | s.source = { :git => "https://github.com/kean/Pulse.git", :tag => "#{s.version}" } 18 | s.social_media_url = "https://kean.blog/" 19 | 20 | s.ios.deployment_target = "14.0" 21 | s.source_files = "Sources/Pulse/**/*.swift" 22 | 23 | end 24 | -------------------------------------------------------------------------------- /PulseUI.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | 3 | s.name = "PulseUI" 4 | s.module_name = "PulseUI" 5 | s.version = "4.2.7" 6 | s.summary = "Logging system for Apple platforms" 7 | s.swift_version = "5.7" 8 | 9 | s.description = <<-DESC 10 | Pulse is a powerful logging system for Apple Platforms. Native. Built with SwiftUI. 11 | DESC 12 | 13 | s.homepage = "https://github.com/kean/Pulse" 14 | s.license = "MIT" 15 | s.author = { "kean" => "https://github.com/kean" } 16 | s.authors = { "kean" => "https://github.com/kean" } 17 | s.source = { :git => "https://github.com/kean/Pulse.git", :tag => "#{s.version}" } 18 | s.social_media_url = "https://kean.blog/" 19 | 20 | s.ios.deployment_target = "14.0" 21 | s.dependency "PulseCore", '~> 4.2' 22 | s.source_files = "Sources/PulseUI/**/*.swift" 23 | 24 | end 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | promo-1 2 | 3 |
4 |
5 | 6 | **Pulse** is a powerful logging system for Apple Platforms. Native. Built with SwiftUI. 7 | 8 | Record and inspect logs and `URLSession` network requests right from your iOS app. Share logs and view them in [Pulse Pro](https://pulselogger.com) or use remote logging to see them in real time. Logs are stored locally and never leave your devices. 9 | 10 | ## About 11 | 12 | `Pulse` is not just a tool, it's a framework. It records events from `URLSession` or from frameworks that use it, such as [Alamofire](https://github.com/Alamofire/Alamofire) or [Get](https://github.com/kean/Get), and displays them using `PulseUI` views that you integrate directly into your app. This way Pulse console is available for everyone who has your test builds. You or your QA team can view the logs on the device and easily share them to attach to bug reports. 13 | 14 | > Pulse is **not** a network proxy. If you need one, check out [**Proxyman**](https://proxyman.io). 15 | 16 | ## Getting Started 17 | 18 | The best way to start using Pulse is with the [**Getting Started**](https://kean-docs.github.io/pulse/documentation/pulse/gettingstarted) guide. There are many ways to use it and to learn more, see the dedicated docs: 19 | 20 | - [**Pulse Docs**](https://kean-docs.github.io/pulse/documentation/pulse/) describe how to integrate the main framework and enable logging 21 | - [**PulseUI Docs**](https://kean-docs.github.io/pulseui/documentation/pulseui/) contains information about adding the debug menu and console into your app 22 | - [**PulseLogHandler Docs**](https://kean-docs.github.io/pulseloghandler/documentation/pulseloghandler/) describe how to use Pulse as [SwiftLog](https://github.com/apple/swift-log) backend 23 | 24 | 25 | 26 | 27 | 28 | ## Pulse Pro 29 | 30 | [**Pulse Pro**](https://pulselogger.com) is a professional macOS app that allows you to view logs in real time. The app is designed to be flexible, expansive, and precise while using all the familiar macOS patterns. It makes it easy to navigate large log files with table and text modes, filters, an all-new network inspector, JSON filters, and more. 31 | 32 | ## Minimum Requirements 33 | 34 | | Pulse | Swift | Xcode | Platforms | 35 | |------------|------------|-------------|--------------------------------------------------| 36 | | Pulse 5.0 | Swift 5.10 | Xcode 15.4 | iOS 15, tvOS 15, watchOS 8, macOS 12, visionOS 1 | 37 | | Pulse 4.0 | Swift 5.7 | Xcode 14.1 | iOS 14, tvOS 15, watchOS 8, macOS 12 | 38 | 39 | ## License 40 | 41 | Pulse is available under the MIT license. See the LICENSE file for more info. 42 | -------------------------------------------------------------------------------- /Sources/Pulse/Helpers/LoggerSession.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | 7 | extension LoggerStore { 8 | public struct Session: Codable, Sendable { 9 | public let id: UUID 10 | public let startDate: Date 11 | 12 | public init(id: UUID = UUID(), startDate: Date = Date()) { 13 | self.id = id 14 | self.startDate = startDate 15 | } 16 | 17 | /// Returns current log session. 18 | public static let current = Session() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Pulse/Helpers/Mutex.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | 7 | package final class Mutex: @unchecked Sendable { 8 | private var _value: T 9 | private let lock: os_unfair_lock_t 10 | 11 | package init(_ value: T) { 12 | self._value = value 13 | self.lock = .allocate(capacity: 1) 14 | self.lock.initialize(to: os_unfair_lock()) 15 | } 16 | 17 | deinit { 18 | lock.deinitialize(count: 1) 19 | lock.deallocate() 20 | } 21 | 22 | package var value: T { 23 | get { 24 | os_unfair_lock_lock(lock) 25 | defer { os_unfair_lock_unlock(lock) } 26 | return _value 27 | } 28 | set { 29 | os_unfair_lock_lock(lock) 30 | defer { os_unfair_lock_unlock(lock) } 31 | _value = newValue 32 | } 33 | } 34 | 35 | package func withLock(_ closure: (inout T) -> U) -> U { 36 | os_unfair_lock_lock(lock) 37 | defer { os_unfair_lock_unlock(lock) } 38 | return closure(&_value) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Pulse/Helpers/PulseDocument.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import CoreData 6 | 7 | package final class PulseDocument { 8 | package let container: NSPersistentContainer 9 | package var context: NSManagedObjectContext { container.viewContext } 10 | 11 | package init(documentURL: URL) throws { 12 | guard Files.fileExists(atPath: documentURL.deletingLastPathComponent().path) else { 13 | throw LoggerStore.Error.fileDoesntExist 14 | } 15 | self.container = NSPersistentContainer(name: documentURL.lastPathComponent, managedObjectModel: PulseDocument.model) 16 | let store = NSPersistentStoreDescription(url: documentURL) 17 | store.setValue("OFF" as NSString, forPragmaNamed: "journal_mode") 18 | container.persistentStoreDescriptions = [store] 19 | 20 | try container.loadStore() 21 | } 22 | 23 | package func close() throws { 24 | let coordinator = container.persistentStoreCoordinator 25 | for store in coordinator.persistentStores { 26 | try coordinator.remove(store) 27 | } 28 | } 29 | 30 | /// - warning: Model has to be loaded only once. 31 | nonisolated(unsafe) static let model: NSManagedObjectModel = { 32 | let model = NSManagedObjectModel() 33 | let blob = NSEntityDescription(class: PulseBlobEntity.self) 34 | blob.properties = [ 35 | NSAttributeDescription("key", .stringAttributeType), 36 | NSAttributeDescription("data", .binaryDataAttributeType) 37 | ] 38 | model.entities = [blob] 39 | return model 40 | }() 41 | } 42 | 43 | package final class PulseBlobEntity: NSManagedObject { 44 | @NSManaged package var key: String 45 | @NSManaged package var data: Data 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Pulse/Helpers/Regex.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | 7 | package final class Regex: @unchecked Sendable { 8 | private let regex: NSRegularExpression 9 | 10 | package struct Options: OptionSet { 11 | package let rawValue: Int 12 | package init(rawValue: Int) { self.rawValue = rawValue } 13 | 14 | package static let caseInsensitive = Options(rawValue: 1 << 0) 15 | package static let multiline = Options(rawValue: 1 << 1) 16 | package static let dotMatchesLineSeparators = Options(rawValue: 1 << 2) 17 | } 18 | 19 | package init(_ pattern: String, _ options: Options = []) throws { 20 | var ops = NSRegularExpression.Options() 21 | if options.contains(.caseInsensitive) { ops.insert(.caseInsensitive) } 22 | if options.contains(.multiline) { ops.insert(.anchorsMatchLines) } 23 | if options.contains(.dotMatchesLineSeparators) { ops.insert(.dotMatchesLineSeparators)} 24 | 25 | self.regex = try NSRegularExpression(pattern: pattern, options: ops) 26 | } 27 | 28 | package func isMatch(_ s: String) -> Bool { 29 | let range = NSRange(s.startIndex.. Bool { 38 | lhs.rawValue < rhs.rawValue 39 | } 40 | } 41 | } 42 | 43 | extension Dictionary where Key == String, Value == LoggerStore.MetadataValue { 44 | func unpack() -> [String: String] { 45 | var entries = [String: String]() 46 | for (key, value) in self { 47 | switch value { 48 | case let .string(string): entries[key] = string 49 | case let .stringConvertible(string): entries[key] = string.description 50 | } 51 | } 52 | return entries 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Pulse/LoggerStore/LoggerStore+Version.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | extension LoggerStore { 6 | /// A semantic version. 7 | public struct Version: Comparable, LosslessStringConvertible, Codable, Sendable { 8 | public let major: Int 9 | public let minor: Int 10 | public let patch: Int 11 | 12 | public init(_ major: Int, _ minor: Int, _ patch: Int) { 13 | precondition(major >= 0 && minor >= 0 && patch >= 0, "Negative versioning is invalid.") 14 | self.major = major 15 | self.minor = minor 16 | self.patch = patch 17 | } 18 | 19 | // MARK: Comparable 20 | 21 | public static func == (lhs: Version, rhs: Version) -> Bool { 22 | !(lhs < rhs) && !(lhs > rhs) 23 | } 24 | 25 | public static func < (lhs: Version, rhs: Version) -> Bool { 26 | (lhs.major, lhs.minor, lhs.patch) < (rhs.major, rhs.minor, rhs.patch) 27 | } 28 | 29 | public init(string: String) throws { 30 | guard let version = Version(string) else { 31 | throw LoggerStore.Error.unknownError // Should never happen 32 | } 33 | self = version 34 | } 35 | 36 | // MARK: LosslessStringConvertible 37 | 38 | public init?(_ string: String) { 39 | guard string.allSatisfy(\.isASCII) else { return nil } 40 | let components = string.split(separator: ".", omittingEmptySubsequences: false) 41 | guard components.count == 3, 42 | let major = Int(components[0]), 43 | let minor = Int(components[1]), 44 | let patch = Int(components[2]) else { 45 | return nil 46 | } 47 | self.major = major 48 | self.minor = minor 49 | self.patch = patch 50 | } 51 | 52 | public var description: String { 53 | "\(major).\(minor).\(patch)" 54 | } 55 | 56 | // MARK: Codable 57 | 58 | public init(from decoder: Decoder) throws { 59 | let container = try decoder.singleValueContainer() 60 | guard let version = Version(try container.decode(String.self)) else { 61 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid version number format") 62 | } 63 | self = version 64 | } 65 | 66 | public func encode(to encoder: Encoder) throws { 67 | var container = encoder.singleValueContainer() 68 | try container.encode(self.description) 69 | } 70 | } 71 | } 72 | 73 | typealias Version = LoggerStore.Version 74 | -------------------------------------------------------------------------------- /Sources/Pulse/NetworkDebugger/NetworkDebugger.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | 7 | final class NetworkDebugger: @unchecked Sendable { 8 | private var mocks: [UUID: URLSessionMock] = [:] 9 | 10 | // Number of handled requests per mock. 11 | private var numberOfHandledRequests: [UUID: Int] = [:] 12 | private var mockedTaskIDs: Set = [] 13 | 14 | private let lock = NSLock() 15 | 16 | static let shared = NetworkDebugger() 17 | 18 | func getMock(for request: URLRequest) -> URLSessionMock? { 19 | lock.lock() 20 | defer { lock.unlock() } 21 | return _getMock(for: request) 22 | } 23 | 24 | func shouldMock(_ request: URLRequest) -> Bool { 25 | lock.lock() 26 | defer { lock.unlock() } 27 | 28 | guard let mock = _getMock(for: request) else { 29 | return false 30 | } 31 | defer { numberOfHandledRequests[mock.mockID, default: 0] += 1 } 32 | let count = numberOfHandledRequests[mock.mockID, default: 0] 33 | if count < (mock.skip ?? 0) { 34 | return false // Skip the first N requests 35 | } 36 | if let maxCount = mock.count, count - (mock.skip ?? 0) >= maxCount { 37 | return false // Mock for N number of times 38 | } 39 | return true 40 | } 41 | 42 | private func _getMock(for request: URLRequest) -> URLSessionMock? { 43 | mocks.lazy.map(\.value).first { 44 | $0.isMatch(request) 45 | } 46 | } 47 | 48 | func update(_ mocks: [URLSessionMock]) { 49 | lock.lock() 50 | defer { lock.unlock() } 51 | 52 | self.mocks.removeAll() 53 | for mock in mocks { 54 | self.mocks[mock.mockID] = mock 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Pulse/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPIType 9 | NSPrivacyAccessedAPICategoryFileTimestamp 10 | NSPrivacyAccessedAPITypeReasons 11 | 12 | C617.1 13 | 14 | 15 | 16 | NSPrivacyAccessedAPIType 17 | NSPrivacyAccessedAPICategoryUserDefaults 18 | NSPrivacyAccessedAPITypeReasons 19 | 20 | CA92.1 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/Pulse/Pulse.docc/Articles/NextSteps.md: -------------------------------------------------------------------------------- 1 | # Next Steps 2 | 3 | Learn how to configure Pulse to best suit your app needs. 4 | 5 | ## Console 6 | 7 | Pulse is highly customizable and you can tweak it to best match your style and your backend. To learn more, see [PulseUI: Overview](https://kean-docs.github.io/pulseui/documentation/pulseui/). 8 | 9 | ## Logger 10 | 11 | ### Configure Store 12 | 13 | ``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. 14 | 15 | ```swift 16 | LoggerStore.shared.configuration.sizeLimit = 512 * 1_000_000 17 | ``` 18 | 19 | > important: Make sure to change it at the app launch before sending any logs. 20 | 21 | > tip: Use ``LoggerStore/Options-swift.struct/inMemory`` if you want to prevent any logs from being recorded persistently 22 | 23 | ### Exporting Logs 24 | 25 | If you want to provide additional ways to share the logs recorded by the store, use ``LoggerStore/export(to:options:)``. 26 | 27 | ```swift 28 | try await store.export(to: <#targetURL#>) 29 | ``` 30 | 31 | Export can be configured with a predicate to limit what gets exported: 32 | 33 | ```swift 34 | var options = LoggerStore.ExportOptions( 35 | predicate: <#predicate#>, 36 | sessions: [<#sessionID#>] 37 | ) 38 | ``` 39 | 40 | > note: The exported store is in a Pulse document format (`.pulse` extension) 41 | 42 | ### Accessing Logs 43 | 44 | ``LoggerStore`` uses Core Data and provides full access to its underlying entities, which you can use to access any previously stored logs, export them, or create custom views for your logs. 45 | 46 | ```swift 47 | struct AnalyticsLogsView: View { 48 | @FetchRequest( 49 | sortDescriptors: [NSSortDescriptor(keyPath: \LoggerMessageEntity.createdAt, ascending: true)], 50 | predicate: NSPredicate(format: "label == %@", "analytics") 51 | ) var messages: FetchedResults 52 | 53 | var body: some View { 54 | List(messages, id: \.objectID) { message in 55 | <#view#> 56 | } 57 | } 58 | } 59 | ``` 60 | 61 | > important: In the current schema, the alogger creates an associated ``LoggerMessageEntity`` entity for every ``NetworkTaskEntity``, but it will likely change in the future. 62 | 63 | ## Network Logging & Debugging 64 | 65 | See for more information about how to configure network logging if your app does not use `URLSession` directly, how to further customize it, how to capture and display decoding errors, and more. 66 | -------------------------------------------------------------------------------- /Sources/Pulse/Pulse.docc/Extensions/LoggerStore-Extension.md: -------------------------------------------------------------------------------- 1 | # ``Pulse/LoggerStore`` 2 | 3 | ## Topics 4 | 5 | ### Initializers 6 | 7 | - ``shared`` 8 | - ``init(storeURL:options:configuration:)`` 9 | - ``Options-swift.struct`` 10 | - ``Configuration-swift.struct`` 11 | 12 | ### Storing Logs 13 | 14 | - ``storeMessage(createdAt:label:level:message:metadata:file:function:line:)`` 15 | - ``storeRequest(_:response:error:data:metrics:label:taskDescription:)`` 16 | 17 | ### Accessing Logs 18 | 19 | - ``messages(sortDescriptors:predicate:)`` 20 | - ``tasks(sortDescriptors:predicate:)`` 21 | 22 | ### Exporting Logs 23 | 24 | - ``export(to:options:)`` 25 | - ``ExportOptions`` 26 | 27 | ### Managing the Store 28 | 29 | - ``info()`` 30 | - ``removeAll()`` 31 | - ``removeSessions(withIDs:)`` 32 | - ``close()`` 33 | - ``destroy()`` 34 | - ``getBlobData(forKey:)`` 35 | 36 | ### Direct Database Access 37 | 38 | - ``container`` 39 | - ``viewContext`` 40 | - ``backgroundContext`` 41 | - ``newBackgroundContext()`` 42 | 43 | ### Core Data Entities 44 | 45 | - ``LoggerMessageEntity`` 46 | - ``LoggerBlobHandleEntity`` 47 | - ``LoggerSessionEntity`` 48 | - ``NetworkTaskEntity`` 49 | - ``NetworkTaskProgressEntity`` 50 | - ``NetworkTransactionMetricsEntity`` 51 | - ``NetworkRequestEntity`` 52 | - ``NetworkResponseEntity`` 53 | 54 | ### Nested Types 55 | 56 | - ``Error`` 57 | - ``Event`` 58 | - ``Info`` 59 | - ``Level`` 60 | - ``MetadataValue`` 61 | - ``Metadata`` 62 | - ``Session-swift.struct`` 63 | - ``Version-swift.struct`` 64 | 65 | ### Deprecated 66 | 67 | - ``allMessages()`` 68 | - ``allTasks()`` 69 | -------------------------------------------------------------------------------- /Sources/Pulse/Pulse.docc/Pulse.md: -------------------------------------------------------------------------------- 1 | # ``Pulse`` 2 | 3 | Logger and network inspector for Apple platforms. 4 | 5 | ## Topics 6 | 7 | ### Essentials 8 | 9 | - 10 | - 11 | - ``LoggerStore`` 12 | 13 | ### Network Logging & Debugging 14 | 15 | - 16 | - ``NetworkLogger`` 17 | - ``URLSessionProxy`` 18 | - ``URLSessionProtocol`` 19 | - ``URLSessionProxyDelegate`` 20 | - ``MockingURLProtocol`` 21 | 22 | ### Remote Logging 23 | 24 | - ``RemoteLogger`` 25 | 26 | ### Deprecated 27 | 28 | - ``Experimental`` 29 | -------------------------------------------------------------------------------- /Sources/Pulse/Pulse.docc/Resources/pulse-console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kean/Pulse/67bff610613a91b83ce6d56aa92c9a103ce9757f/Sources/Pulse/Pulse.docc/Resources/pulse-console.png -------------------------------------------------------------------------------- /Sources/Pulse/Pulse.docc/Resources/pulse-pro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kean/Pulse/67bff610613a91b83ce6d56aa92c9a103ce9757f/Sources/Pulse/Pulse.docc/Resources/pulse-pro.png -------------------------------------------------------------------------------- /Sources/Pulse/Pulse.docc/Resources/pulseui-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kean/Pulse/67bff610613a91b83ce6d56aa92c9a103ce9757f/Sources/Pulse/Pulse.docc/Resources/pulseui-main.png -------------------------------------------------------------------------------- /Sources/Pulse/Pulse.docc/Resources/remote-logging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kean/Pulse/67bff610613a91b83ce6d56aa92c9a103ce9757f/Sources/Pulse/Pulse.docc/Resources/remote-logging.png -------------------------------------------------------------------------------- /Sources/PulseUI/Extensions/NSAttributedString+Extensions.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | 7 | extension NSMutableAttributedString { 8 | package func append(_ string: String, _ attributes: [NSAttributedString.Key: Any] = [:]) { 9 | append(NSAttributedString(string: string, attributes: attributes)) 10 | } 11 | 12 | package func addAttributes(_ attributes: [NSAttributedString.Key: Any]) { 13 | addAttributes(attributes, range: NSRange(location: 0, length: string.count)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/PulseUI/Extensions/SwiftUI+Extensions.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Combine 7 | 8 | #if os(iOS) || os(macOS) || os(visionOS) 9 | extension Color { 10 | static var separator: Color { Color(UXColor.separator) } 11 | static var secondaryFill: Color { Color(UXColor.secondarySystemFill) } 12 | } 13 | #endif 14 | 15 | #if os(watchOS) || os(tvOS) 16 | extension Color { 17 | static var separator: Color { Color.secondary.opacity(0.3) } 18 | static var secondaryFill: Color { Color.secondary.opacity(0.3) } 19 | } 20 | #endif 21 | 22 | extension View { 23 | package func invisible() -> some View { 24 | self.hidden().accessibilityHidden(true) 25 | } 26 | } 27 | 28 | #if os(iOS) || os(visionOS) 29 | 30 | enum Keyboard { 31 | static var isHidden: AnyPublisher { 32 | Publishers.Merge( 33 | NotificationCenter.default 34 | .publisher(for: UIResponder.keyboardWillShowNotification) 35 | .map { _ in false }, 36 | 37 | NotificationCenter.default 38 | .publisher(for: UIResponder.keyboardWillHideNotification) 39 | .map { _ in true } 40 | ) 41 | .eraseToAnyPublisher() 42 | } 43 | } 44 | 45 | #endif 46 | 47 | // MARK: - Backport 48 | 49 | package struct Backport { 50 | package let content: Content 51 | } 52 | 53 | extension View { 54 | package var backport: Backport { Backport(content: self) } 55 | } 56 | 57 | extension View { 58 | package func inlineNavigationTitle(_ title: String) -> some View { 59 | self.navigationTitle(title) 60 | #if os(iOS) || os(visionOS) 61 | .navigationBarTitleDisplayMode(.inline) 62 | #endif 63 | } 64 | } 65 | 66 | /// Allows you to use `@StateObject` only for memory management (without observing). 67 | package final class IgnoringUpdates: ObservableObject { 68 | package var value: T 69 | package init(_ value: T) { self.value = value } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Console/ConsoleView-ios.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | import CoreData 9 | import Pulse 10 | import Combine 11 | import WatchConnectivity 12 | 13 | public struct ConsoleView: View { 14 | @StateObject private var environment: ConsoleEnvironment // Never reloads 15 | @Environment(\.presentationMode) private var presentationMode 16 | private var isCloseButtonHidden = false 17 | 18 | init(environment: ConsoleEnvironment) { 19 | _environment = StateObject(wrappedValue: environment) 20 | } 21 | 22 | public var body: some View { 23 | if #available(iOS 16, *) { 24 | contents 25 | } else { 26 | PlaceholderView(imageName: "xmark.octagon", title: "Unsupported", subtitle: "Pulse requires iOS 16 or later").padding() 27 | } 28 | } 29 | 30 | @available(iOS 16, visionOS 1, *) 31 | private var contents: some View { 32 | ConsoleListView() 33 | .navigationTitle(environment.title) 34 | .toolbar { 35 | ToolbarItemGroup(placement: .navigationBarLeading) { 36 | if !isCloseButtonHidden && presentationMode.wrappedValue.isPresented { 37 | Button("Close") { 38 | presentationMode.wrappedValue.dismiss() 39 | } 40 | } 41 | } 42 | ToolbarItemGroup(placement: .navigationBarTrailing) { 43 | trailingNavigationBarItems 44 | } 45 | } 46 | .injecting(environment) 47 | } 48 | 49 | /// Changes the default close button visibility. 50 | public func closeButtonHidden(_ isHidden: Bool = true) -> ConsoleView { 51 | var copy = self 52 | copy.isCloseButtonHidden = isHidden 53 | return copy 54 | } 55 | 56 | @available(iOS 16, visionOS 1, *) 57 | @ViewBuilder private var trailingNavigationBarItems: some View { 58 | Button(action: { environment.router.isShowingShareStore = true }) { 59 | Image(systemName: "square.and.arrow.up") 60 | } 61 | Button(action: { environment.router.isShowingFilters = true }) { 62 | Image(systemName: "line.horizontal.3.decrease.circle") 63 | } 64 | ConsoleContextMenu() 65 | } 66 | } 67 | 68 | #if DEBUG 69 | struct ConsoleView_Previews: PreviewProvider { 70 | static var previews: some View { 71 | Group { 72 | NavigationView { 73 | ConsoleView(environment: .init(store: .mock)) 74 | }.previewDisplayName("Console") 75 | NavigationView { 76 | ConsoleView(store: .mock, mode: .network) 77 | }.previewDisplayName("Network") 78 | } 79 | } 80 | } 81 | #endif 82 | 83 | #endif 84 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Console/ConsoleView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if !os(macOS) 6 | 7 | import SwiftUI 8 | import CoreData 9 | import Pulse 10 | import Combine 11 | 12 | extension ConsoleView { 13 | /// Initializes the console view. 14 | /// 15 | /// - parameters: 16 | /// - store: The store to display. By default, `LoggerStore/shared`. 17 | /// - mode: The initial console mode. By default, ``ConsoleMode/all``. If you change 18 | /// the mode to ``ConsoleMode/network``, the console will display the 19 | /// network messages up on appearance. 20 | /// - delegate: The delegate that allows you to customize multiple aspects 21 | /// of the console view. 22 | public init( 23 | store: LoggerStore = .shared, 24 | mode: ConsoleMode = .all 25 | ) { 26 | self.init(environment: .init(store: store, mode: mode)) 27 | } 28 | } 29 | 30 | #endif 31 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Console/List/ConsoleListContentView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) 6 | 7 | import CoreData 8 | import Pulse 9 | import Combine 10 | import SwiftUI 11 | 12 | @available(iOS 16, visionOS 1, *) 13 | struct ConsoleListContentView: View { 14 | @EnvironmentObject var viewModel: ConsoleListViewModel 15 | 16 | var body: some View { 17 | plainView 18 | } 19 | 20 | @ViewBuilder 21 | private var plainView: some View { 22 | if viewModel.entities.isEmpty { 23 | Text("Empty") 24 | .font(.subheadline) 25 | .foregroundColor(.secondary) 26 | .listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16)) 27 | } else { 28 | ForEach(viewModel.visibleEntities, id: \.objectID) { entity in 29 | let objectID = entity.objectID 30 | ConsoleEntityCell(entity: entity) 31 | .id(objectID) 32 | #if os(iOS) || os(visionOS) 33 | .onAppear { viewModel.onAppearCell(with: objectID) } 34 | .onDisappear { viewModel.onDisappearCell(with: objectID) } 35 | #endif 36 | #if os(iOS) 37 | .listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16)) 38 | #endif 39 | } 40 | } 41 | footerView 42 | .listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16)) 43 | } 44 | 45 | @ViewBuilder 46 | private var footerView: some View { 47 | if let session = viewModel.previousSession { 48 | Button(action: { viewModel.buttonShowPreviousSessionTapped(for: session) }) { 49 | Text("Show Previous Session") 50 | .font(.subheadline) 51 | .foregroundColor(.accentColor) 52 | Spacer() 53 | Text(session.formattedDate(isCompact: false)) 54 | .font(.subheadline) 55 | .foregroundColor(.secondary) 56 | } 57 | .buttonStyle(.plain) 58 | #if os(iOS) || os(visionOS) 59 | .listRowSeparator(.hidden, edges: .bottom) 60 | #endif 61 | } 62 | } 63 | } 64 | 65 | #if os(iOS) || os(visionOS) 66 | @available(iOS 16, visionOS 1, *) 67 | package struct ConsoleStaticList: View { 68 | package let entities: [NSManagedObject] 69 | 70 | package init(entities: [NSManagedObject]) { 71 | self.entities = entities 72 | } 73 | 74 | package var body: some View { 75 | List { 76 | ForEach(entities, id: \.objectID, content: ConsoleEntityCell.init) 77 | } 78 | .listStyle(.plain) 79 | #if os(iOS) || os(visionOS) 80 | .environment(\.defaultMinListRowHeight, 8) 81 | #endif 82 | } 83 | } 84 | #endif 85 | 86 | #endif 87 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Console/List/ConsoleListOptions.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | import Pulse 7 | 8 | struct ConsoleListOptions: Equatable { 9 | var messageSortBy: MessageSortBy = .dateCreated 10 | var taskSortBy: TaskSortBy = .dateCreated 11 | var order: Ordering = .descending 12 | 13 | enum Ordering: String, CaseIterable { 14 | case descending = "Descending" 15 | case ascending = "Ascending" 16 | } 17 | 18 | enum MessageSortBy: String, CaseIterable { 19 | case dateCreated = "Date" 20 | case level = "Level" 21 | 22 | var key: String { 23 | switch self { 24 | case .dateCreated: return "createdAt" 25 | case .level: return "level" 26 | } 27 | } 28 | } 29 | 30 | enum TaskSortBy: String, CaseIterable { 31 | case dateCreated = "Date" 32 | case duration = "Duration" 33 | case requestSize = "Request Size" 34 | case responseSize = "Response Size" 35 | 36 | var key: String { 37 | switch self { 38 | case .dateCreated: return "createdAt" 39 | case .duration: return "duration" 40 | case .requestSize: return "requestBodySize" 41 | case .responseSize: return "responseBodySize" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Console/List/ConsoleListView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | import CoreData 9 | import Pulse 10 | import Combine 11 | 12 | @available(iOS 16, visionOS 1, *) 13 | struct ConsoleListView: View { 14 | @EnvironmentObject var environment: ConsoleEnvironment 15 | @EnvironmentObject var filters: ConsoleFiltersViewModel 16 | 17 | var body: some View { 18 | _InternalConsoleListView(environment: environment, filters: filters) 19 | } 20 | } 21 | 22 | @available(iOS 16, visionOS 1, *) 23 | private struct _InternalConsoleListView: View { 24 | private let environment: ConsoleEnvironment 25 | 26 | @StateObject private var listViewModel: IgnoringUpdates 27 | @StateObject private var searchBarViewModel: ConsoleSearchBarViewModel 28 | @StateObject private var searchViewModel: IgnoringUpdates 29 | 30 | init(environment: ConsoleEnvironment, filters: ConsoleFiltersViewModel) { 31 | self.environment = environment 32 | 33 | let listViewModel = ConsoleListViewModel(environment: environment, filters: filters) 34 | let searchBarViewModel = ConsoleSearchBarViewModel() 35 | let searchViewModel = ConsoleSearchViewModel(environment: environment, source: listViewModel, searchBar: searchBarViewModel) 36 | 37 | _listViewModel = StateObject(wrappedValue: IgnoringUpdates(listViewModel)) 38 | _searchBarViewModel = StateObject(wrappedValue: searchBarViewModel) 39 | _searchViewModel = StateObject(wrappedValue: IgnoringUpdates(searchViewModel)) 40 | } 41 | 42 | var body: some View { 43 | contents 44 | .environmentObject(listViewModel.value) 45 | .environmentObject(searchViewModel.value) 46 | .environmentObject(searchBarViewModel) 47 | .onAppear { listViewModel.value.isViewVisible = true } 48 | .onDisappear { listViewModel.value.isViewVisible = false } 49 | } 50 | 51 | @ViewBuilder private var contents: some View { 52 | _ConsoleListView() 53 | .environment(\.defaultMinListRowHeight, 8) 54 | .searchable(text: $searchBarViewModel.text) 55 | .textInputAutocapitalization(.never) 56 | .onSubmit(of: .search, searchViewModel.value.onSubmitSearch) 57 | .disableAutocorrection(true) 58 | } 59 | } 60 | 61 | @available(iOS 16, visionOS 1, *) 62 | private struct _ConsoleListView: View { 63 | @Environment(\.isSearching) private var isSearching 64 | @Environment(\.store) private var store 65 | 66 | var body: some View { 67 | List { 68 | if isSearching { 69 | ConsoleSearchListContentView() 70 | } else { 71 | ConsoleToolbarView() 72 | .listRowSeparator(.hidden, edges: .all) 73 | .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 8, trailing: 16)) 74 | ConsoleListContentView() 75 | } 76 | } 77 | .listStyle(.plain) 78 | } 79 | } 80 | 81 | #endif 82 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Console/Views/ConsoleHelperViews.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | struct ConsoleTimestampView: View { 9 | let date: Date 10 | 11 | var body: some View { 12 | Text(ConsoleMessageCell.timeFormatter.string(from: date)) 13 | #if os(tvOS) 14 | .font(.system(size: 21)) 15 | #else 16 | .font(.caption) 17 | #endif 18 | .monospacedDigit() 19 | .tracking(-0.5) 20 | .lineLimit(1) 21 | .foregroundStyle(.secondary) 22 | } 23 | } 24 | 25 | struct MockBadgeView: View { 26 | var body: some View { 27 | Text("MOCK") 28 | .foregroundStyle(.background) 29 | .font(.caption2.weight(.semibold)) 30 | .padding(EdgeInsets(top: 2, leading: 5, bottom: 1, trailing: 5)) 31 | .background(Color.secondary.opacity(0.66)) 32 | .clipShape(Capsule()) 33 | } 34 | } 35 | 36 | struct StatusIndicatorView: View { 37 | let state: NetworkTaskEntity.State? 38 | 39 | var body: some View { 40 | Image(systemName: "circle.fill") 41 | .foregroundStyle(color) 42 | #if os(tvOS) 43 | .font(.system(size: 12)) 44 | #else 45 | .font(.system(size: 9)) 46 | #endif 47 | .clipShape(RoundedRectangle(cornerRadius: 3)) 48 | } 49 | 50 | private var color: Color { 51 | guard let state else { 52 | return .secondary 53 | } 54 | switch state { 55 | case .pending: return .orange 56 | case .success: return .green 57 | case .failure: return .red 58 | } 59 | } 60 | } 61 | 62 | struct BookmarkIconView: View { 63 | var body: some View { 64 | Image(systemName: "bookmark.fill") 65 | .font(.footnote) 66 | .foregroundColor(.pink) 67 | .frame(width: 8, height: 8) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/FileViewer/FileViewerViewModel.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import CoreData 7 | import Pulse 8 | import Combine 9 | 10 | #if os(iOS) || os(macOS) || os(visionOS) 11 | import PDFKit 12 | #endif 13 | 14 | final class FileViewerViewModel: ObservableObject { 15 | let title: String 16 | private let context: FileViewerViewModelContext 17 | var contentType: NetworkLogger.ContentType? { context.contentType } 18 | private let getData: () -> Data 19 | 20 | private(set) lazy var contents: Contents = render(data: getData()) 21 | 22 | init(title: String, context: FileViewerViewModelContext, data: @escaping () -> Data) { 23 | self.title = title 24 | self.context = context 25 | self.getData = data 26 | } 27 | 28 | enum Contents { 29 | case image(ImagePreviewViewModel) 30 | case other(RichTextViewModel) 31 | #if os(iOS) || os(macOS) || os(visionOS) 32 | case pdf(PDFDocument) 33 | #endif 34 | } 35 | 36 | private func render(data: Data) -> Contents { 37 | if contentType?.isImage ?? false, let image = UXImage(data: data) { 38 | return .image(ImagePreviewViewModel(image: image, data: data, context: context)) 39 | } else if contentType?.isPDF ?? false, let pdf = makePDF(data: data) { 40 | return pdf 41 | } else { 42 | let string = TextRenderer().render(data, contentType: contentType, error: context.error) 43 | return .other(RichTextViewModel(string: string, contentType: contentType)) 44 | } 45 | } 46 | 47 | private func makePDF(data: Data) -> Contents? { 48 | #if os(iOS) || os(macOS) || os(visionOS) 49 | if let pdf = PDFDocument(data: data) { 50 | return .pdf(pdf) 51 | } 52 | #endif 53 | return nil 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/FileViewer/RichTextView/RichTextView-tvos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RichTextView-tvos.swift 3 | // Pulse 4 | // 5 | // Created by seunghwan Lee on 12/10/24. 6 | // 7 | 8 | #if os(tvOS) 9 | import SwiftUI 10 | import UIKit 11 | 12 | struct ScrollableTextView: UIViewRepresentable { 13 | private let text: String? 14 | private let attributedText: AttributedString? 15 | 16 | init(text: String? = nil, attributedText: AttributedString? = nil) { 17 | self.text = text 18 | self.attributedText = attributedText 19 | } 20 | 21 | func makeUIView(context: UIViewRepresentableContext) -> UITextView { 22 | let textView = UITextView() 23 | textView.isUserInteractionEnabled = true 24 | textView.panGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] 25 | 26 | if let attributedText { 27 | textView.attributedText = NSAttributedString(attributedText) 28 | } else if let text { 29 | textView.text = text 30 | } 31 | 32 | return textView 33 | } 34 | 35 | func updateUIView(_ uiView: UITextView, context: Context) { 36 | // Do nothing 37 | } 38 | } 39 | 40 | struct RichTextView: View { 41 | let viewModel: RichTextViewModel 42 | 43 | var body: some View { 44 | if let attributedText = viewModel.attributedString { 45 | ScrollableTextView(attributedText: attributedText) 46 | } else { 47 | ScrollableTextView(text: viewModel.text) 48 | } 49 | } 50 | } 51 | #endif 52 | 53 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/FileViewer/RichTextView/RichTextView-watchos.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | #if os(watchOS) || os(macOS) 9 | 10 | struct RichTextView: View { 11 | let viewModel: RichTextViewModel 12 | 13 | var body: some View { 14 | ScrollView { 15 | if let string = viewModel.attributedString { 16 | Text(string) 17 | } else { 18 | Text(viewModel.text) 19 | } 20 | } 21 | #if os(watchOS) 22 | .toolbar { 23 | if #available(watchOS 9, *) { 24 | ShareLink(item: viewModel.text) 25 | } 26 | } 27 | #endif 28 | } 29 | } 30 | #endif 31 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/FileViewer/RichTextView/RichTextViewSearchToobar-ios.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | 9 | struct RichTextViewSearchToobar: View { 10 | @ObservedObject var viewModel: RichTextViewModel 11 | 12 | @State private var isRealMenuShown = false 13 | 14 | var body: some View { 15 | HStack(alignment: .center, spacing: 24) { 16 | options 17 | stepper 18 | } 19 | .padding(12) 20 | .background(Material.regular) 21 | .cornerRadius(8) 22 | .onReceive(Keyboard.isHidden) { _ in 23 | // Show a non-interactive placeholder during animation, 24 | // then show the actual menu when navigation is settled. 25 | withAnimation(nil) { 26 | isRealMenuShown = false 27 | } 28 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(750)) { 29 | withAnimation(nil) { 30 | isRealMenuShown = true 31 | } 32 | } 33 | } 34 | } 35 | 36 | private var options: some View { 37 | ZStack { 38 | Image(systemName: "ellipsis.circle") 39 | .foregroundColor(.accentColor) 40 | .font(.system(size: 20)) 41 | .opacity(isRealMenuShown ? 0 : 1) 42 | if isRealMenuShown { 43 | Menu(content: { 44 | StringSearchOptionsMenu(options: $viewModel.searchOptions, isKindNeeded: false) 45 | }, label: { 46 | Image(systemName: "ellipsis.circle") 47 | .font(.system(size: 20)) 48 | }) 49 | .menuStyle(.borderlessButton) 50 | .fixedSize() 51 | } 52 | } 53 | } 54 | 55 | private var stepper: some View { 56 | HStack(spacing: 12) { 57 | Button(action: viewModel.previousMatch) { 58 | Image(systemName: "chevron.left.circle") 59 | .font(.system(size: 20)) 60 | }.disabled(viewModel.matches.isEmpty) 61 | Text(viewModel.matches.isEmpty ? "0 of 0" : "\(viewModel.selectedMatchIndex+1) of \(viewModel.matches.count)") 62 | .font(Font.body.monospacedDigit()) 63 | Button(action: viewModel.nextMatch) { 64 | Image(systemName: "chevron.right.circle") 65 | .font(.system(size: 20)) 66 | }.disabled(viewModel.matches.isEmpty) 67 | } 68 | .fixedSize() 69 | } 70 | } 71 | 72 | #endif 73 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Filters/Cells/ConsoleSearchLogLevelsCell.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | package struct ConsoleSearchLogLevelsCell: View { 9 | @Binding package var selection: Set 10 | 11 | package init(selection: Binding>) { 12 | self._selection = selection 13 | } 14 | 15 | var isAllSelected: Bool { 16 | selection.count == LoggerStore.Level.allCases.count 17 | } 18 | 19 | func toggleSelectAll() { 20 | if isAllSelected { 21 | selection = [] 22 | } else { 23 | selection = Set(LoggerStore.Level.allCases) 24 | } 25 | } 26 | 27 | func binding(forLevel level: LoggerStore.Level) -> Binding { 28 | Binding(get: { 29 | self.selection.contains(level) 30 | }, set: { isOn in 31 | if isOn { 32 | self.selection.insert(level) 33 | } else { 34 | self.selection.remove(level) 35 | } 36 | }) 37 | } 38 | 39 | #if os(macOS) 40 | package var body: some View { 41 | VStack(alignment: .leading, spacing: -16) { 42 | HStack { 43 | Spacer() 44 | Button(isAllSelected ? "Deselect All" : "Select All", action: toggleSelectAll) 45 | } 46 | HStack(spacing: 24) { 47 | makeLevelsSection(levels: [.trace, .debug, .info]) 48 | makeLevelsSection(levels: [.notice, .warning]) 49 | makeLevelsSection(levels: [.error, .critical]) 50 | } 51 | .fixedSize() 52 | } 53 | } 54 | 55 | private func makeLevelsSection(levels: [LoggerStore.Level]) -> some View { 56 | VStack(alignment: .leading) { 57 | Spacer() 58 | ForEach(levels, id: \.self) { level in 59 | Toggle(level.name.capitalized, isOn: binding(forLevel: level)) 60 | } 61 | } 62 | } 63 | #else 64 | package var body: some View { 65 | Section { 66 | ForEach(LoggerStore.Level.allCases, id: \.self) { level in 67 | HStack { 68 | Checkbox(level.name.capitalized, isOn: binding(forLevel: level)) 69 | #if os(iOS) || os(visionOS) 70 | Circle() 71 | .frame(width: 8, height: 8) 72 | .foregroundColor(Color.textColor(for: level)) 73 | #endif 74 | } 75 | } 76 | } 77 | Section { 78 | Button(isAllSelected ? "Deselect All" : "Select All", action: toggleSelectAll) 79 | } 80 | } 81 | #endif 82 | } 83 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Filters/Cells/ConsoleSearchTimePeriodCell.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(macOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | struct ConsoleSearchTimePeriodCell: View { 11 | @Binding var selection: ConsoleFilters.Dates 12 | 13 | var body: some View { 14 | DateRangePicker(title: "Start", date: $selection.startDate) 15 | DateRangePicker(title: "End", date: $selection.endDate) 16 | quickFilters 17 | } 18 | 19 | @ViewBuilder 20 | private var quickFilters: some View { 21 | HStack(alignment: .center, spacing: 8) { 22 | Text("Quick Filters") 23 | .lineLimit(1) 24 | .foregroundColor(.secondary) 25 | .frame(maxWidth: .infinity, alignment: .leading) 26 | Button("Recent") { selection = .recent } 27 | Button("Today") { selection = .today } 28 | } 29 | .buttonStyle(.plain) 30 | .padding(.top, 4) 31 | .foregroundColor(.accentColor) 32 | } 33 | } 34 | 35 | #endif 36 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Filters/Cells/ConsoleSearchToggleCell.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | package struct ConsoleSearchToggleCell: View { 9 | package let title: String 10 | @Binding package var isOn: Bool 11 | 12 | package init(title: String, isOn: Binding) { 13 | self.title = title 14 | self._isOn = isOn 15 | } 16 | 17 | package var body: some View { 18 | #if os(macOS) 19 | HStack { 20 | Toggle(title, isOn: $isOn) 21 | Spacer() 22 | } 23 | #else 24 | Toggle(title, isOn: $isOn) 25 | #endif 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Filters/ConsoleFilterViewModel.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import CoreData 6 | import Pulse 7 | import Combine 8 | import SwiftUI 9 | 10 | final class ConsoleFiltersViewModel: ObservableObject { 11 | @Published var mode: ConsoleMode = .all 12 | @Published var options = ConsoleDataSource.PredicateOptions() 13 | 14 | var criteria: ConsoleFilters { 15 | get { options.filters } 16 | set { options.filters = newValue } 17 | } 18 | 19 | let defaultCriteria: ConsoleFilters 20 | 21 | // TODO: Refactor 22 | let entities = CurrentValueSubject<[NSManagedObject], Never>([]) 23 | 24 | init(options: ConsoleDataSource.PredicateOptions) { 25 | self.options = options 26 | self.defaultCriteria = options.filters 27 | } 28 | 29 | // MARK: Helpers 30 | 31 | func isDefaultFilters(for mode: ConsoleMode) -> Bool { 32 | guard criteria.shared == defaultCriteria.shared else { return false } 33 | if mode == .network { 34 | return criteria.network == defaultCriteria.network 35 | } else { 36 | return criteria.messages == defaultCriteria.messages 37 | } 38 | } 39 | 40 | func select(sessions: Set) { 41 | self.criteria.shared.sessions.selection = sessions 42 | } 43 | 44 | func resetAll() { 45 | criteria = defaultCriteria 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Filters/Filters/ConsoleFilters.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | import Pulse 7 | 8 | /// Filter the logs displayed in the console. 9 | struct ConsoleFilters: Hashable { 10 | var shared = Shared() 11 | var messages = Messages() 12 | var network = Network() 13 | 14 | struct Shared: Hashable { 15 | var sessions = Sessions() 16 | var dates = Dates() 17 | } 18 | 19 | struct Messages: Hashable { 20 | var logLevels = LogLevels() 21 | var labels = Labels() 22 | } 23 | 24 | struct Network: Hashable { 25 | var host = Host() 26 | var url = URL() 27 | } 28 | } 29 | 30 | protocol ConsoleFilterProtocol: Hashable { 31 | init() // Initializes with the default values 32 | } 33 | 34 | extension ConsoleFilters { 35 | struct Sessions: Hashable, ConsoleFilterProtocol { 36 | var selection: Set = [] 37 | } 38 | 39 | struct Dates: Hashable, ConsoleFilterProtocol { 40 | var startDate: Date? 41 | var endDate: Date? 42 | 43 | static var today: Dates { 44 | Dates(startDate: Calendar.current.startOfDay(for: Date())) 45 | } 46 | 47 | static var recent: Dates { 48 | Dates(startDate: Date().addingTimeInterval(-1200)) 49 | } 50 | } 51 | 52 | struct LogLevels: ConsoleFilterProtocol { 53 | var levels: Set = Set(LoggerStore.Level.allCases) 54 | .subtracting([LoggerStore.Level.trace]) 55 | } 56 | 57 | struct Labels: ConsoleFilterProtocol { 58 | var hidden: Set = [] 59 | var focused: String? 60 | } 61 | 62 | struct Host: ConsoleFilterProtocol { 63 | var hidden: Set = [] 64 | var focused: String? 65 | } 66 | 67 | struct URL: ConsoleFilterProtocol { 68 | var hidden: Set = [] 69 | var focused: String? 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Filters/Views/ConsoleDomainsSelectionView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | import Combine 8 | 9 | #if !os(macOS) 10 | 11 | @available(iOS 16, visionOS 1, *) 12 | struct ConsoleDomainsSelectionView: View { 13 | @ObservedObject var viewModel: ConsoleFiltersViewModel 14 | @EnvironmentObject private var index: LoggerStoreIndex 15 | 16 | var body: some View { 17 | ConsoleSearchListSelectionView( 18 | title: "Hosts", 19 | items: index.hosts.sorted(), 20 | id: \.self, 21 | selection: viewModel.bindingForHosts(index: index), 22 | description: { $0 }, 23 | label: { Text($0) } 24 | ) 25 | } 26 | } 27 | 28 | extension ConsoleFiltersViewModel { 29 | package func bindingForHosts(index: LoggerStoreIndex) -> Binding> { 30 | Binding(get: { 31 | if let focused = self.criteria.network.host.focused { 32 | return [focused] 33 | } else { 34 | return Set(index.hosts).subtracting(self.criteria.network.host.hidden) 35 | } 36 | }, set: { newValue in 37 | self.criteria.network.host.focused = nil 38 | self.criteria.network.host.hidden = [] 39 | switch newValue.count { 40 | case 1: 41 | self.criteria.network.host.focused = newValue.first! 42 | default: 43 | self.criteria.network.host.hidden = Set(index.hosts).subtracting(newValue) 44 | } 45 | }) 46 | } 47 | } 48 | 49 | #endif 50 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Filters/Views/ConsoleLabelsSelectionView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | import Combine 8 | 9 | #if !os(macOS) 10 | 11 | @available(iOS 16, visionOS 1, *) 12 | struct ConsoleLabelsSelectionView: View { 13 | @ObservedObject var viewModel: ConsoleFiltersViewModel 14 | @EnvironmentObject private var index: LoggerStoreIndex 15 | 16 | var body: some View { 17 | ConsoleSearchListSelectionView( 18 | title: "Labels", 19 | items: index.labels.sorted(), 20 | id: \.self, 21 | selection: viewModel.bindingForSelectedLabels(index: index), 22 | description: { $0 }, 23 | label: { Text($0) } 24 | ) 25 | } 26 | } 27 | 28 | #endif 29 | 30 | extension ConsoleFiltersViewModel { 31 | package func bindingForSelectedLabels(index: LoggerStoreIndex) -> Binding> { 32 | Binding(get: { 33 | if let focused = self.criteria.messages.labels.focused { 34 | return [focused] 35 | } else { 36 | return Set(index.labels).subtracting(self.criteria.messages.labels.hidden) 37 | } 38 | }, set: { newValue in 39 | self.criteria.messages.labels.focused = nil 40 | self.criteria.messages.labels.hidden = [] 41 | switch newValue.count { 42 | case 1: 43 | self.criteria.messages.labels.focused = newValue.first! 44 | default: 45 | self.criteria.messages.labels.hidden = Set(index.labels).subtracting(newValue) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Filters/Views/ConsoleSearchSectionHeader.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | 7 | #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) 8 | 9 | struct ConsoleSectionHeader: View { 10 | let icon: String 11 | let title: String 12 | let reset: () -> Void 13 | let isDefault: Bool 14 | 15 | init( 16 | icon: String, 17 | title: String, 18 | filter: Binding, 19 | default: Filter? = nil 20 | ) { 21 | self.icon = icon 22 | self.title = title 23 | self.reset = { filter.wrappedValue = `default` ?? Filter() } 24 | self.isDefault = filter.wrappedValue == `default` ?? Filter() 25 | } 26 | 27 | var body: some View { 28 | HStack { 29 | Text(title) 30 | if !isDefault { 31 | Button(action: reset) { 32 | Image(systemName: "arrow.uturn.left") 33 | } 34 | .padding(.bottom, 3) 35 | } else { 36 | Button(action: {}) { 37 | Image(systemName: "arrow.uturn.left") 38 | } 39 | .padding(.bottom, 3) 40 | .hidden() 41 | .accessibilityHidden(true) 42 | } 43 | } 44 | } 45 | } 46 | 47 | #endif 48 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Filters/Views/ConsoleSection.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | 7 | package struct ConsoleSection: View { 8 | package var isDividerHidden = false 9 | @ViewBuilder package var header: () -> Header 10 | @ViewBuilder package var content: () -> Content 11 | 12 | package init( 13 | isDividerHidden: Bool = false, 14 | @ViewBuilder header: @escaping () -> Header, 15 | @ViewBuilder content: @escaping () -> Content 16 | ) { 17 | self.isDividerHidden = isDividerHidden 18 | self.header = header 19 | self.content = content 20 | } 21 | 22 | package var body: some View { 23 | #if os(macOS) 24 | Section(content: { 25 | VStack(spacing: 8) { 26 | content() 27 | } 28 | .padding(.horizontal, 12) 29 | .padding(.vertical, 4) 30 | }, header: { 31 | VStack(spacing: 0) { 32 | if !isDividerHidden { 33 | Divider() 34 | } 35 | header() 36 | .padding(.top, 8) 37 | .padding(.horizontal, 12) 38 | } 39 | }) 40 | #else 41 | Section(content: content, header: header) 42 | #endif 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Filters/Views/ConsoleSessionsPickerView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | import CoreData 8 | 9 | @available(iOS 16, macOS 13, visionOS 1, *) 10 | package struct ConsoleSessionsPickerView: View { 11 | @Binding var selection: Set 12 | @State private var isShowingPicker = false 13 | 14 | @Environment(\.store) private var store: LoggerStore 15 | 16 | #if os(iOS) || os(visionOS) || os(macOS) 17 | package static var makeSessionPicker: (_ selection: Binding>) -> AnyView = { 18 | #if os(macOS) 19 | AnyView(EmptyView()) // Has to be injected 20 | #else 21 | AnyView(SessionPickerView(selection: $0)) 22 | #endif 23 | } 24 | #endif 25 | 26 | #if os(watchOS) || os(tvOS) 27 | @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \LoggerSessionEntity.createdAt, ascending: false)]) 28 | private var sessions: FetchedResults 29 | #endif 30 | 31 | package var body: some View { 32 | #if os(iOS) || os(visionOS) 33 | NavigationLink(destination: ConsoleSessionsPickerView.makeSessionPicker($selection)) { 34 | InfoRow(title: "Sessions", details: selectedSessionTitle) 35 | } 36 | #elseif os(macOS) 37 | HStack { 38 | Text(selectedSessionTitle) 39 | .lineLimit(1) 40 | .foregroundColor(.secondary) 41 | Spacer() 42 | Button("Select...") { isShowingPicker = true } 43 | } 44 | .popover(isPresented: $isShowingPicker, arrowEdge: .trailing) { 45 | ConsoleSessionsPickerView.makeSessionPicker($selection) 46 | .frame(width: 260, height: 370) 47 | 48 | } 49 | #else 50 | ConsoleSearchListSelectionView( 51 | title: "Sessions", 52 | items: sessions, 53 | id: \.id, 54 | selection: $selection, 55 | description: \.formattedDate, 56 | label: { ConsoleSessionCell(session: $0, isCompact: false) }, 57 | limit: 3 58 | ) 59 | #endif 60 | } 61 | 62 | private var selectedSessionTitle: String { 63 | if selection.isEmpty { 64 | return "None" 65 | } else if selection == [store.session.id] { 66 | return "Current" 67 | } else if selection.count == 1, let session = session(withID: selection.first!) { 68 | return session.formattedDate 69 | } else { 70 | #if os(macOS) 71 | return "\(selection.count) Sessions Selected" 72 | #else 73 | return "\(selection.count)" 74 | #endif 75 | } 76 | } 77 | 78 | private func session(withID id: UUID) -> LoggerSessionEntity? { 79 | let request = NSFetchRequest(entityName: String(describing: LoggerSessionEntity.self)) 80 | request.predicate = NSPredicate(format: "id == %@", id as NSUUID) 81 | request.fetchLimit = 1 82 | return try? store.viewContext.fetch(request).first 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/Cells/NetworkCURLCell.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(tvOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | struct NetworkCURLCell: View { 11 | let task: NetworkTaskEntity 12 | 13 | var body: some View { 14 | NavigationLink(destination: destination) { 15 | NetworkMenuCell( 16 | icon: "terminal.fill", 17 | tintColor: .secondary, 18 | title: "cURL Representation", 19 | details: "" 20 | ) 21 | } 22 | } 23 | 24 | private var destination: some View { 25 | let curl = task.cURLDescription() 26 | let string = TextRenderer().render(curl, role: .body2, style: .monospaced) 27 | let viewModel = RichTextViewModel(string: string) 28 | viewModel.isLinkDetectionEnabled = false 29 | return RichTextView(viewModel: viewModel) 30 | .navigationTitle("cURL Representation") 31 | } 32 | } 33 | 34 | #endif 35 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/Cells/NetworkHeadersCell.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if !os(macOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | struct NetworkHeadersCell: View { 11 | let viewModel: NetworkHeadersCellViewModel 12 | 13 | var body: some View { 14 | NavigationLink(destination: destination) { 15 | NetworkMenuCell( 16 | icon: "list.bullet.rectangle.portrait.fill", 17 | tintColor: .secondary, 18 | title: viewModel.title, 19 | details: viewModel.details 20 | ) 21 | } 22 | .foregroundColor(viewModel.isEnabled ? nil : .secondary) 23 | .disabled(!viewModel.isEnabled) 24 | } 25 | 26 | private var destination: some View { 27 | NetworkDetailsView(title: viewModel.title) { viewModel.detailsViewModel } 28 | } 29 | } 30 | 31 | struct NetworkHeadersCellViewModel { 32 | let title: String 33 | let details: String 34 | let isEnabled: Bool 35 | 36 | var detailsViewModel: KeyValueSectionViewModel { 37 | KeyValueSectionViewModel.makeHeaders(title: title, headers: headers) 38 | } 39 | 40 | private let headers: [String: String] 41 | 42 | init(title: String, headers: [String: String]?) { 43 | self.title = title 44 | let headers = headers ?? [:] 45 | self.details = "\(headers.count)" 46 | self.isEnabled = !headers.isEmpty 47 | self.headers = headers 48 | } 49 | } 50 | 51 | #if DEBUG 52 | struct NetworkHeadersCell_Previews: PreviewProvider { 53 | static var previews: some View { 54 | NavigationView { 55 | List { 56 | ForEach(MockTask.allEntities, id: \.objectID) { task in 57 | Section { 58 | Text(task.url ?? "–") 59 | NetworkHeadersCell(viewModel: .init(title: "Original Request Headers", headers: task.originalRequest?.headers)) 60 | NetworkHeadersCell(viewModel: .init(title: "Current Request Headers", headers: task.currentRequest?.headers)) 61 | NetworkHeadersCell(viewModel: .init(title: "Response Headers", headers: task.response?.headers)) 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | #endif 69 | 70 | #endif 71 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/Cells/NetworkMenuCell.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | struct NetworkMenuCell: View { 9 | let icon: String 10 | let tintColor: Color 11 | let title: String 12 | var details: String = "" 13 | 14 | var body: some View { 15 | #if os(watchOS) 16 | HStack { 17 | HStack { 18 | Text(title) 19 | Spacer() 20 | Text(details).foregroundColor(.secondary) 21 | } 22 | } 23 | #elseif os(tvOS) 24 | HStack { 25 | Label(title, systemImage: icon) 26 | Spacer() 27 | Text(details).foregroundColor(.secondary) 28 | } 29 | #elseif os(macOS) 30 | HStack { 31 | Label(title, systemImage: icon) 32 | Spacer() 33 | Text(details).foregroundColor(.secondary) 34 | }.padding(.vertical, 1) 35 | #else 36 | HStack { 37 | Image(systemName: icon) 38 | .foregroundColor(tintColor) 39 | .font(.system(size: 20)) 40 | .frame(width: 27, alignment: .center) 41 | Text(title) 42 | Spacer() 43 | Text(details).foregroundColor(.secondary) 44 | } 45 | #endif 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/Cells/NetworkMetricsCell.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if !os(watchOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | @available(iOS 16, visionOS 1, macOS 13, *) 11 | struct NetworkMetricsCell: View { 12 | let task: NetworkTaskEntity 13 | 14 | var body: some View { 15 | NavigationLink(destination: destinationMetrics) { 16 | NetworkMenuCell( 17 | icon: "clock.fill", 18 | tintColor: .orange, 19 | title: "Metrics", 20 | details: "" 21 | ) 22 | }.disabled(!task.hasMetrics) 23 | } 24 | 25 | private var destinationMetrics: some View { 26 | NetworkInspectorMetricsViewModel(task: task).map { 27 | NetworkInspectorMetricsView(viewModel: $0) 28 | } 29 | } 30 | } 31 | 32 | #endif 33 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/Cells/NetworkRequestBodyCell.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if !os(macOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | struct NetworkRequestBodyCell: View { 11 | let viewModel: NetworkRequestBodyCellViewModel 12 | 13 | var body: some View { 14 | NavigationLink(destination: destination) { 15 | NetworkMenuCell( 16 | icon: "arrow.up.circle.fill", 17 | tintColor: .blue, 18 | title: "Request Body", 19 | details: viewModel.details 20 | ) 21 | } 22 | .foregroundColor(viewModel.isEnabled ? nil : .secondary) 23 | .disabled(!viewModel.isEnabled) 24 | } 25 | 26 | private var destination: some View { 27 | NetworkInspectorRequestBodyView(viewModel: viewModel.detailsViewModel) 28 | } 29 | } 30 | 31 | struct NetworkRequestBodyCellViewModel { 32 | let details: String 33 | let isEnabled: Bool 34 | let detailsViewModel: NetworkInspectorRequestBodyViewModel 35 | 36 | init(task: NetworkTaskEntity) { 37 | let size = task.requestBodySize 38 | self.details = size > 0 ? ByteCountFormatter.string(fromByteCount: size) : "Empty" 39 | self.isEnabled = size > 0 40 | self.detailsViewModel = NetworkInspectorRequestBodyViewModel(task: task) 41 | } 42 | } 43 | 44 | #endif 45 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/Cells/NetworkRequestInfoCell.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | struct NetworkRequestInfoCell: View { 9 | let viewModel: NetworkRequestInfoCellViewModel 10 | 11 | var body: some View { 12 | NavigationLink(destination: destinationRequestDetails) { 13 | contents 14 | } 15 | } 16 | 17 | private var contents: some View { 18 | (Text(viewModel.httpMethod).fontWeight(.semibold).font(.callout.smallCaps()) + Text(" ") + Text(viewModel.url)) 19 | .lineLimit(4) 20 | .font(.callout) 21 | } 22 | 23 | private var destinationRequestDetails: some View { 24 | NetworkDetailsView(title: "Request") { viewModel.render() } 25 | } 26 | } 27 | 28 | package final class NetworkRequestInfoCellViewModel { 29 | package let httpMethod: String 30 | package let url: String 31 | package let render: () -> NSAttributedString 32 | 33 | package init(task: NetworkTaskEntity, store: LoggerStore) { 34 | self.httpMethod = task.httpMethod ?? "GET" 35 | self.url = task.url ?? "–" 36 | self.render = { 37 | TextRenderer(options: .sharing).make { 38 | $0.render(task, content: .all, store: store) 39 | } 40 | } 41 | } 42 | 43 | package init(transaction: NetworkTransactionMetricsEntity) { 44 | self.httpMethod = transaction.request.httpMethod ?? "GET" 45 | self.url = transaction.request.url ?? "–" 46 | self.render = { TextRenderer(options: .sharing).make { $0.render(transaction) } } 47 | } 48 | } 49 | 50 | #if DEBUG 51 | struct NetworkRequestInfoCell_Previews: PreviewProvider { 52 | static var previews: some View { 53 | NavigationView { 54 | List { 55 | ForEach(MockTask.allEntities, id: \.objectID) { task in 56 | NetworkRequestInfoCell(viewModel: .init(task: task, store: .mock)) 57 | } 58 | } 59 | #if os(macOS) 60 | .frame(width: 260) 61 | #endif 62 | } 63 | } 64 | } 65 | #endif 66 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/Cells/NetworkRequestStatusSectionView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if !os(macOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | @available(iOS 16, visionOS 1, *) 11 | struct NetworkRequestStatusSectionView: View { 12 | let viewModel: NetworkRequestStatusSectionViewModel 13 | 14 | var body: some View { 15 | NetworkRequestStatusCell(viewModel: viewModel.status) 16 | if let description = viewModel.errorDescription { 17 | NavigationLink(destination: destinaitionError) { 18 | Text(description) 19 | .lineLimit(4) 20 | .font(.callout) 21 | } 22 | } 23 | NetworkRequestInfoCell(viewModel: viewModel.requestViewModel) 24 | } 25 | 26 | @ViewBuilder 27 | private var destinaitionError: some View { 28 | NetworkDetailsView(title: "Error") { viewModel.errorDetailsViewModel } 29 | } 30 | } 31 | 32 | final class NetworkRequestStatusSectionViewModel { 33 | let status: NetworkRequestStatusCellModel 34 | let errorDescription: String? 35 | let requestViewModel: NetworkRequestInfoCellViewModel 36 | let errorDetailsViewModel: KeyValueSectionViewModel? 37 | 38 | init(task: NetworkTaskEntity, store: LoggerStore) { 39 | self.status = NetworkRequestStatusCellModel(task: task, store: store) 40 | self.errorDescription = task.state == .failure ? task.errorDebugDescription : nil 41 | self.requestViewModel = NetworkRequestInfoCellViewModel(task: task, store: store) 42 | self.errorDetailsViewModel = KeyValueSectionViewModel.makeErrorDetails(for: task) 43 | } 44 | } 45 | 46 | #if DEBUG 47 | @available(iOS 16, visionOS 1, *) 48 | struct NetworkRequestStatusSectionView_Previews: PreviewProvider { 49 | static var previews: some View { 50 | NavigationView { 51 | List { 52 | ForEach(MockTask.allEntities, id: \.objectID) { task in 53 | Section { 54 | NetworkRequestStatusSectionView(viewModel: .init(task: task, store: .mock)) 55 | } 56 | } 57 | } 58 | #if os(macOS) 59 | .frame(width: 260) 60 | #endif 61 | } 62 | } 63 | } 64 | #endif 65 | 66 | #endif 67 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/Cells/NetworkResponseBodyCell.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | #if !os(macOS) 9 | 10 | @available(iOS 16, visionOS 1, *) 11 | struct NetworkResponseBodyCell: View { 12 | let viewModel: NetworkResponseBodyCellViewModel 13 | 14 | var body: some View { 15 | NavigationLink(destination: destination) { 16 | NetworkMenuCell( 17 | icon: "arrow.down.circle.fill", 18 | tintColor: .indigo, 19 | title: "Response Body", 20 | details: viewModel.details 21 | ) 22 | } 23 | .foregroundColor(viewModel.isEnabled ? nil : .secondary) 24 | .disabled(!viewModel.isEnabled) 25 | } 26 | 27 | private var destination: some View { 28 | NetworkInspectorResponseBodyView(viewModel: viewModel.detailsViewModel) 29 | } 30 | } 31 | 32 | #endif 33 | 34 | struct NetworkResponseBodyCellViewModel { 35 | let details: String 36 | let isEnabled: Bool 37 | let detailsViewModel: NetworkInspectorResponseBodyViewModel 38 | 39 | init(task: NetworkTaskEntity) { 40 | let size = task.responseBodySize 41 | self.details = size > 0 ? ByteCountFormatter.string(fromByteCount: size) : "Empty" 42 | self.isEnabled = size > 0 43 | self.detailsViewModel = NetworkInspectorResponseBodyViewModel(task: task) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/NetworkDetailsView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | package struct NetworkDetailsView: View { 9 | private var title: String 10 | private let viewModel: NetworkDetailsViewModel? 11 | @State private var isShowingShareSheet = false 12 | 13 | package init(title: String, viewModel: @escaping () -> KeyValueSectionViewModel?) { 14 | self.title = title 15 | self.viewModel = NetworkDetailsViewModel { 16 | viewModel().map { viewModel in 17 | TextRenderer().render(viewModel.items, color: viewModel.color) 18 | } 19 | } 20 | } 21 | 22 | package init(title: String, text: @escaping () -> NSAttributedString?) { 23 | self.title = title 24 | self.viewModel = NetworkDetailsViewModel(text) 25 | } 26 | 27 | package var body: some View { 28 | contents.inlineNavigationTitle(title) 29 | } 30 | 31 | @ViewBuilder 32 | private var contents: some View { 33 | if let viewModel = viewModel?.text, !viewModel.isEmpty { 34 | #if !os(macOS) 35 | RichTextView(viewModel: viewModel) 36 | #endif 37 | } else { 38 | PlaceholderView(imageName: "nosign", title: "Empty") 39 | } 40 | } 41 | } 42 | 43 | final class NetworkDetailsViewModel { 44 | private(set) lazy var text = makeString().map { RichTextViewModel(string: $0) } 45 | private let makeString: () -> NSAttributedString? 46 | 47 | init(_ closure: @escaping () -> NSAttributedString?) { 48 | self.makeString = closure 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/NetworkInspectorRequestBodyView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if !os(macOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | struct NetworkInspectorRequestBodyView: View { 11 | let viewModel: NetworkInspectorRequestBodyViewModel 12 | 13 | var body: some View { 14 | contents 15 | .inlineNavigationTitle("Request Body") 16 | } 17 | 18 | @ViewBuilder 19 | private var contents: some View { 20 | if let viewModel = viewModel.fileViewModel { 21 | FileViewer(viewModel: viewModel) 22 | .onDisappear { self.viewModel.onDisappear() } 23 | } else if viewModel.task.type == .uploadTask { 24 | PlaceholderView(imageName: "arrow.up.circle", title: { 25 | var title = "Uploaded from a File" 26 | if viewModel.task.requestBodySize > 0 { 27 | title = "\(ByteCountFormatter.string(fromByteCount: viewModel.task.requestBodySize))\n\(title)" 28 | } 29 | return title 30 | }()) 31 | } else if viewModel.task.requestBodySize > 0 { 32 | PlaceholderView(imageName: "exclamationmark.circle", title: "Unavailable", subtitle: "The request body is no longer available") 33 | } else { 34 | PlaceholderView(imageName: "nosign", title: "Empty Request") 35 | } 36 | } 37 | } 38 | 39 | final class NetworkInspectorRequestBodyViewModel { 40 | private(set) lazy var fileViewModel = data.map { data in 41 | FileViewerViewModel( 42 | title: "Request Body", 43 | context: task.requestFileViewerContext, 44 | data: { data } 45 | ) 46 | } 47 | 48 | private var data: Data? { 49 | guard let data = task.requestBody?.data, !data.isEmpty else { return nil } 50 | return data 51 | } 52 | 53 | let task: NetworkTaskEntity 54 | 55 | init(task: NetworkTaskEntity) { 56 | self.task = task 57 | } 58 | 59 | func onDisappear() { 60 | task.requestBody?.reset() 61 | } 62 | } 63 | 64 | #endif 65 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/NetworkInspectorResponseBodyView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | #if !os(macOS) 9 | 10 | struct NetworkInspectorResponseBodyView: View { 11 | let viewModel: NetworkInspectorResponseBodyViewModel 12 | 13 | var body: some View { 14 | contents 15 | .inlineNavigationTitle("Response Body") 16 | } 17 | 18 | @ViewBuilder 19 | var contents: some View { 20 | if let viewModel = viewModel.fileViewModel { 21 | FileViewer(viewModel: viewModel) 22 | .onDisappear { self.viewModel.onDisappear() } 23 | } else if viewModel.task.type == .downloadTask { 24 | PlaceholderView(imageName: "arrow.down.circle", title: { 25 | var title = "Downloaded to a File" 26 | if viewModel.task.responseBodySize > 0 { 27 | title = "\(ByteCountFormatter.string(fromByteCount: viewModel.task.responseBodySize))\n\(title)" 28 | } 29 | return title 30 | }()) 31 | } else if viewModel.task.responseBodySize > 0 { 32 | PlaceholderView(imageName: "exclamationmark.circle", title: "Unavailable", subtitle: "The response body was deleted from the store to reduce its size. Increase `responseBodySizeLimit` of the store.") 33 | } else { 34 | PlaceholderView(imageName: "nosign", title: "Empty Response") 35 | } 36 | } 37 | } 38 | 39 | #endif 40 | 41 | final class NetworkInspectorResponseBodyViewModel { 42 | private(set) lazy var fileViewModel = data.map { data in 43 | FileViewerViewModel( 44 | title: "Response Body", 45 | context: task.responseFileViewerContext, 46 | data: { data } 47 | ) 48 | } 49 | 50 | private var data: Data? { 51 | guard let data = task.responseBody?.data, !data.isEmpty else { return nil } 52 | return data 53 | } 54 | 55 | let task: NetworkTaskEntity 56 | 57 | init(task: NetworkTaskEntity) { 58 | self.task = task 59 | } 60 | 61 | func onDisappear() { 62 | task.responseBody?.reset() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/NetworkInspectorView-shared.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if !os(macOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | @available(iOS 16, visionOS 1, *) 11 | extension NetworkInspectorView { 12 | @ViewBuilder 13 | static func makeRequestSection(task: NetworkTaskEntity, isCurrentRequest: Bool) -> some View { 14 | let url = URL(string: task.url ?? "") 15 | NetworkRequestBodyCell(viewModel: .init(task: task)) 16 | if isCurrentRequest { 17 | NetworkHeadersCell(viewModel: .init(title: "Request Headers", headers: task.currentRequest?.headers)) 18 | NetworkCookiesCell(viewModel: .init(title: "Request Cookies", headers: task.currentRequest?.headers, url: url)) 19 | } else { 20 | NetworkHeadersCell(viewModel: .init(title: "Request Headers", headers: task.originalRequest?.headers)) 21 | NetworkCookiesCell(viewModel: .init(title: "Request Cookies", headers: task.originalRequest?.headers, url: url)) 22 | } 23 | } 24 | 25 | @ViewBuilder 26 | static func makeResponseSection(task: NetworkTaskEntity) -> some View { 27 | let url = URL(string: task.url ?? "") 28 | NetworkResponseBodyCell(viewModel: .init(task: task)) 29 | NetworkHeadersCell(viewModel: .init(title: "Response Headers", headers: task.response?.headers)) 30 | NetworkCookiesCell(viewModel: .init(title: "Response Cookies", headers: task.response?.headers, url: url)) 31 | } 32 | 33 | @ViewBuilder 34 | static func makeHeaderView(task: NetworkTaskEntity, store: LoggerStore) -> some View { 35 | ZStack { 36 | NetworkInspectorTransferInfoView(viewModel: .init(empty: true)) 37 | .hidden() 38 | .accessibilityHidden(true) 39 | if task.hasMetrics { 40 | NetworkInspectorTransferInfoView(viewModel: .init(task: task)) 41 | } else if task.state == .pending { 42 | SpinnerView(viewModel: ProgressViewModel(task: task)) 43 | } else { 44 | // Fallback in case metrics are disabled 45 | let status = NetworkRequestStatusSectionViewModel(task: task, store: store).status 46 | Image(systemName: status.status.systemImage) 47 | .foregroundColor(status.status.tint) 48 | .font(.system(size: 64)) 49 | } 50 | } 51 | } 52 | } 53 | 54 | struct NetworkInspectorRequestTypePicker: View { 55 | @Binding var isCurrentRequest: Bool 56 | 57 | var body: some View { 58 | Picker("Request Type", selection: $isCurrentRequest) { 59 | Text("Original").tag(false) 60 | Text("Current").tag(true) 61 | } 62 | } 63 | } 64 | 65 | #endif 66 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/NetworkInspectorView-tvos.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(tvOS) 6 | 7 | import SwiftUI 8 | import CoreData 9 | import Pulse 10 | import Combine 11 | 12 | struct NetworkInspectorView: View { 13 | @ObservedObject var task: NetworkTaskEntity 14 | 15 | @ObservedObject private var settings: UserSettings = .shared 16 | @Environment(\.store) private var store 17 | @EnvironmentObject private var environment: ConsoleEnvironment 18 | 19 | var body: some View { 20 | contents 21 | .inlineNavigationTitle(task.getShortTitle(options: settings.listDisplayOptions)) 22 | } 23 | 24 | var contents: some View { 25 | HStack { 26 | Form { lhs }.frame(width: 740) 27 | Form { rhs } 28 | } 29 | .disableScrollClip() 30 | } 31 | 32 | @ViewBuilder 33 | private var lhs: some View { 34 | Section { 35 | NetworkRequestStatusSectionView(viewModel: .init(task: task, store: store)) 36 | } 37 | Section { 38 | NetworkInspectorRequestTypePicker(isCurrentRequest: $settings.isShowingCurrentRequest) 39 | NetworkInspectorView.makeRequestSection(task: task, isCurrentRequest: settings.isShowingCurrentRequest) 40 | } header: { Text("Request") } 41 | if task.state != .pending { 42 | Section { 43 | NetworkInspectorView.makeResponseSection(task: task) 44 | } header: { Text("Response") } 45 | 46 | } 47 | Section { 48 | NetworkCURLCell(task: task) 49 | } header: { Text("Transactions") } 50 | } 51 | 52 | @ViewBuilder 53 | private var rhs: some View { 54 | Section { 55 | NetworkInspectorView.makeHeaderView(task: task, store: store) 56 | .padding(.bottom, 32) 57 | } 58 | .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) 59 | .listRowBackground(Color.clear) 60 | NetworkInspectorMetricsViewModel(task: task) 61 | .map(NetworkInspectorMetricsView.init) 62 | } 63 | } 64 | 65 | #if DEBUG 66 | struct NetworkInspectorView_Previews: PreviewProvider { 67 | static var previews: some View { 68 | NavigationView { 69 | NetworkInspectorView(task: LoggerStore.preview.entity(for: .login)) 70 | } 71 | .injecting(ConsoleEnvironment(store: .preview)) 72 | } 73 | } 74 | #endif 75 | 76 | #endif 77 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Inspector/NetworkInspectorView-watchos.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(watchOS) 6 | 7 | import SwiftUI 8 | import CoreData 9 | import Pulse 10 | import Combine 11 | 12 | struct NetworkInspectorView: View { 13 | @ObservedObject var task: NetworkTaskEntity 14 | 15 | @ObservedObject private var settings: UserSettings = .shared 16 | @Environment(\.store) private var store 17 | @EnvironmentObject private var environment: ConsoleEnvironment 18 | 19 | var body: some View { 20 | contents 21 | .inlineNavigationTitle(task.getShortTitle(options: settings.listDisplayOptions)) 22 | // .toolbar { 23 | // if #available(watchOS 9, *), let url = viewModel.shareTaskAsHTML() { 24 | // ShareLink(item: url) 25 | // } 26 | // } 27 | } 28 | 29 | var contents: some View { 30 | List { 31 | Section { 32 | NetworkRequestStatusSectionView(viewModel: .init(task: task, store: store)) 33 | } 34 | Section { 35 | makeTransferInfo(isReceivedHidden: true) 36 | NetworkInspectorRequestTypePicker(isCurrentRequest: $settings.isShowingCurrentRequest) 37 | NetworkInspectorView.makeRequestSection(task: task, isCurrentRequest: settings.isShowingCurrentRequest) 38 | } 39 | if task.state != .pending { 40 | Section { 41 | makeTransferInfo(isSentHidden: true) 42 | NetworkInspectorView.makeResponseSection(task: task) 43 | } 44 | } 45 | } 46 | } 47 | 48 | @ViewBuilder 49 | private func makeTransferInfo(isSentHidden: Bool = false, isReceivedHidden: Bool = false) -> some View { 50 | if task.hasMetrics { 51 | NetworkInspectorTransferInfoView(viewModel: .init(task: task), isSentHidden: isSentHidden, isReceivedHidden: isReceivedHidden) 52 | .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) 53 | .listRowBackground(Color.clear) 54 | .padding(.top, 8) 55 | .padding(.bottom, 16) 56 | } 57 | } 58 | } 59 | 60 | #if DEBUG 61 | struct NetworkInspectorView_Previews: PreviewProvider { 62 | static var previews: some View { 63 | NavigationView { 64 | NetworkInspectorView(task: LoggerStore.preview.entity(for: .login)) 65 | }.navigationViewStyle(.stack) 66 | } 67 | } 68 | #endif 69 | 70 | #endif 71 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/MessageDetails/ConsoleMessageDetailsView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) 9 | 10 | @available(iOS 16, macOS 13, visionOS 1, *) 11 | package struct ConsoleMessageDetailsView: View { 12 | package let message: LoggerMessageEntity 13 | 14 | package init(message: LoggerMessageEntity) { 15 | self.message = message 16 | } 17 | 18 | #if os(iOS) || os(visionOS) 19 | package var body: some View { 20 | contents 21 | .inlineNavigationTitle("") 22 | .toolbar { 23 | ToolbarItemGroup(placement: .automatic) { 24 | trailingNavigationBarItems 25 | } 26 | } 27 | } 28 | 29 | @ViewBuilder 30 | private var trailingNavigationBarItems: some View { 31 | NavigationLink(destination: ConsoleMessageMetadataView(message: message)) { 32 | Image(systemName: "info.circle") 33 | } 34 | } 35 | #elseif os(watchOS) 36 | package var body: some View { 37 | ScrollView { 38 | VStack(spacing: 8) { 39 | NavigationLink(destination: ConsoleMessageMetadataView(message: message)) { 40 | Label("Details", systemImage: "info.circle") 41 | } 42 | contents 43 | } 44 | } 45 | } 46 | #elseif os(tvOS) 47 | package var body: some View { 48 | contents 49 | } 50 | #endif 51 | 52 | private var contents: some View { 53 | VStack { 54 | RichTextView(viewModel: makeTextViewModel()) 55 | }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 56 | } 57 | 58 | private func makeTextViewModel() -> RichTextViewModel { 59 | RichTextViewModel(string: TextRenderer().preformatted(message.text)) 60 | } 61 | } 62 | 63 | #if DEBUG 64 | @available(iOS 16, macOS 13, visionOS 1, *) 65 | struct ConsoleMessageDetailsView_Previews: PreviewProvider { 66 | static var previews: some View { 67 | NavigationView { 68 | ConsoleMessageDetailsView(message: makeMockMessage()) 69 | } 70 | } 71 | } 72 | #endif 73 | 74 | #endif 75 | 76 | #if DEBUG 77 | 78 | package func makeMockMessage() -> LoggerMessageEntity { 79 | let entity = LoggerMessageEntity(context: LoggerStore.mock.viewContext) 80 | entity.text = "test" 81 | entity.createdAt = Date() 82 | entity.label = "auth" 83 | entity.level = LoggerStore.Level.critical.rawValue 84 | entity.file = "LoggerStore.swift" 85 | entity.function = "createMockMessage()" 86 | entity.line = 12 87 | entity.rawMetadata = "customKey: customValue" 88 | return entity 89 | } 90 | 91 | #endif 92 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/MessageDetails/ConsoleMessageMetadataView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | 8 | @available(iOS 16, macOS 13, visionOS 1, *) 9 | struct ConsoleMessageMetadataView: View { 10 | let message: LoggerMessageEntity 11 | 12 | init(message: LoggerMessageEntity) { 13 | self.message = message 14 | } 15 | 16 | var body: some View { 17 | RichTextView(viewModel: .init(string: string)) 18 | .navigationTitle("Message Details") 19 | } 20 | 21 | private var string: NSAttributedString { 22 | let renderer = TextRenderer() 23 | let sections = KeyValueSectionViewModel.makeMetadata(for: message) 24 | renderer.render(sections) 25 | return renderer.make() 26 | } 27 | } 28 | 29 | extension KeyValueSectionViewModel { 30 | package static func makeMetadata(for message: LoggerMessageEntity) -> [KeyValueSectionViewModel] { 31 | let metadataItems: [(String, String?)] = message.metadata 32 | .sorted(by: { $0.key < $1.key }) 33 | .map { ($0.key, $0.value )} 34 | return [ 35 | KeyValueSectionViewModel(title: "Summary", color: .textColor(for: message.logLevel), items: [ 36 | ("Date", DateFormatter.fullDateFormatter.string(from: message.createdAt)), 37 | ("Level", LoggerStore.Level(rawValue: message.level)?.name), 38 | ("Label", message.label.nonEmpty) 39 | ]), 40 | KeyValueSectionViewModel(title: "Details", color: .primary, items: [ 41 | ("File", message.file.nonEmpty), 42 | ("Function", message.function.nonEmpty), 43 | ("Line", message.line == 0 ? nil : "\(message.line)") 44 | ]), 45 | KeyValueSectionViewModel(title: "Metadata", color: .indigo, items: metadataItems) 46 | ] 47 | } 48 | } 49 | 50 | private extension String { 51 | var nonEmpty: String? { 52 | isEmpty ? nil : self 53 | } 54 | } 55 | 56 | #if DEBUG 57 | @available(iOS 16, macOS 13, visionOS 1, *) 58 | struct ConsoleMessageMetadataView_Previews: PreviewProvider { 59 | static var previews: some View { 60 | NavigationView { 61 | ConsoleMessageMetadataView(message: makeMockMessage()) 62 | } 63 | } 64 | } 65 | #endif 66 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Remote/RemoteLoggerEnterPasswordView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Network 7 | import Pulse 8 | import Combine 9 | 10 | @available(iOS 16, visionOS 1, *) 11 | struct RemoteLoggerEnterPasswordView: View { 12 | @ObservedObject var viewModel: RemoteLoggerSettingsViewModel 13 | @ObservedObject var logger: RemoteLogger = .shared 14 | 15 | let server: RemoteLoggerServerViewModel 16 | 17 | @State private var passcode = "" 18 | 19 | @FocusState private var isTextFieldFocused: Bool 20 | 21 | var body: some View { 22 | Form { 23 | Section(content: { 24 | SecureField("Password", text: $passcode) 25 | .focused($isTextFieldFocused) 26 | .submitLabel(.continue) 27 | .onSubmit { 28 | connect() 29 | } 30 | }, footer: { 31 | VStack(alignment: .leading, spacing: 16) { 32 | Text("Enter the password for '\(server.name)'.") 33 | } 34 | }) 35 | } 36 | .inlineNavigationTitle("Enter Password") 37 | #if os(iOS) || os(visionOS) 38 | .toolbar { 39 | ToolbarItem(placement: .navigationBarLeading) { 40 | Button("Cancel", role: .cancel) { 41 | viewModel.pendingPasscodeProtectedServer = nil 42 | } 43 | } 44 | ToolbarItem(placement: .navigationBarTrailing) { 45 | Button("Connect") { 46 | connect() 47 | } 48 | } 49 | } 50 | #endif 51 | .onAppear { 52 | isTextFieldFocused = true 53 | } 54 | } 55 | 56 | private func connect() { 57 | viewModel.pendingPasscodeProtectedServer = nil 58 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { 59 | viewModel.connect(to: server, passcode: passcode) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Remote/RemoteLoggerSelectedDeviceView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Network 7 | import Pulse 8 | 9 | @available(iOS 16, visionOS 1, *) 10 | struct RemoteLoggerSelectedDeviceView: View { 11 | @ObservedObject var logger: RemoteLogger = .shared 12 | 13 | let name: String 14 | let server: RemoteLoggerServerViewModel? 15 | 16 | var body: some View { 17 | HStack { 18 | VStack(alignment: .leading, spacing: 2) { 19 | Text(name) 20 | makeStatusView(for: logger.connectionState) 21 | } 22 | Spacer() 23 | if server?.server.isProtected ?? false { 24 | Image(systemName: "lock.fill") 25 | .foregroundColor(.separator) 26 | } 27 | #if !os(watchOS) && !os(tvOS) 28 | Menu(content: { 29 | Button("Forget this Device", role: .destructive) { 30 | logger.forgetServer(named: name) 31 | } 32 | }, label: { 33 | Image(systemName: "ellipsis.circle") 34 | }) 35 | #if os(macOS) 36 | .menuStyle(.borderlessButton) 37 | .fixedSize() 38 | #endif 39 | #else 40 | Button(role: .destructive, action: { 41 | logger.forgetServer(named: name) 42 | }, label: { 43 | Image(systemName: "trash") 44 | .foregroundStyle(.red) 45 | }).buttonStyle(.plain) 46 | #endif 47 | } 48 | } 49 | 50 | private func makeStatusView(for state: RemoteLogger.ConnectionState) -> some View { 51 | HStack(spacing: 8) { 52 | Circle() 53 | .frame(width: circleSize, height: circleSize) 54 | .foregroundColor(statusColor) 55 | Text(statusTitle) 56 | .lineLimit(1) 57 | .font(.subheadline) 58 | .foregroundColor(.secondary) 59 | } 60 | } 61 | 62 | private var statusColor: Color { 63 | switch logger.connectionState { 64 | case .connected: return Color.green 65 | case .connecting: return Color.yellow 66 | case .disconnected: return Color.gray 67 | } 68 | } 69 | 70 | private var statusTitle: String { 71 | switch logger.connectionState { 72 | case .connected: return "Connected" 73 | case .connecting: return "Connecting..." 74 | case .disconnected: return "Disconnected" 75 | } 76 | } 77 | } 78 | 79 | #if os(tvOS) 80 | private let circleSize: CGFloat = 16 81 | #else 82 | private let circleSize: CGFloat = 8 83 | #endif 84 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Remote/RemoteLoggerSettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | import SwiftUI 7 | import Combine 8 | import Pulse 9 | import Network 10 | 11 | @MainActor 12 | final class RemoteLoggerSettingsViewModel: ObservableObject { 13 | @Published var isEnabled = false 14 | @Published var pendingPasscodeProtectedServer: RemoteLoggerServerViewModel? 15 | @Published var isShowingConnectionError = false 16 | private(set) var connectionError: RemoteLogger.ConnectionError? 17 | 18 | private let logger: RemoteLogger 19 | private var cancellables: [AnyCancellable] = [] 20 | 21 | static var shared = RemoteLoggerSettingsViewModel() 22 | 23 | init(logger: RemoteLogger? = nil) { 24 | self.logger = logger ?? .shared 25 | 26 | isEnabled = self.logger.isEnabled 27 | 28 | $isEnabled.dropFirst().removeDuplicates().receive(on: DispatchQueue.main) 29 | .throttle(for: .milliseconds(500), scheduler: DispatchQueue.main, latest: true) 30 | .sink { [weak self] in 31 | self?.didUpdateIsEnabled($0) 32 | }.store(in: &cancellables) 33 | } 34 | 35 | private func didUpdateIsEnabled(_ isEnabled: Bool) { 36 | isEnabled ? logger.enable() : logger.disable() 37 | } 38 | 39 | func connect(to viewModel: RemoteLoggerServerViewModel) { 40 | let server = viewModel.server 41 | if server.isProtected { 42 | if let passcode = server.name.flatMap(logger.getPasscode) { 43 | _connect(to: server, passcode: passcode) 44 | } else { 45 | pendingPasscodeProtectedServer = viewModel 46 | } 47 | } else { 48 | _connect(to: server) 49 | } 50 | } 51 | 52 | func connect(to viewModel: RemoteLoggerServerViewModel, passcode: String) { 53 | _connect(to: viewModel.server, passcode: passcode) 54 | } 55 | 56 | private func _connect(to server: NWBrowser.Result, passcode: String? = nil) { 57 | logger.connect(to: server, passcode: passcode) { 58 | switch $0 { 59 | case .success: 60 | break 61 | case .failure(let error): 62 | self.connectionError = error 63 | self.isShowingConnectionError = true 64 | } 65 | } 66 | } 67 | } 68 | 69 | struct RemoteLoggerServerViewModel: Identifiable { 70 | var id: NWBrowser.Result { server } 71 | let server: NWBrowser.Result 72 | let name: String 73 | let isSelected: Bool 74 | } 75 | 76 | extension NWBrowser.Result { 77 | var name: String? { 78 | switch endpoint { 79 | case .service(let name, _, _, _): 80 | return name 81 | default: 82 | return nil 83 | } 84 | } 85 | 86 | var isProtected: Bool { 87 | switch metadata { 88 | case .bonjour(let record): 89 | return record["protected"].map { Bool($0) } == true 90 | case .none: 91 | return false 92 | @unknown default: 93 | return false 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Search/ConsoleSearchListContentView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | import CoreData 10 | import Combine 11 | 12 | @available(iOS 16, visionOS 1, *) 13 | struct ConsoleSearchListContentView: View { 14 | @EnvironmentObject private var viewModel: ConsoleSearchViewModel 15 | 16 | var body: some View { 17 | ConsoleSearchToolbar() 18 | .listRowBackground(Color.clear) 19 | .listRowSeparator(.hidden, edges: .top) 20 | ConsoleSearchSuggestionsView() 21 | if viewModel.isNewResultsButtonShown { 22 | showNewResultsPromptView 23 | } 24 | ConsoleSearchResultsListContentView() 25 | } 26 | 27 | @ViewBuilder private var showNewResultsPromptView: some View { 28 | Button(action: viewModel.buttonShowNewlyAddedSearchResultsTapped) { 29 | HStack { 30 | Image(systemName: "arrow.clockwise.circle.fill") 31 | Text("New Results Added") 32 | } 33 | .font(.subheadline.weight(.medium)) 34 | .foregroundColor(.white) 35 | .padding(8) 36 | .background(Color.accentColor) 37 | .cornerRadius(8) 38 | } 39 | .listRowSeparator(.hidden) 40 | .listRowBackground(Color.separator.opacity(0.2)) 41 | .frame(maxWidth: .infinity, alignment: .center) 42 | .listRowBackground(Color.clear) 43 | } 44 | } 45 | 46 | @available(iOS 16, visionOS 1, *) 47 | struct ConsoleSearchResultsListContentView: View { 48 | @EnvironmentObject private var viewModel: ConsoleSearchViewModel 49 | 50 | var body: some View { 51 | if !viewModel.results.isEmpty { 52 | PlainListGroupSeparator() 53 | } 54 | ForEach(viewModel.results) { result in 55 | let isLast = result.id == viewModel.results.last?.id 56 | ConsoleSearchResultView(viewModel: result, isSeparatorNeeded: viewModel.parameters.term != nil && !isLast) 57 | .onAppear { 58 | viewModel.didScroll(to: result) 59 | } 60 | } 61 | if !viewModel.isSearching && !viewModel.hasMore && !viewModel.results.isEmpty { 62 | Text("No more results") 63 | .frame(maxWidth: .infinity, minHeight: 24, alignment: .center) 64 | .font(.footnote) 65 | .foregroundStyle(.secondary) 66 | .listRowSeparator(.hidden, edges: .bottom) 67 | } 68 | } 69 | } 70 | 71 | #endif 72 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Search/Services/ConsoleSearchRecentSearchesStore.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(macOS) || os(visionOS) 6 | 7 | import Foundation 8 | import SwiftUI 9 | 10 | final class ConsoleSearchRecentSearchesStore { 11 | private let mode: ConsoleMode 12 | 13 | private(set) var searches: [ConsoleSearchTerm] = [] 14 | 15 | init(mode: ConsoleMode) { 16 | self.mode = mode 17 | 18 | self.searches = decode([ConsoleSearchTerm].self, from: UserDefaults.standard.string(forKey: searchesKey) ?? "[]") ?? [] 19 | } 20 | 21 | private var searchesKey: String { "\(mode.rawValue)-recent-searches" } 22 | 23 | func saveSearch(_ search: ConsoleSearchTerm) { 24 | // If the user changes the type o the search, remove the old ones: 25 | // we only care about the term. 26 | searches.removeAll { $0.text == search.text } 27 | searches.insert(search, at: 0) 28 | saveSearches() 29 | } 30 | 31 | func clearRecentSearches() { 32 | searches = [] 33 | saveSearches() 34 | } 35 | 36 | private func saveSearches() { 37 | while searches.count > 20 { 38 | searches.removeLast() 39 | } 40 | UserDefaults.standard.set((encode(searches) ?? "[]"), forKey: searchesKey) 41 | } 42 | } 43 | 44 | private func encode(_ value: T) -> String? { 45 | (try? JSONEncoder().encode(value)).flatMap { 46 | String(data: $0, encoding: .utf8) 47 | } 48 | } 49 | 50 | private func decode(_ type: T.Type, from string: String) -> T? { 51 | string.data(using: .utf8).flatMap { 52 | try? JSONDecoder().decode(type, from: $0) 53 | } 54 | } 55 | 56 | #endif 57 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Search/Services/ConsoleSearchScope.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(macOS) || os(visionOS) 6 | 7 | import Foundation 8 | 9 | package enum ConsoleSearchScope: Equatable, Hashable, Codable, CaseIterable { 10 | // MARK: Logs 11 | case message 12 | case metadata 13 | 14 | // MARK: Network 15 | case url 16 | case originalRequestHeaders 17 | case currentRequestHeaders 18 | case requestBody 19 | case responseHeaders 20 | case responseBody 21 | 22 | package var isDisplayedInResults: Bool { 23 | switch self { 24 | case .message, .url: 25 | return false 26 | case .metadata, .originalRequestHeaders, .currentRequestHeaders, .requestBody, .responseHeaders, .responseBody: 27 | return true 28 | } 29 | } 30 | 31 | package static let messageScopes: [ConsoleSearchScope] = [ 32 | .message, 33 | .metadata 34 | ] 35 | 36 | package static let networkScopes: [ConsoleSearchScope] = [ 37 | .url, 38 | .originalRequestHeaders, 39 | .currentRequestHeaders, 40 | .requestBody, 41 | .responseHeaders, 42 | .responseBody 43 | ] 44 | 45 | package var title: String { 46 | switch self { 47 | case .url: return "URL" 48 | case .originalRequestHeaders: return "Original Request Headers" 49 | case .currentRequestHeaders: return "Current Request Headers" 50 | case .requestBody: return "Request Body" 51 | case .responseHeaders: return "Response Headers" 52 | case .responseBody: return "Response Body" 53 | case .message: return "Message" 54 | case .metadata: return "Metadata" 55 | } 56 | } 57 | } 58 | 59 | #endif 60 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Search/Services/ConsoleSearchTerm.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | 7 | package struct ConsoleSearchTerm: Identifiable, Hashable, Codable { 8 | package var id: ConsoleSearchTerm { self } 9 | 10 | package var text: String 11 | package var options: StringSearchOptions 12 | 13 | package init(text: String, options: StringSearchOptions) { 14 | self.text = text 15 | self.options = options 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Search/Views/ConsoleSearchContextMenu.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) || os(macOS) 6 | 7 | import SwiftUI 8 | import CoreData 9 | import Pulse 10 | import Combine 11 | 12 | @available(iOS 16, visionOS 1, *) 13 | struct ConsoleSearchContextMenu: View { 14 | @EnvironmentObject private var viewModel: ConsoleSearchViewModel 15 | 16 | var body: some View { 17 | Menu { 18 | StringSearchOptionsMenu(options: $viewModel.options) 19 | } label: { 20 | Image(systemName: "ellipsis.circle") 21 | .font(.system(size: 20)) 22 | .foregroundColor(.accentColor) 23 | } 24 | } 25 | } 26 | #endif 27 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Search/Views/ConsoleSearchSuggestionView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(macOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | import CoreData 10 | import Combine 11 | 12 | @available(iOS 16, visionOS 1, *) 13 | struct ConsoleSearchSuggestionView: View { 14 | let suggestion: ConsoleSearchSuggestion 15 | let action: () -> Void 16 | 17 | var body: some View { 18 | Button(action: action) { 19 | HStack { 20 | if case .apply = suggestion.action { 21 | Image(systemName: "magnifyingglass") 22 | .foregroundColor(.accentColor) 23 | } else { 24 | Image(systemName: "line.3.horizontal.decrease.circle") 25 | .foregroundColor(.secondary) 26 | } 27 | Text(suggestion.text) 28 | .lineLimit(1) 29 | Spacer() 30 | } 31 | } 32 | } 33 | } 34 | 35 | struct ShortcutTooltip: View { 36 | let title: String 37 | 38 | var body: some View { 39 | Text(title) 40 | .font(.caption) 41 | .foregroundColor(.separator) 42 | .background(Rectangle().frame(width: 34, height: 28).foregroundColor(Color.separator.opacity(0.2)).cornerRadius(8)) 43 | } 44 | } 45 | 46 | #endif 47 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Search/Views/ConsoleSearchSuggestionsView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | import CoreData 10 | import Combine 11 | 12 | @available(iOS 16, visionOS 1, *) 13 | struct ConsoleSearchSuggestionsView: View { 14 | @EnvironmentObject private var viewModel: ConsoleSearchViewModel 15 | 16 | var body: some View { 17 | let suggestions = viewModel.suggestionsViewModel! 18 | if !suggestions.searches.isEmpty { 19 | makeList(with: Array(suggestions.searches.prefix(3))) 20 | buttonClearSearchHistory 21 | } 22 | 23 | if viewModel.parameters.isEmpty { 24 | ConsoleSearchScopesPicker(viewModel: viewModel) 25 | } 26 | } 27 | 28 | private var buttonClearSearchHistory: some View { 29 | HStack { 30 | Spacer() 31 | Button(action: viewModel.buttonClearRecentSearchesTapped) { 32 | HStack { 33 | Text("Clear Search History") 34 | } 35 | .foregroundColor(.accentColor) 36 | .font(.subheadline) 37 | }.buttonStyle(.plain) 38 | } 39 | .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 20)) 40 | .listRowSeparator(.hidden, edges: .bottom) 41 | } 42 | 43 | private func makeList(with suggestions: [ConsoleSearchSuggestion]) -> some View { 44 | ForEach(suggestions) { suggestion in 45 | ConsoleSearchSuggestionView(suggestion: suggestion) { 46 | viewModel.perform(suggestion) 47 | } 48 | } 49 | } 50 | } 51 | 52 | #if DEBUG 53 | @available(iOS 16, visionOS 1, *) 54 | struct Previews_ConsoleSearchSuggestionsView_Previews: PreviewProvider { 55 | static let environment = ConsoleEnvironment(store: .mock) 56 | 57 | static var previews: some View { 58 | List { 59 | ConsoleSearchSuggestionsView() 60 | } 61 | .listStyle(.plain) 62 | .injecting(environment) 63 | .environmentObject(ConsoleSearchViewModel(environment: environment, source: ConsoleListViewModel(environment: environment, filters: .init(options: .init())), searchBar: .init())) 64 | .frame(width: 340) 65 | } 66 | } 67 | #endif 68 | 69 | #endif 70 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Search/Views/ConsoleSearchToolbar.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | import CoreData 10 | import Combine 11 | 12 | @available(iOS 16, visionOS 1, *) 13 | struct ConsoleSearchToolbar: View { 14 | @EnvironmentObject private var viewModel: ConsoleSearchViewModel 15 | 16 | var body: some View { 17 | HStack(alignment: .bottom, spacing: 0) { 18 | Text(viewModel.toolbarTitle) 19 | .foregroundColor(.secondary) 20 | .font(.subheadline.weight(.medium)) 21 | if viewModel.isSpinnerNeeded { 22 | ProgressView() 23 | .padding(.leading, 8) 24 | } 25 | Spacer() 26 | searchOptionsView 27 | } 28 | .buttonStyle(.plain) 29 | } 30 | 31 | private var searchOptionsView: some View { 32 | #if os(iOS) || os(visionOS) 33 | HStack(spacing: 14) { 34 | ConsoleSearchContextMenu() 35 | } 36 | #else 37 | StringSearchOptionsMenu(options: $viewModel.options) 38 | .fixedSize() 39 | #endif 40 | } 41 | } 42 | 43 | @available(iOS 16, visionOS 1, *) 44 | struct ConsoleSearchScopesPicker: View { 45 | @ObservedObject var viewModel: ConsoleSearchViewModel 46 | 47 | var body: some View { 48 | ForEach(viewModel.allScopes, id: \.self) { scope in 49 | Checkbox(isOn: Binding(get: { 50 | viewModel.scopes.contains(scope) 51 | }, set: { isOn in 52 | if isOn { 53 | viewModel.scopes.insert(scope) 54 | } else { 55 | viewModel.scopes.remove(scope) 56 | } 57 | }), label: { Text(scope.title).lineLimit(1) }) 58 | } 59 | } 60 | } 61 | #endif 62 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Sessions/SessionPickerView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | import Pulse 7 | import SwiftUI 8 | import CoreData 9 | import Combine 10 | 11 | #if os(iOS) || os(visionOS) 12 | 13 | @available(iOS 16, macOS 13, visionOS 1, *) 14 | struct SessionPickerView: View { 15 | @Binding var selection: Set 16 | 17 | var body: some View { 18 | SessionListView(selection: $selection, sharedSessions: .constant(nil)) 19 | .environment(\.editMode, .constant(.active)) 20 | .inlineNavigationTitle("Sessions") 21 | } 22 | } 23 | 24 | #endif 25 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Settings/SettingsView-ios.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | import UniformTypeIdentifiers 10 | 11 | @available(iOS 16, visionOS 1, *) 12 | public struct SettingsView: View { 13 | private let store: LoggerStore 14 | @State private var newHeaderName = "" 15 | @EnvironmentObject private var settings: UserSettings 16 | @ObservedObject private var logger: RemoteLogger = .shared 17 | 18 | public init(store: LoggerStore = .shared) { 19 | self.store = store 20 | } 21 | 22 | public var body: some View { 23 | Form { 24 | if !UserSettings.shared.isRemoteLoggingHidden, 25 | store === RemoteLogger.shared.store { 26 | RemoteLoggerSettingsView(viewModel: .shared) 27 | } 28 | Section("Other") { 29 | NavigationLink(destination: StoreDetailsView(source: .store(store)), label: { 30 | Text("Store Info") 31 | }) 32 | } 33 | } 34 | .animation(.default, value: logger.selectedServerName) 35 | .animation(.default, value: logger.servers) 36 | } 37 | } 38 | 39 | #if DEBUG 40 | @available(iOS 16, visionOS 1, *) 41 | struct SettingsView_Previews: PreviewProvider { 42 | static var previews: some View { 43 | NavigationView { 44 | SettingsView(store: .mock) 45 | .environmentObject(UserSettings.shared) 46 | .injecting(ConsoleEnvironment(store: .mock)) 47 | .navigationTitle("Settings") 48 | } 49 | } 50 | } 51 | #endif 52 | 53 | #endif 54 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Settings/SettingsView-macos.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(macOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | @available(macOS 13, *) 11 | struct SettingsView: View { 12 | @State private var isPresentingShareStoreView = false 13 | @State private var shareItems: ShareItems? 14 | 15 | @Environment(\.store) private var store 16 | @EnvironmentObject private var environment: ConsoleEnvironment 17 | 18 | var body: some View { 19 | List { 20 | if !UserSettings.shared.isRemoteLoggingHidden { 21 | if store === RemoteLogger.shared.store { 22 | RemoteLoggerSettingsView(viewModel: .shared) 23 | } else { 24 | Text("Not available") 25 | .foregroundColor(.secondary) 26 | } 27 | } 28 | Section("Store") { 29 | // TODO: load this info async 30 | // if #available(macOS 13, *), let info = try? store.info() { 31 | // LoggerStoreSizeChart(info: info, sizeLimit: store.configuration.sizeLimit) 32 | // } 33 | } 34 | 35 | Section { 36 | HStack { 37 | Button("Show in Finder") { 38 | NSWorkspace.shared.activateFileViewerSelecting([store.storeURL]) 39 | } 40 | if !(store.options.contains(.readonly)) { 41 | Button("Remove Logs") { 42 | store.removeAll() 43 | } 44 | } 45 | } 46 | } 47 | }.listStyle(.sidebar).scrollContentBackground(.hidden) 48 | } 49 | } 50 | 51 | // MARK: - Preview 52 | 53 | #if DEBUG 54 | @available(macOS 13, *) 55 | struct UserSettingsView_Previews: PreviewProvider { 56 | static var previews: some View { 57 | SettingsView() 58 | } 59 | } 60 | #endif 61 | #endif 62 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Settings/SettingsView-tvos.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(tvOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | public struct SettingsView: View { 11 | private let store: LoggerStore 12 | 13 | public init(store: LoggerStore = .shared) { 14 | self.store = store 15 | } 16 | 17 | public var body: some View { 18 | Form { 19 | if !UserSettings.shared.isRemoteLoggingHidden, 20 | store === RemoteLogger.shared.store { 21 | RemoteLoggerSettingsView(viewModel: .shared) 22 | } 23 | Section { 24 | if #available(tvOS 16, *) { 25 | NavigationLink(destination: StoreDetailsView(source: .store(store))) { 26 | Text("Store Info") 27 | } 28 | } 29 | if !store.options.contains(.readonly) { 30 | Button(role: .destructive, action: { store.removeAll() }) { 31 | Text("Remove Logs") 32 | } 33 | } 34 | } 35 | } 36 | .navigationTitle("Settings") 37 | .frame(maxWidth: 800) 38 | } 39 | } 40 | 41 | #if DEBUG 42 | struct SettingsView_Previews: PreviewProvider { 43 | static var previews: some View { 44 | NavigationView { 45 | SettingsView(store: .mock) 46 | }.navigationViewStyle(.stack) 47 | } 48 | } 49 | #endif 50 | #endif 51 | -------------------------------------------------------------------------------- /Sources/PulseUI/Features/Settings/SettingsView-watchos.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(watchOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | public struct SettingsView: View { 11 | private let store: LoggerStore 12 | 13 | @State private var isShowingShareView = false 14 | 15 | public init(store: LoggerStore = .shared) { 16 | self.store = store 17 | } 18 | 19 | public var body: some View { 20 | Form { 21 | Section { 22 | if !UserSettings.shared.isRemoteLoggingHidden, 23 | store === RemoteLogger.shared.store { 24 | #if targetEnvironment(simulator) 25 | RemoteLoggerSettingsView(viewModel: .shared) 26 | #else 27 | RemoteLoggerSettingsView(viewModel: .shared) 28 | .disabled(true) 29 | .foregroundColor(.secondary) 30 | Text("Not available on watchOS devices") 31 | .foregroundColor(.secondary) 32 | #endif 33 | } 34 | } 35 | Section { 36 | Button("Share Store") { isShowingShareView = true } 37 | } 38 | Section { 39 | NavigationLink(destination: StoreDetailsView(source: .store(store))) { 40 | Text("Store Info") 41 | } 42 | if !(store.options.contains(.readonly)) { 43 | Button(role: .destructive, action: { store.removeAll() }) { 44 | Text("Remove Logs") 45 | } 46 | } 47 | } 48 | } 49 | .navigationTitle("Settings") 50 | .sheet(isPresented: $isShowingShareView) { 51 | NavigationView { 52 | ShareStoreView { 53 | isShowingShareView = false 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | #if DEBUG 61 | struct SettingsView_Previews: PreviewProvider { 62 | static var previews: some View { 63 | NavigationView { 64 | SettingsView(store: .mock) 65 | }.navigationViewStyle(.stack) 66 | } 67 | } 68 | #endif 69 | #endif 70 | -------------------------------------------------------------------------------- /Sources/PulseUI/Helpers/DecodingErrorsPreviews.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) 6 | 7 | #if DEBUG 8 | 9 | import Foundation 10 | import SwiftUI 11 | import Pulse 12 | 13 | struct DecodingErrors_Previews: PreviewProvider { 14 | static var previews: some View { 15 | Group { 16 | fileViewer(error: typeMismatchError()) 17 | .previewDisplayName("Type Mismatch (Object)") 18 | fileViewer(error: typeMismatchErrorInArray()) 19 | .previewDisplayName("Type Mismatch (Array)") 20 | fileViewer(error: valueNotFound()) 21 | .previewDisplayName("Value Not Found") 22 | fileViewer(error: keyNotFound()) 23 | .previewDisplayName("Key Not Found") 24 | fileViewer(error: dataCorrupted()) 25 | .previewDisplayName("Data Corrupted") 26 | } 27 | } 28 | 29 | @ViewBuilder 30 | private static func fileViewer(error: NetworkLogger.DecodingError) -> some View { 31 | let viewer = FileViewer(viewModel: .init(title: "Response", context: .init(contentType: .init(rawValue: "application/json"), originalSize: 1200, error: error), data: { MockJSON.allPossibleValues })) 32 | NavigationView { 33 | viewer 34 | } 35 | } 36 | } 37 | 38 | private func typeMismatchError() -> NetworkLogger.DecodingError { 39 | struct JSON: Decodable { 40 | let actors: [Actor] 41 | 42 | struct Actor: Decodable { 43 | let age: String 44 | } 45 | } 46 | return getError(JSON.self) 47 | } 48 | 49 | private func typeMismatchErrorInArray() -> NetworkLogger.DecodingError { 50 | struct JSON: Decodable { 51 | let actors: [Actor] 52 | 53 | struct Actor: Decodable { 54 | let children: [Int] 55 | } 56 | } 57 | return getError(JSON.self) 58 | } 59 | 60 | private func valueNotFound() -> NetworkLogger.DecodingError { 61 | struct JSON: Decodable { 62 | let actors: [Actor] 63 | 64 | struct Actor: Decodable { 65 | let wife: String 66 | } 67 | } 68 | return getError(JSON.self) 69 | } 70 | 71 | private func keyNotFound() -> NetworkLogger.DecodingError { 72 | struct JSON: Decodable { 73 | let actors: [Actor] 74 | 75 | struct Actor: Decodable { 76 | let lastName: String 77 | } 78 | } 79 | return getError(JSON.self) 80 | } 81 | 82 | private func dataCorrupted() -> NetworkLogger.DecodingError { 83 | struct JSON: Decodable { 84 | let actors: [Actor] 85 | 86 | struct Actor: Decodable { 87 | let name: URL 88 | } 89 | } 90 | return getError(JSON.self) 91 | } 92 | 93 | private func getError(_ type: T.Type) -> NetworkLogger.DecodingError { 94 | do { 95 | _ = try JSONDecoder().decode(type, from: MockJSON.allPossibleValues) 96 | fatalError() 97 | } catch { 98 | return NetworkLogger.DecodingError(error as! DecodingError) 99 | } 100 | } 101 | 102 | #endif 103 | 104 | #endif 105 | -------------------------------------------------------------------------------- /Sources/PulseUI/Helpers/FileViewModelContext.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Pulse 6 | import Foundation 7 | 8 | package struct FileViewerViewModelContext { 9 | package var contentType: NetworkLogger.ContentType? 10 | package var originalSize: Int64 11 | package var metadata: [String: String]? 12 | package var isResponse = true 13 | package var error: NetworkLogger.DecodingError? 14 | package var sourceURL: URL? 15 | 16 | package init(contentType: NetworkLogger.ContentType? = nil, originalSize: Int64, metadata: [String : String]? = nil, isResponse: Bool = true, error: NetworkLogger.DecodingError? = nil, sourceURL: URL? = nil) { 17 | self.contentType = contentType 18 | self.originalSize = originalSize 19 | self.metadata = metadata 20 | self.isResponse = isResponse 21 | self.error = error 22 | self.sourceURL = sourceURL 23 | } 24 | } 25 | 26 | extension NetworkTaskEntity { 27 | package var requestFileViewerContext: FileViewerViewModelContext { 28 | FileViewerViewModelContext( 29 | contentType: originalRequest?.contentType, 30 | originalSize: requestBodySize, 31 | metadata: metadata, 32 | isResponse: false, 33 | error: nil 34 | ) 35 | } 36 | 37 | package var responseFileViewerContext: FileViewerViewModelContext { 38 | FileViewerViewModelContext( 39 | contentType: response?.contentType, 40 | originalSize: responseBodySize, 41 | metadata: metadata, 42 | isResponse: true, 43 | error: decodingError, 44 | sourceURL: currentRequest?.url.flatMap(URL.init) 45 | ) 46 | } 47 | 48 | /// - returns `nil` if the task is an unknown state. It may happen if the 49 | /// task is pending, but it's from the previous app run. 50 | package func state(in store: LoggerStore?) -> NetworkTaskEntity.State? { 51 | let state = self.state 52 | if state == .pending, let store, self.session != store.session.id { 53 | return nil 54 | } 55 | return state 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/PulseUI/Helpers/LoggerStoreIndex.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | import CoreData 7 | import Combine 8 | import Pulse 9 | 10 | /// Keeps track of hosts, paths, etc. 11 | package final class LoggerStoreIndex: ObservableObject { 12 | @Published private(set) package var labels: Set = [] 13 | @Published private(set) package var files: Set = [] 14 | @Published private(set) package var hosts: Set = [] 15 | @Published private(set) package var paths: Set = [] 16 | 17 | private let context: NSManagedObjectContext 18 | private var cancellable: AnyCancellable? 19 | 20 | convenience package init(store: LoggerStore) { 21 | self.init(context: store.backgroundContext) 22 | 23 | store.backgroundContext.perform { 24 | self.prepopulate() 25 | } 26 | cancellable = store.events.receive(on: DispatchQueue.main).sink { [weak self] in 27 | self?.handle($0) 28 | } 29 | } 30 | 31 | package init(context: NSManagedObjectContext) { 32 | self.context = context 33 | context.perform { 34 | self.prepopulate() 35 | } 36 | } 37 | 38 | private func handle(_ event: LoggerStore.Event) { 39 | switch event { 40 | case .messageStored(let event): 41 | self.files.insert(event.file) 42 | self.labels.insert(event.label) 43 | case .networkTaskCompleted(let event): 44 | if let host = event.originalRequest.url.flatMap(getHost) { 45 | var hosts = self.hosts 46 | let (isInserted, _) = hosts.insert(host) 47 | if isInserted { self.hosts = hosts } 48 | } 49 | default: 50 | break 51 | } 52 | } 53 | 54 | private func prepopulate() { 55 | let files = context.getDistinctValues(entityName: "LoggerMessageEntity", property: "file") 56 | let labels = context.getDistinctValues(entityName: "LoggerMessageEntity", property: "label") 57 | let urls = context.getDistinctValues(entityName: "NetworkTaskEntity", property: "url") 58 | 59 | var hosts = Set() 60 | var paths = Set() 61 | 62 | for url in urls { 63 | guard let components = URLComponents(string: url) else { 64 | continue 65 | } 66 | if let host = components.host, !host.isEmpty { 67 | hosts.insert(host) 68 | } 69 | paths.insert(components.path) 70 | } 71 | 72 | DispatchQueue.main.async { 73 | self.labels = labels 74 | self.files = files 75 | self.hosts = hosts 76 | self.paths = paths 77 | } 78 | } 79 | 80 | func clear() { 81 | self.labels = [] 82 | self.files = [] 83 | self.paths = [] 84 | self.hosts = [] 85 | } 86 | } 87 | 88 | private func getHost(for url: URL) -> String? { 89 | if let host = url.host { 90 | return host 91 | } 92 | if url.scheme == nil, let url = URL(string: "https://" + url.absoluteString) { 93 | return url.host ?? "" // URL(string: "example.com")?.host with not scheme returns host: "" 94 | } 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /Sources/PulseUI/Helpers/ManagedObjectsCountObserver.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import CoreData 6 | 7 | package final class ManagedObjectsCountObserver: NSObject, ObservableObject, NSFetchedResultsControllerDelegate { 8 | package let controller: NSFetchedResultsController 9 | 10 | @Published private(set) package var count = 0 11 | 12 | package init(entity: T.Type, context: NSManagedObjectContext, sortDescriptior: NSSortDescriptor) { 13 | let request = NSFetchRequest(entityName: "\(T.self)") 14 | request.fetchBatchSize = 1 15 | request.sortDescriptors = [sortDescriptior] 16 | 17 | self.controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil) 18 | 19 | super.init() 20 | 21 | self.controller.delegate = self 22 | self.refresh() 23 | } 24 | 25 | package func setPredicate(_ predicate: NSPredicate?) { 26 | controller.fetchRequest.predicate = predicate 27 | refresh() 28 | } 29 | 30 | package func refresh() { 31 | try? controller.performFetch() 32 | self.count = controller.fetchedObjects?.count ?? 0 33 | } 34 | 35 | package func controllerDidChangeContent(_ controller: NSFetchedResultsController) { 36 | self.count = controller.fetchedObjects?.count ?? 0 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/PulseUI/Helpers/ManagedObjectsObserver.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | import CoreData 7 | import Pulse 8 | import Combine 9 | import SwiftUI 10 | 11 | package final class ManagedObjectsObserver: NSObject, NSFetchedResultsControllerDelegate { 12 | @Published private(set) package var objects: [T] = [] 13 | 14 | private let controller: NSFetchedResultsController 15 | 16 | package init(request: NSFetchRequest, 17 | context: NSManagedObjectContext, 18 | cacheName: String? = nil) { 19 | self.controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: cacheName) 20 | super.init() 21 | 22 | try? controller.performFetch() 23 | objects = controller.fetchedObjects ?? [] 24 | 25 | controller.delegate = self 26 | } 27 | 28 | package func controllerDidChangeContent(_ controller: NSFetchedResultsController) { 29 | objects = self.controller.fetchedObjects ?? [] 30 | } 31 | } 32 | 33 | extension ManagedObjectsObserver where T == LoggerSessionEntity { 34 | package static func sessions(for context: NSManagedObjectContext) -> ManagedObjectsObserver { 35 | let request = NSFetchRequest(entityName: "\(LoggerSessionEntity.self)") 36 | request.sortDescriptors = [NSSortDescriptor(keyPath: \LoggerSessionEntity.createdAt, ascending: false)] 37 | 38 | return ManagedObjectsObserver(request: request, context: context, cacheName: "com.github.pulse.sessions-cache") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/PulseUI/Helpers/Regex.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | 7 | final class Regex { 8 | private let regex: NSRegularExpression 9 | 10 | struct Options: OptionSet { 11 | let rawValue: Int 12 | 13 | static let caseInsensitive = Options(rawValue: 1 << 0) 14 | static let multiline = Options(rawValue: 1 << 1) 15 | static let dotMatchesLineSeparators = Options(rawValue: 1 << 2) 16 | } 17 | 18 | init(_ pattern: String, _ options: Options = []) throws { 19 | var ops = NSRegularExpression.Options() 20 | if options.contains(.caseInsensitive) { ops.insert(.caseInsensitive) } 21 | if options.contains(.multiline) { ops.insert(.anchorsMatchLines) } 22 | if options.contains(.dotMatchesLineSeparators) { ops.insert(.dotMatchesLineSeparators)} 23 | 24 | self.regex = try NSRegularExpression(pattern: pattern, options: ops) 25 | } 26 | 27 | func isMatch(_ s: String) -> Bool { 28 | let range = NSRange(s.startIndex.. [Match] { 33 | let range = NSRange(s.startIndex.. [Match] { 38 | let matches = regex.matches(in: s, options: [], range: range) 39 | return matches.map { match in 40 | let ranges = (0.. NSAttributedString { 25 | let string = NSMutableAttributedString(string: html) 26 | string.addAttributes(helper.attributes(role: .body2, style: .monospaced)) 27 | guard options.color != .monochrome else { 28 | return string 29 | } 30 | func makeRange(from substring: Substring) -> NSRange { 31 | NSRange(substring.startIndex..]*>"), 34 | let attributesRegex = try? Regex(#"(\w*?)=(\"\w.*?\")"#) else { 35 | assertionFailure("Invalid regex") // Should never happen 36 | return string 37 | } 38 | for match in tagRegex.matches(in: html) { 39 | let range = makeRange(from: match.fullMatch) 40 | string.addAttribute(.foregroundColor, value: Palette.pink, range: range) 41 | for match in attributesRegex.matches(in: html, range: range) { 42 | if match.groups.count == 2 { 43 | string.addAttribute(.foregroundColor, value: UXColor.systemOrange, range: makeRange(from: match.groups[0])) 44 | string.addAttribute(.foregroundColor, value: Palette.red, range: makeRange(from: match.groups[1])) 45 | } 46 | } 47 | } 48 | return string 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/PulseUI/Helpers/UIKit+Extensions.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) 6 | 7 | import UIKit 8 | 9 | extension UIView { 10 | func pinToSuperview(insets: UIEdgeInsets = .zero) { 11 | translatesAutoresizingMaskIntoConstraints = false 12 | NSLayoutConstraint.activate([ 13 | topAnchor.constraint(equalTo: superview!.topAnchor, constant: insets.top), 14 | trailingAnchor.constraint(equalTo: superview!.trailingAnchor, constant: -insets.right), 15 | leadingAnchor.constraint(equalTo: superview!.leadingAnchor, constant: insets.left), 16 | bottomAnchor.constraint(equalTo: superview!.bottomAnchor, constant: -insets.bottom) 17 | ]) 18 | } 19 | } 20 | 21 | extension UIApplication { 22 | package static var keyWindow: UIWindow? { 23 | shared.connectedScenes 24 | .compactMap { $0 as? UIWindowScene } 25 | .flatMap { $0.windows } 26 | .first { $0.isKeyWindow } 27 | } 28 | } 29 | 30 | #endif 31 | -------------------------------------------------------------------------------- /Sources/PulseUI/Helpers/UserSettings.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | import Combine 8 | 9 | /// Allows you to control Pulse appearance and other settings programmatically. 10 | public final class UserSettings: ObservableObject { 11 | public static let shared = UserSettings() 12 | 13 | /// The console default mode. 14 | @AppStorage("com.github.kean.pulse.console.mode") 15 | public var mode: ConsoleMode = .network 16 | 17 | /// The line limit for messages in the console. By default, `3`. 18 | @AppStorage("com.github.kean.pulse.consoleCellLineLimit") 19 | public var lineLimit: Int = 3 20 | 21 | /// Enables link detection in the response viewier. By default, `false`. 22 | @AppStorage("com.github.kean.pulse.linkDetection") 23 | public var isLinkDetectionEnabled = false 24 | 25 | /// The default sharing output type. By default, ``ShareStoreOutput/store``. 26 | @AppStorage("com.github.kean.pulse.sharingOutput") 27 | public var sharingOutput: ShareStoreOutput = .store 28 | 29 | // Deprecated in Pulse 5.1. 30 | @available(*, deprecated, message: "Replaced with listDisplayOptions.header.fields and listDisplayOptions.footer.fields") 31 | public var displayHeaders: [String] { 32 | get { [] } 33 | set { } 34 | } 35 | 36 | /// If `true`, the network inspector will show the current request by default. 37 | /// If `false`, show the original request. 38 | @AppStorage("com.github.kean.pulse.showCurrentRequest") 39 | public var isShowingCurrentRequest = true 40 | 41 | /// The allowed sharing options. 42 | public var allowedShareStoreOutputs: [ShareStoreOutput] { 43 | get { decode(rawAllowedShareStoreOutputs) ?? [] } 44 | set { rawAllowedShareStoreOutputs = encode(newValue) ?? "[]" } 45 | } 46 | 47 | @AppStorage("com.github.kean.pulse.allowedShareStoreOutputs") 48 | var rawAllowedShareStoreOutputs: String = "[]" 49 | 50 | /// If enabled, the console stops showing the remote logging option. 51 | @AppStorage("com.github.kean.pulse.isRemoteLoggingAllowed") 52 | public var isRemoteLoggingHidden = false 53 | 54 | /// Task cell display options. 55 | public var listDisplayOptions: ConsoleListDisplaySettings { 56 | get { 57 | if let options = cachedDisplayOptions { 58 | return options 59 | } 60 | let options = decode(rawDisplayOptions) ?? ConsoleListDisplaySettings() 61 | cachedDisplayOptions = options 62 | return options 63 | } 64 | set { 65 | cachedDisplayOptions = newValue 66 | rawDisplayOptions = encode(newValue) ?? "{}" 67 | } 68 | } 69 | 70 | var cachedDisplayOptions: ConsoleListDisplaySettings? 71 | 72 | @AppStorage("com.github.kean.pulse.DisplayOptions") 73 | var rawDisplayOptions: String = "{}" 74 | } 75 | 76 | private func decode(_ string: String) -> T? { 77 | let data = string.data(using: .utf8) ?? Data() 78 | return (try? JSONDecoder().decode(T.self, from: data)) 79 | } 80 | 81 | private func encode(_ value: T) -> String? { 82 | guard let data = try? JSONEncoder().encode(value) else { return nil } 83 | return String(data: data, encoding: .utf8) 84 | } 85 | -------------------------------------------------------------------------------- /Sources/PulseUI/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPIType 9 | NSPrivacyAccessedAPICategoryFileTimestamp 10 | NSPrivacyAccessedAPITypeReasons 11 | 12 | C617.1 13 | 14 | 15 | 16 | NSPrivacyAccessedAPIType 17 | NSPrivacyAccessedAPICategoryUserDefaults 18 | NSPrivacyAccessedAPITypeReasons 19 | 20 | CA92.1 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/PulseUI/PulseUI.docc/Resources/pulseui-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kean/Pulse/67bff610613a91b83ce6d56aa92c9a103ce9757f/Sources/PulseUI/PulseUI.docc/Resources/pulseui-main.png -------------------------------------------------------------------------------- /Sources/PulseUI/Views/Checkbox.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | 7 | package struct Checkbox: View { 8 | @Binding var isOn: Bool 9 | let label: () -> Label 10 | 11 | package init(isOn: Binding, @ViewBuilder label: @escaping () -> Label) { 12 | self._isOn = isOn 13 | self.label = label 14 | } 15 | 16 | package var body: some View { 17 | #if os(iOS) || os(visionOS) 18 | Button(action: { isOn.toggle() }) { 19 | HStack { 20 | Image(systemName: isOn ? "checkmark.circle.fill" : "circle") 21 | .font(.title3) 22 | .foregroundColor(isOn ? .accentColor : .separator) 23 | label() 24 | .frame(maxWidth: .infinity, alignment: .leading) 25 | } 26 | .contentShape(Rectangle()) 27 | }.buttonStyle(.plain) 28 | #else 29 | Toggle(isOn: $isOn, label: label) 30 | #endif 31 | } 32 | } 33 | 34 | extension Checkbox where Label == Text { 35 | init(_ title: String, isOn: Binding) { 36 | self.init(isOn: isOn) { Text(title) } 37 | } 38 | } 39 | 40 | #if DEBUG 41 | struct Previews_CheckboxView_Previews: PreviewProvider { 42 | static var previews: some View { 43 | List { 44 | Checkbox("Checkbox", isOn: .constant(true)).disabled(false) 45 | Checkbox("Checkbox", isOn: .constant(false)).disabled(false) 46 | Checkbox("Checkbox", isOn: .constant(true)).disabled(true) 47 | Checkbox("Checkbox", isOn: .constant(false)).disabled(true) 48 | } 49 | } 50 | } 51 | #endif 52 | -------------------------------------------------------------------------------- /Sources/PulseUI/Views/DateRangePicker.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(macOS) || os(visionOS) 6 | 7 | import SwiftUI 8 | import CoreData 9 | import Pulse 10 | import Combine 11 | 12 | package struct DateRangePicker: View { 13 | package let title: String 14 | @Binding package var date: Date? 15 | 16 | package init(title: String, date: Binding) { 17 | self.title = title 18 | self._date = date 19 | } 20 | 21 | #if os(macOS) 22 | package var body: some View { 23 | HStack { 24 | Text(title + " Date") 25 | Spacer() 26 | contents 27 | }.frame(height: 24) 28 | } 29 | #else 30 | package var body: some View { 31 | #if os(iOS) || os(visionOS) 32 | if #available(iOS 16, *) { 33 | ViewThatFits { 34 | horizontal 35 | 36 | VStack(alignment: .leading) { 37 | Text(title + " Date") 38 | contents 39 | } 40 | } 41 | } else { 42 | horizontal 43 | } 44 | #else 45 | horizontal 46 | #endif 47 | } 48 | 49 | private var horizontal: some View { 50 | HStack { 51 | Text(title) 52 | Spacer() 53 | contents 54 | } 55 | } 56 | #endif 57 | 58 | @ViewBuilder 59 | private var contents: some View { 60 | if let date = date { 61 | editView(date: date) 62 | } else { 63 | setDateView 64 | } 65 | } 66 | 67 | @ViewBuilder 68 | private func editView(date: Date) -> some View { 69 | HStack { 70 | let binding = Binding(get: { date }, set: { self.date = $0 }) 71 | DatePicker(title, selection: binding) 72 | .environment(\.locale, Locale(identifier: "en_US")) 73 | .fixedSize() 74 | .labelsHidden() 75 | Button(action: { self.date = nil }) { 76 | Image(systemName: "minus.circle.fill") 77 | .font(.body) 78 | } 79 | .buttonStyle(.plain) 80 | .foregroundColor(.red) 81 | #if os(iOS) || os(visionOS) 82 | .padding(.trailing, -4) 83 | #endif 84 | } 85 | } 86 | 87 | @ViewBuilder 88 | private var setDateView: some View { 89 | Button("Set \(title) Date") { 90 | date = Date() 91 | } 92 | } 93 | } 94 | #endif 95 | -------------------------------------------------------------------------------- /Sources/PulseUI/Views/InfoRow.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | 7 | package struct InfoRow: View { 8 | package let title: String 9 | package let details: String? 10 | 11 | package init(title: String, details: String?) { 12 | self.title = title 13 | self.details = details 14 | } 15 | 16 | package var body: some View { 17 | HStack { 18 | Text(title) 19 | .lineLimit(1) 20 | Spacer() 21 | if let details = details { 22 | Text(details) 23 | .lineLimit(1) 24 | .foregroundColor(.secondary) 25 | } 26 | } 27 | } 28 | } 29 | 30 | package struct KeyValueRow: Identifiable { 31 | package let id: Int 32 | package let item: (String, String?) 33 | 34 | package var title: String { item.0 } 35 | package var details: String? { item.1 } 36 | 37 | package init(id: Int, item: (String, String?)) { 38 | self.id = id 39 | self.item = item 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/PulseUI/Views/LoggerStoreSizeChart.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | import Pulse 7 | import Charts 8 | 9 | @available(iOS 16, tvOS 16, macOS 13, watchOS 9, visionOS 1, *) 10 | package struct LoggerStoreSizeChart: View { 11 | package let info: LoggerStore.Info 12 | package let sizeLimit: Int64? 13 | 14 | package init(info: LoggerStore.Info, sizeLimit: Int64?) { 15 | self.info = info 16 | self.sizeLimit = sizeLimit 17 | } 18 | 19 | package var body: some View { 20 | VStack { 21 | HStack { 22 | Text("Logs") 23 | Spacer() 24 | Text(title).foregroundColor(.secondary) 25 | } 26 | chart 27 | } 28 | } 29 | 30 | private var title: String { 31 | let used = ByteCountFormatter.string(fromByteCount: info.totalStoreSize) 32 | let limit = sizeLimit.map(ByteCountFormatter.string) 33 | if let limit = limit { 34 | #if os(watchOS) 35 | return "\(used) / \(limit)" 36 | #else 37 | return "\(used) of \(limit) used" 38 | #endif 39 | } 40 | return used 41 | } 42 | 43 | private var chart: some View { 44 | Chart(data) { 45 | BarMark(x: .value("Data Size", $0.bytes), stacking: .normalized) 46 | .foregroundStyle(by: .value("Category", $0.category)) 47 | } 48 | .chartForegroundStyleScale([ 49 | Category.messages: .blue, 50 | Category.responses: .green, 51 | Category.free: .secondaryFill 52 | ]) 53 | .chartPlotStyle { $0.cornerRadius(8) } 54 | #if os(tvOS) 55 | .frame(height: 100) 56 | #elseif os(watchOS) 57 | .frame(height: 52) 58 | #else 59 | .frame(height: 60) 60 | #endif 61 | } 62 | 63 | private var data: [Series] { 64 | [Series(category: .messages, bytes: info.totalStoreSize - info.blobsSize), 65 | Series(category: .responses, bytes: info.blobsSize), 66 | sizeLimit.map { Series(category: .free, bytes: max(0, $0 - info.totalStoreSize)) }] 67 | .compactMap { $0 } 68 | } 69 | } 70 | 71 | @available(iOS 16, tvOS 16, macOS 13, watchOS 9.0, visionOS 1, *) 72 | private enum Category: String, Hashable, Plottable { 73 | case messages = "Logs" 74 | case responses = "Blobs" 75 | case free = "Free" 76 | } 77 | 78 | @available(iOS 16, tvOS 16, macOS 13, watchOS 9.0, visionOS 1, *) 79 | private struct Series: Identifiable { 80 | let category: Category 81 | let bytes: Int64 82 | var id: Category { category } 83 | } 84 | 85 | #if DEBUG 86 | //@available(iOS 16, tvOS 16, macOS 13, watchOS 9.0, visionOS 1, *) 87 | //struct LoggerStoreSizeChart_Previews: PreviewProvider { 88 | // static var previews: some View { 89 | // LoggerStoreSizeChart(info: try! LoggerStore.mock.info(), sizeLimit: 512 * 1024) 90 | // .padding() 91 | // .previewLayout(.sizeThatFits) 92 | // } 93 | //} 94 | #endif 95 | -------------------------------------------------------------------------------- /Sources/PulseUI/Views/Metrics/NetworkInspectorMetricsView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if !os(watchOS) 6 | 7 | import SwiftUI 8 | import Pulse 9 | 10 | // MARK: - View 11 | 12 | @available(iOS 16, visionOS 1, macOS 13, *) 13 | struct NetworkInspectorMetricsView: View { 14 | let viewModel: NetworkInspectorMetricsViewModel 15 | 16 | var body: some View { 17 | #if os(tvOS) 18 | ForEach(viewModel.transactions) { 19 | NetworkInspectorTransactionView(viewModel: $0) 20 | } 21 | #else 22 | List { 23 | ForEach(viewModel.transactions) { 24 | NetworkInspectorTransactionView(viewModel: $0) 25 | } 26 | } 27 | #if os(iOS) || os(visionOS) 28 | .listStyle(.insetGrouped) 29 | #endif 30 | #if os(macOS) 31 | .scrollContentBackground(.hidden) 32 | #endif 33 | #if !os(macOS) 34 | .navigationTitle("Metrics") 35 | #endif 36 | #endif 37 | } 38 | } 39 | 40 | // MARK: - ViewModel 41 | 42 | final class NetworkInspectorMetricsViewModel { 43 | private(set) lazy var transactions = task.orderedTransactions.map { 44 | NetworkInspectorTransactionViewModel(transaction: $0, task: task) 45 | } 46 | 47 | private let task: NetworkTaskEntity 48 | 49 | init?(task: NetworkTaskEntity) { 50 | guard task.hasMetrics else { return nil } 51 | self.task = task 52 | } 53 | } 54 | 55 | // MARK: - Preview 56 | 57 | #if DEBUG 58 | @available(iOS 16, visionOS 1, macOS 13, *) 59 | struct NetworkInspectorMetricsView_Previews: PreviewProvider { 60 | static var previews: some View { 61 | #if os(macOS) 62 | NetworkInspectorMetricsView(viewModel: .init( 63 | task: LoggerStore.preview.entity(for: .createAPI) 64 | )!).previewLayout(.fixed(width: 500, height: 800)) 65 | #else 66 | NavigationView { 67 | NetworkInspectorMetricsView(viewModel: .init( 68 | task: LoggerStore.preview.entity(for: .createAPI) 69 | )!) 70 | } 71 | #endif 72 | } 73 | } 74 | #endif 75 | 76 | #endif 77 | -------------------------------------------------------------------------------- /Sources/PulseUI/Views/PDFRepresentedView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | 7 | #if os(iOS) || os(visionOS) 8 | import PDFKit 9 | 10 | package struct PDFKitRepresentedView: UIViewRepresentable { 11 | package let document: PDFDocument 12 | 13 | package init(document: PDFDocument) { 14 | self.document = document 15 | } 16 | 17 | package func makeUIView(context: Context) -> PDFView { 18 | let pdfView = PDFView() 19 | pdfView.document = document 20 | return pdfView 21 | } 22 | 23 | package func updateUIView(_ view: PDFView, context: Context) { 24 | // Do nothing 25 | } 26 | } 27 | #elseif os(macOS) 28 | import PDFKit 29 | 30 | package struct PDFKitRepresentedView: NSViewRepresentable { 31 | package let document: PDFDocument 32 | 33 | package init(document: PDFDocument) { 34 | self.document = document 35 | } 36 | 37 | package func makeNSView(context: Context) -> PDFView { 38 | let pdfView = PDFView() 39 | pdfView.document = document 40 | pdfView.autoScales = true 41 | return pdfView 42 | } 43 | 44 | package func updateNSView(_ view: PDFView, context: Context) { 45 | // Do nothing 46 | } 47 | } 48 | #endif 49 | -------------------------------------------------------------------------------- /Sources/PulseUI/Views/PlaceholderView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | 7 | package struct PlaceholderView: View { 8 | package var imageName: String? 9 | package let title: String 10 | package var subtitle: String? 11 | 12 | #if os(tvOS) 13 | private let iconSize: CGFloat = 150 14 | #else 15 | private let iconSize: CGFloat = 70 16 | #endif 17 | 18 | #if os(macOS) 19 | private let maxWidth: CGFloat = .infinity 20 | #elseif os(tvOS) 21 | private let maxWidth: CGFloat = .infinity 22 | #else 23 | private let maxWidth: CGFloat = 280 24 | #endif 25 | 26 | package init(imageName: String? = nil, title: String, subtitle: String? = nil) { 27 | self.imageName = imageName 28 | self.title = title 29 | self.subtitle = subtitle 30 | } 31 | 32 | package var body: some View { 33 | VStack { 34 | imageName.map(Image.init(systemName:)) 35 | .font(.system(size: iconSize, weight: .light)) 36 | Spacer().frame(height: 8) 37 | Text(title) 38 | .font(.title) 39 | .multilineTextAlignment(.center) 40 | if let subtitle = self.subtitle { 41 | Spacer().frame(height: 10) 42 | Text(subtitle) 43 | .multilineTextAlignment(.center) 44 | } 45 | } 46 | .foregroundColor(.secondary) 47 | .frame(maxWidth: maxWidth, maxHeight: /*@START_MENU_TOKEN@*/.infinity/*@END_MENU_TOKEN@*/, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) 48 | } 49 | } 50 | 51 | #if DEBUG 52 | struct PlaceholderView_Previews: PreviewProvider { 53 | static var previews: some View { 54 | PlaceholderView(imageName: "questionmark.folder", title: "Store Unavailable") 55 | } 56 | } 57 | #endif 58 | -------------------------------------------------------------------------------- /Sources/PulseUI/Views/PulseUI+UIKit.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | #if os(iOS) || os(visionOS) 6 | 7 | import Foundation 8 | import UIKit 9 | import Pulse 10 | import SwiftUI 11 | 12 | /// Shows the console inside the navigation controller. 13 | /// 14 | /// - note: Use ``ConsoleView`` directly to show it in the existing navigation 15 | /// controller or other container controller. 16 | public final class MainViewController: UIViewController { 17 | private let environment: ConsoleEnvironment 18 | 19 | public static var isAutomaticAppearanceOverrideRemovalEnabled = true 20 | 21 | public init(store: LoggerStore = .shared) { 22 | self.environment = ConsoleEnvironment(store: store) 23 | super.init(nibName: nil, bundle: nil) 24 | 25 | if MainViewController.isAutomaticAppearanceOverrideRemovalEnabled { 26 | removeAppearanceOverrides() 27 | } 28 | let console = ConsoleView(environment: environment) 29 | let vc = UIHostingController(rootView: NavigationView { console }) 30 | addChild(vc) 31 | view.addSubview(vc.view) 32 | vc.view.pinToSuperview() 33 | } 34 | 35 | required init?(coder: NSCoder) { 36 | fatalError("init(coder:) has not been implemented") 37 | } 38 | } 39 | 40 | private var isAppearanceCleanupNeeded = true 41 | 42 | private func removeAppearanceOverrides() { 43 | guard isAppearanceCleanupNeeded else { return } 44 | isAppearanceCleanupNeeded = false 45 | 46 | let appearance = UINavigationBar.appearance(whenContainedInInstancesOf: [MainViewController.self]) 47 | appearance.tintColor = nil 48 | appearance.barTintColor = nil 49 | appearance.titleTextAttributes = nil 50 | appearance.isTranslucent = true 51 | } 52 | 53 | #endif 54 | -------------------------------------------------------------------------------- /Sources/PulseUI/Views/SectionHeaderView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | #if os(iOS) || os(macOS) || os(visionOS) 4 | 5 | import SwiftUI 6 | 7 | package struct SectionHeaderView: View { 8 | package var systemImage: String? 9 | package let title: String 10 | 11 | package init(systemImage: String? = nil, title: String) { 12 | self.systemImage = systemImage 13 | self.title = title 14 | } 15 | 16 | package var body: some View { 17 | HStack { 18 | if let systemImage = systemImage { 19 | Image(systemName: systemImage) 20 | } 21 | Text(title) 22 | .lineLimit(1) 23 | .font(.headline) 24 | .foregroundColor(.secondary) 25 | Spacer() 26 | } 27 | #if os(macOS) 28 | .padding(.bottom, 8) 29 | #endif 30 | } 31 | } 32 | 33 | #endif 34 | -------------------------------------------------------------------------------- /Sources/PulseUI/Views/TextView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | 7 | #if os(iOS) || os(tvOS) || os(visionOS) 8 | /// A simple text view for rendering attributed strings. 9 | struct TextView: UIViewRepresentable { 10 | let string: NSAttributedString 11 | 12 | func makeUIView(context: Context) -> UXTextView { 13 | let textView = UITextView() 14 | configureTextView(textView) 15 | textView.attributedText = string 16 | return textView 17 | } 18 | 19 | func updateUIView(_ uiView: UXTextView, context: Context) { 20 | // Do nothing 21 | } 22 | } 23 | #elseif os(macOS) 24 | struct TextView: NSViewRepresentable { 25 | let string: NSAttributedString 26 | 27 | func makeNSView(context: Context) -> NSScrollView { 28 | let scrollView = NSTextView.scrollableTextView() 29 | scrollView.hasVerticalScroller = false 30 | let textView = scrollView.documentView as! NSTextView 31 | configureTextView(textView) 32 | textView.attributedText = string 33 | return scrollView 34 | } 35 | 36 | func updateNSView(_ nsView: NSScrollView, context: Context) { 37 | // Do nothing 38 | } 39 | } 40 | #elseif os(watchOS) 41 | struct TextView: View { 42 | let string: NSAttributedString 43 | 44 | var body: some View { 45 | if let string = try? AttributedString(string, including: \.uiKit) { 46 | Text(string) 47 | } else { 48 | Text(string.string) 49 | } 50 | } 51 | } 52 | #endif 53 | 54 | #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) 55 | private func configureTextView(_ textView: UXTextView) { 56 | textView.isSelectable = true 57 | textView.backgroundColor = .clear 58 | 59 | #if os(iOS) || os(macOS) || os(visionOS) 60 | textView.isEditable = false 61 | textView.isAutomaticLinkDetectionEnabled = false 62 | #endif 63 | 64 | #if os(iOS) || os(visionOS) 65 | textView.isScrollEnabled = false 66 | textView.adjustsFontForContentSizeCategory = true 67 | textView.textContainerInset = .zero 68 | #endif 69 | 70 | #if os(macOS) 71 | textView.isAutomaticSpellingCorrectionEnabled = false 72 | textView.textContainerInset = .zero 73 | #endif 74 | } 75 | #endif 76 | -------------------------------------------------------------------------------- /Sources/PulseUI/Views/WebView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | 7 | #if os(iOS) || os(visionOS) 8 | 9 | import WebKit 10 | import UIKit 11 | 12 | package struct WebView: UIViewRepresentable { 13 | package let data: Data 14 | package let contentType: String 15 | 16 | package init(data: Data, contentType: String) { 17 | self.data = data 18 | self.contentType = contentType 19 | } 20 | 21 | package func makeUIView(context: Context) -> WKWebView { 22 | let webView = WKWebView(frame: .zero, configuration: .init()) 23 | webView.load(data, mimeType: contentType, characterEncodingName: "UTF8", baseURL: FileManager.default.temporaryDirectory) 24 | return webView 25 | } 26 | 27 | package func updateUIView(_ webView: WKWebView, context: Context) { 28 | // Do nothing 29 | } 30 | } 31 | #endif 32 | 33 | #if os(macOS) 34 | 35 | import WebKit 36 | import AppKit 37 | 38 | package struct WebView: NSViewRepresentable { 39 | package let data: Data 40 | package let contentType: String 41 | 42 | package func makeNSView(context: Context) -> WKWebView { 43 | let webView = WKWebView(frame: .zero, configuration: .init()) 44 | webView.load(data, mimeType: contentType, characterEncodingName: "UTF8", baseURL: FileManager.default.temporaryDirectory) 45 | return webView 46 | } 47 | 48 | package func updateNSView(_ nsView: WKWebView, context: Context) { 49 | // Do nothing 50 | } 51 | } 52 | 53 | #endif 54 | --------------------------------------------------------------------------------