├── .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 |
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 |
--------------------------------------------------------------------------------