diff --git a/secant.xcodeproj/project.pbxproj b/secant.xcodeproj/project.pbxproj index 104a35d..7ae657a 100644 --- a/secant.xcodeproj/project.pbxproj +++ b/secant.xcodeproj/project.pbxproj @@ -99,6 +99,11 @@ 9E01F8282833CDA0000EFC57 /* ScanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E01F8272833CDA0000EFC57 /* ScanTests.swift */; }; 9E02B56A27FED43E005B809B /* FileManagerInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E02B56927FED43E005B809B /* FileManagerInterface.swift */; }; 9E02B56C27FED475005B809B /* DatabaseFilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E02B56B27FED475005B809B /* DatabaseFilesTests.swift */; }; + 9E0F5741297E7F1D005304FA /* TCALogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E0F5740297E7F1C005304FA /* TCALogger.swift */; }; + 9E0F5743297EB96C005304FA /* TCALoggerReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E0F5742297EB96C005304FA /* TCALoggerReducer.swift */; }; + 9E0F5745297EBA1B005304FA /* LogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E0F5744297EBA1B005304FA /* LogStore.swift */; }; + 9E0F5747297EE5F3005304FA /* OSLogger_.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E0F5746297EE5F3005304FA /* OSLogger_.swift */; }; + 9E0F574B2980260D005304FA /* LoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E0F574A2980260D005304FA /* LoggerTests.swift */; }; 9E153A5F2920CE2700112F41 /* MnemonicInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E153A5E2920CD5100112F41 /* MnemonicInterface.swift */; }; 9E153A602920CE2700112F41 /* MnemonicLiveKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E153A5C2920CD5100112F41 /* MnemonicLiveKey.swift */; }; 9E153A612920CE2700112F41 /* MnemonicMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E153A5B2920CD5100112F41 /* MnemonicMocks.swift */; }; @@ -137,6 +142,10 @@ 9E5BF648282277BE00BA3F17 /* NotificationCenterInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF647282277BE00BA3F17 /* NotificationCenterInterface.swift */; }; 9E5BF64F2823E94900BA3F17 /* TransactionAddressTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF64D2823E94900BA3F17 /* TransactionAddressTextField.swift */; }; 9E5BF6502823E94900BA3F17 /* TransactionAddressTextFieldStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5BF64E2823E94900BA3F17 /* TransactionAddressTextFieldStore.swift */; }; + 9E612C6F2987A9B100D09B09 /* UIShareDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E612C6E2987A9B100D09B09 /* UIShareDialog.swift */; }; + 9E612C7229880E9200D09B09 /* LogsHandlerInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E612C7129880E9200D09B09 /* LogsHandlerInterface.swift */; }; + 9E612C7429880F2200D09B09 /* LogsHandlerLive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E612C7329880F2200D09B09 /* LogsHandlerLive.swift */; }; + 9E612C7629880FC900D09B09 /* LogsHandlerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E612C7529880FC900D09B09 /* LogsHandlerTest.swift */; }; 9E66122A287717A900C75B70 /* HomeCircularProgressSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E661229287717A900C75B70 /* HomeCircularProgressSnapshotTests.swift */; }; 9E66122C2877188700C75B70 /* SyncStatusSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E66122B2877188700C75B70 /* SyncStatusSnapshot.swift */; }; 9E6612312878337F00C75B70 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 9E6612302878337F00C75B70 /* Lottie */; }; @@ -395,6 +404,11 @@ 9E01F8272833CDA0000EFC57 /* ScanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanTests.swift; sourceTree = ""; }; 9E02B56927FED43E005B809B /* FileManagerInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerInterface.swift; sourceTree = ""; }; 9E02B56B27FED475005B809B /* DatabaseFilesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFilesTests.swift; sourceTree = ""; }; + 9E0F5740297E7F1C005304FA /* TCALogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCALogger.swift; sourceTree = ""; }; + 9E0F5742297EB96C005304FA /* TCALoggerReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCALoggerReducer.swift; sourceTree = ""; }; + 9E0F5744297EBA1B005304FA /* LogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogStore.swift; sourceTree = ""; }; + 9E0F5746297EE5F3005304FA /* OSLogger_.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogger_.swift; sourceTree = ""; }; + 9E0F574A2980260D005304FA /* LoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerTests.swift; sourceTree = ""; }; 9E153A5B2920CD5100112F41 /* MnemonicMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MnemonicMocks.swift; sourceTree = ""; }; 9E153A5C2920CD5100112F41 /* MnemonicLiveKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MnemonicLiveKey.swift; sourceTree = ""; }; 9E153A5D2920CD5100112F41 /* MnemonicTestKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MnemonicTestKey.swift; sourceTree = ""; }; @@ -434,6 +448,10 @@ 9E5BF647282277BE00BA3F17 /* NotificationCenterInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenterInterface.swift; sourceTree = ""; }; 9E5BF64D2823E94900BA3F17 /* TransactionAddressTextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionAddressTextField.swift; sourceTree = ""; }; 9E5BF64E2823E94900BA3F17 /* TransactionAddressTextFieldStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionAddressTextFieldStore.swift; sourceTree = ""; }; + 9E612C6E2987A9B100D09B09 /* UIShareDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIShareDialog.swift; sourceTree = ""; }; + 9E612C7129880E9200D09B09 /* LogsHandlerInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsHandlerInterface.swift; sourceTree = ""; }; + 9E612C7329880F2200D09B09 /* LogsHandlerLive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsHandlerLive.swift; sourceTree = ""; }; + 9E612C7529880FC900D09B09 /* LogsHandlerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsHandlerTest.swift; sourceTree = ""; }; 9E661229287717A900C75B70 /* HomeCircularProgressSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCircularProgressSnapshotTests.swift; sourceTree = ""; }; 9E66122B2877188700C75B70 /* SyncStatusSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusSnapshot.swift; sourceTree = ""; }; 9E6612322878338C00C75B70 /* LottieAnimation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LottieAnimation.swift; sourceTree = ""; }; @@ -962,6 +980,17 @@ path = ScanTests; sourceTree = ""; }; + 9E0F573F297E7F00005304FA /* Logging */ = { + isa = PBXGroup; + children = ( + 9E0F5740297E7F1C005304FA /* TCALogger.swift */, + 9E0F5742297EB96C005304FA /* TCALoggerReducer.swift */, + 9E0F5744297EBA1B005304FA /* LogStore.swift */, + 9E0F5746297EE5F3005304FA /* OSLogger_.swift */, + ); + path = Logging; + sourceTree = ""; + }; 9E153A5A2920CCE700112F41 /* Mnemonic */ = { isa = PBXGroup; children = ( @@ -1109,6 +1138,24 @@ path = TransactionAddress; sourceTree = ""; }; + 9E612C6D2987A96500D09B09 /* UIKitBridge */ = { + isa = PBXGroup; + children = ( + 9E612C6E2987A9B100D09B09 /* UIShareDialog.swift */, + ); + path = UIKitBridge; + sourceTree = ""; + }; + 9E612C7029880E6700D09B09 /* LogsHandler */ = { + isa = PBXGroup; + children = ( + 9E612C7129880E9200D09B09 /* LogsHandlerInterface.swift */, + 9E612C7329880F2200D09B09 /* LogsHandlerLive.swift */, + 9E612C7529880FC900D09B09 /* LogsHandlerTest.swift */, + ); + path = LogsHandler; + sourceTree = ""; + }; 9E6612342878341F00C75B70 /* Lotties */ = { isa = PBXGroup; children = ( @@ -1226,6 +1273,7 @@ 9E7FE0BB282D1DC200C374E8 /* Utils */ = { isa = PBXGroup; children = ( + 9E0F573F297E7F00005304FA /* Logging */, 9E7FE0D4282D281800C374E8 /* Array+Chunked.swift */, F9C165B3274031F600592F76 /* Bindings.swift */, 0DACFA7E27208CE00039EEA5 /* Clamped.swift */, @@ -1260,6 +1308,7 @@ 9EB863882922CC0E003D0F8B /* FeedbackGenerator */, 9EB863B52923C4ED003D0F8B /* FileManager */, 9EBDF981291F91B1000A1A05 /* LocalAuthentication */, + 9E612C7029880E6700D09B09 /* LogsHandler */, 9E153A5A2920CCE700112F41 /* Mnemonic */, 9EB863B42923C490003D0F8B /* NotificationCenter */, 9EB8638F2922D000003D0F8B /* NumberFormatter */, @@ -1650,6 +1699,7 @@ 9E02B56B27FED475005B809B /* DatabaseFilesTests.swift */, 9E39112D283F91600073DD9A /* ZatoshiTests.swift */, 0DB4E0B02881F2DB00947B78 /* WalletBalance+testing.swift */, + 9E0F574A2980260D005304FA /* LoggerTests.swift */, ); path = UtilTests; sourceTree = ""; @@ -1726,6 +1776,7 @@ children = ( F9971A6227680DFE00A2DB75 /* SettingsStore.swift */, F9971A6427680DFE00A2DB75 /* SettingsView.swift */, + 9E612C6D2987A96500D09B09 /* UIKitBridge */, ); path = Settings; sourceTree = ""; @@ -1996,6 +2047,7 @@ 2EB7758727FC67FD00269373 /* TransactionAmountTextFieldStore.swift in Sources */, 669FDAE9272C23B3007B9422 /* CircularFrame.swift in Sources */, 9EF8136027F043CC0075AF48 /* AppDelegate.swift in Sources */, + 9E612C7229880E9200D09B09 /* LogsHandlerInterface.swift in Sources */, 9EBDF960291E657B000A1A05 /* DeeplinkTestKey.swift in Sources */, 34E0AF1128DEE5220034CF37 /* Wedge.swift in Sources */, F96B41E8273B501F0021B49A /* TransactionDetailView.swift in Sources */, @@ -2013,9 +2065,11 @@ 0DACFA7F27208CE00039EEA5 /* Clamped.swift in Sources */, 9EAB467A2861EA6A002904A0 /* TransactionRowView.swift in Sources */, 9EB8638C2922CD4A003D0F8B /* FeedbackGeneratorTestKey.swift in Sources */, + 9E0F5741297E7F1D005304FA /* TCALogger.swift in Sources */, 0DFE93E3272CA1AA000FCCA5 /* RecoveryPhraseValidationFlowStore.swift in Sources */, 9E2DF99E27CF704D00649636 /* ImportWalletView.swift in Sources */, 9E9ADA7D2938F4C00071767B /* RootInitialization.swift in Sources */, + 9E612C7429880F2200D09B09 /* LogsHandlerLive.swift in Sources */, 9EBDF967291ECDA2000A1A05 /* AudioServicesTestKey.swift in Sources */, 0D535FE2271F9476009A9E3E /* EnumeratedChip.swift in Sources */, 9EBDF97E291F7EB0000A1A05 /* AppVersionInterface.swift in Sources */, @@ -2035,6 +2089,7 @@ 9E2DF99D27CF704D00649636 /* ImportSeedEditor.swift in Sources */, F9971A5327680DD000A2DB75 /* ProfileStore.swift in Sources */, 346D41E428DF0B8600963F36 /* CheckCircle.swift in Sources */, + 9E0F5745297EBA1B005304FA /* LogStore.swift in Sources */, 9EB863AA29239EB2003D0F8B /* RecoveryPhraseRandomizer.swift in Sources */, 9EB863C52923C8AF003D0F8B /* FileManagerTest.swift in Sources */, 9EB863BF2923C72C003D0F8B /* SecItemLive.swift in Sources */, @@ -2053,6 +2108,7 @@ 0D18581B272728D60046B928 /* PhraseChip.swift in Sources */, 9E7FE0F92832824C00C374E8 /* QRCodeScanView.swift in Sources */, 9E153A6F292167FF00112F41 /* ZcashSDKEnvironmentTestKey.swift in Sources */, + 9E0F5743297EB96C005304FA /* TCALoggerReducer.swift in Sources */, 0DF482BA2787ADA800EB37D6 /* ConditionalModifier.swift in Sources */, 9E7225F3288AB6DD00DF7F17 /* MultipleLineTextField.swift in Sources */, 3448CB3228E47666006ADEDB /* NotEnoughFreeSpaceView.swift in Sources */, @@ -2110,6 +2166,7 @@ 66DC733F271D88CC0053CBB6 /* StandardButtonStyle.swift in Sources */, 663FABA0271D876200E495F8 /* PrimaryButton.swift in Sources */, 663FAB9C271D874D00E495F8 /* ActiveButton.swift in Sources */, + 9E612C6F2987A9B100D09B09 /* UIShareDialog.swift in Sources */, 9EBDF956291E5E86000A1A05 /* DatabaseFilesLiveKey.swift in Sources */, 9E2F1C842809B606004E65FE /* DebugMenu.swift in Sources */, 9E153A5F2920CE2700112F41 /* MnemonicInterface.swift in Sources */, @@ -2148,6 +2205,7 @@ F9971A6627680DFE00A2DB75 /* SettingsView.swift in Sources */, F96B41EB273B50520021B49A /* Strings.swift in Sources */, 9EB863D02923D3FC003D0F8B /* SDKSynchronizerMocks.swift in Sources */, + 9E612C7629880FC900D09B09 /* LogsHandlerTest.swift in Sources */, 2EDA07A227EDE1AE00D6F09B /* TextFieldFooter.swift in Sources */, F9971A5427680DD000A2DB75 /* ProfileView.swift in Sources */, F9971A6027680DF600A2DB75 /* ScanStore.swift in Sources */, @@ -2174,6 +2232,7 @@ 34E5F2F328E46DB700C17E5F /* DiskSpaceChecker.swift in Sources */, F9C165C42740403600592F76 /* TransactionSentView.swift in Sources */, 9E153A6E292167FF00112F41 /* ZcashSDKEnvironmentLiveKey.swift in Sources */, + 9E0F5747297EE5F3005304FA /* OSLogger_.swift in Sources */, F9971A5927680DDE00A2DB75 /* RequestStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2205,6 +2264,7 @@ 9E5BF644281FEC9900BA3F17 /* SendTests.swift in Sources */, 0D1C1AA327611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift in Sources */, 9E9ECC9A28589E150099D5A2 /* RecoveryPhraseValidationFlowSnapshotTests.swift in Sources */, + 9E0F574B2980260D005304FA /* LoggerTests.swift in Sources */, 9EAFEB822805793200199FC9 /* RootTests.swift in Sources */, 9E391132284644580073DD9A /* AppInitializationTests.swift in Sources */, 9E9ECC9928589E150099D5A2 /* RecoveryPhraseDisplaySnapshotTests.swift in Sources */, diff --git a/secant/Dependencies/LogsHandler/LogsHandlerInterface.swift b/secant/Dependencies/LogsHandler/LogsHandlerInterface.swift new file mode 100644 index 0000000..9b6df37 --- /dev/null +++ b/secant/Dependencies/LogsHandler/LogsHandlerInterface.swift @@ -0,0 +1,19 @@ +// +// LogsHandlerInterface.swift +// secant-testnet +// +// Created by Lukáš Korba on 30.01.2023. +// + +import Foundation +import ComposableArchitecture + +extension DependencyValues { + var logsHandler: LogsHandlerClient { + get { self[LogsHandlerClient.self] } + set { self[LogsHandlerClient.self] = newValue } + } +} +struct LogsHandlerClient { + let exportAndStoreLogs: (URL, URL, URL) async throws -> Void +} diff --git a/secant/Dependencies/LogsHandler/LogsHandlerLive.swift b/secant/Dependencies/LogsHandler/LogsHandlerLive.swift new file mode 100644 index 0000000..33b6baa --- /dev/null +++ b/secant/Dependencies/LogsHandler/LogsHandlerLive.swift @@ -0,0 +1,39 @@ +// +// LogsHandlerLive.swift +// secant-testnet +// +// Created by Lukáš Korba on 30.01.2023. +// + +import Foundation +import ComposableArchitecture + +extension LogsHandlerClient: DependencyKey { + static let liveValue = LogsHandlerClient( + exportAndStoreLogs: { tempSDKDir, tempTCADir, tempWalletDir in + async let sdkLogs = LogsHandlerClient.exportAndStoreLogsFor(key: LoggerConstants.sdkLogs, atURL: tempSDKDir) + async let tcaLogs = LogsHandlerClient.exportAndStoreLogsFor(key: LoggerConstants.tcaLogs, atURL: tempTCADir) + async let walletLogs = LogsHandlerClient.exportAndStoreLogsFor(key: LoggerConstants.walletLogs, atURL: tempWalletDir) + + let logs = try await [sdkLogs, tcaLogs, walletLogs] + + try logs.forEach { logsHandler in + try logsHandler.result.write(to: logsHandler.dir, atomically: true, encoding: String.Encoding.utf8) + } + } + ) +} + +private extension LogsHandlerClient { + static func exportAndStoreLogsFor(key: String, atURL: URL) async throws -> (result: String, dir: URL) { + let logsStr = try await LogStore.exportCategory(key) + + var result = "" + logsStr?.forEach({ line in + result.append(line) + result.append("\n\n") + }) + + return (result: result, dir: atURL) + } +} diff --git a/secant/Dependencies/LogsHandler/LogsHandlerTest.swift b/secant/Dependencies/LogsHandler/LogsHandlerTest.swift new file mode 100644 index 0000000..3aa6670 --- /dev/null +++ b/secant/Dependencies/LogsHandler/LogsHandlerTest.swift @@ -0,0 +1,15 @@ +// +// LogsHandlerTest.swift +// secant-testnet +// +// Created by Lukáš Korba on 30.01.2023. +// + +import ComposableArchitecture +import XCTestDynamicOverlay + +extension LogsHandlerClient: TestDependencyKey { + static let testValue = Self( + exportAndStoreLogs: XCTUnimplemented("\(Self.self).exportAndStoreLogs") + ) +} diff --git a/secant/Features/Root/RootInitialization.swift b/secant/Features/Root/RootInitialization.swift index 100c43b..4423f58 100644 --- a/secant/Features/Root/RootInitialization.swift +++ b/secant/Features/Root/RootInitialization.swift @@ -25,6 +25,8 @@ extension RootReducer { Reduce { state, action in switch action { case .initialization(.appDelegate(.didFinishLaunching)): + // TODO: [#524] finish all the wallet events according to definition, https://github.com/zcash/secant-ios-wallet/issues/524 + LoggerProxy.event(".appDelegate(.didFinishLaunching)") /// We need to fetch data from keychain, in order to be 100% sure the kecyhain can be read we delay the check a bit return Effect(value: .initialization(.checkWalletInitialization)) .delay(for: 0.02, scheduler: mainQueue) diff --git a/secant/Features/Root/RootStore.swift b/secant/Features/Root/RootStore.swift index 71ed392..1af2ecf 100644 --- a/secant/Features/Root/RootStore.swift +++ b/secant/Features/Root/RootStore.swift @@ -139,7 +139,8 @@ extension RootReducer { spendParamsURL: try databaseFiles.spendParamsURLFor(network), outputParamsURL: try databaseFiles.outputParamsURLFor(network), viewingKeys: [viewingKey], - walletBirthday: birthday + walletBirthday: birthday, + loggerProxy: OSLogger_(logLevel: .debug, category: LoggerConstants.sdkLogs) ) return initializer @@ -173,7 +174,7 @@ extension RootStore { static var placeholder: RootStore { RootStore( initialState: .placeholder, - reducer: RootReducer()._printChanges() + reducer: RootReducer().logging()._printChanges() ) } } diff --git a/secant/Features/Settings/SettingsStore.swift b/secant/Features/Settings/SettingsStore.swift index ca95b56..27350b2 100644 --- a/secant/Features/Settings/SettingsStore.swift +++ b/secant/Features/Settings/SettingsStore.swift @@ -10,25 +10,49 @@ struct SettingsReducer: ReducerProtocol { case backupPhrase } + var destination: Destination? + var exportLogsDisabled = false + var isSharingLogs = false var phraseDisplayState: RecoveryPhraseDisplayReducer.State var rescanDialog: ConfirmationDialogState? - var destination: Destination? + + var tempSDKDir: URL { + let tempDir = FileManager.default.temporaryDirectory + let sdkFileName = "sdkLogs.txt" + return tempDir.appendingPathComponent(sdkFileName) + } + + var tempTCADir: URL { + let tempDir = FileManager.default.temporaryDirectory + let sdkFileName = "tcaLogs.txt" + return tempDir.appendingPathComponent(sdkFileName) + } + + var tempWalletDir: URL { + let tempDir = FileManager.default.temporaryDirectory + let sdkFileName = "walletLogs.txt" + return tempDir.appendingPathComponent(sdkFileName) + } } enum Action: Equatable { case backupWallet case backupWalletAccessRequest case cancelRescan + case exportLogs case fullRescan + case logsExported + case logsShareFinished case phraseDisplay(RecoveryPhraseDisplayReducer.Action) case quickRescan case rescanBlockchain case updateDestination(SettingsReducer.State.Destination?) } - + @Dependency(\.localAuthentication) var localAuthentication @Dependency(\.mnemonic) var mnemonic @Dependency(\.sdkSynchronizer) var sdkSynchronizer + @Dependency(\.logsHandler) var logsHandler @Dependency(\.walletStorage) var walletStorage var body: some ReducerProtocol { @@ -57,6 +81,26 @@ struct SettingsReducer: ReducerProtocol { state.rescanDialog = nil return .none + case .exportLogs: + state.exportLogsDisabled = true + return .run { [state] send in + do { + try await logsHandler.exportAndStoreLogs(state.tempSDKDir, state.tempTCADir, state.tempWalletDir) + await send(.logsExported) + } catch { + // TODO: [#527] address the error here https://github.com/zcash/secant-ios-wallet/issues/527 + } + } + + case .logsExported: + state.exportLogsDisabled = false + state.isSharingLogs = true + return .none + + case .logsShareFinished: + state.isSharingLogs = false + return .none + case .rescanBlockchain: state.rescanDialog = .init( title: TextState("Rescan"), @@ -94,7 +138,7 @@ extension SettingsViewStore { send: SettingsReducer.Action.updateDestination ) } - + var bindingForBackupPhrase: Binding { self.destinationBinding.map( extract: { $0 == .backupPhrase }, diff --git a/secant/Features/Settings/SettingsView.swift b/secant/Features/Settings/SettingsView.swift index e789b6d..3ac1ff2 100644 --- a/secant/Features/Settings/SettingsView.swift +++ b/secant/Features/Settings/SettingsView.swift @@ -3,7 +3,7 @@ import ComposableArchitecture struct SettingsView: View { let store: SettingsStore - + var body: some View { WithViewStore(store) { viewStore in VStack { @@ -22,7 +22,26 @@ struct SettingsView: View { .primaryButtonStyle .frame(height: 50) .padding(.horizontal, 30) - + + Button( + action: { viewStore.send(.exportLogs) }, + label: { + if viewStore.exportLogsDisabled { + HStack { + ProgressView() + Text("Exporting...") + } + } else { + Text("Export & share logs") + } + } + ) + .primaryButtonStyle + .frame(height: 50) + .padding(.horizontal, 30) + .padding(.top, 30) + .disabled(viewStore.exportLogsDisabled) + Spacer() } .navigationTitle("Settings") @@ -37,6 +56,17 @@ struct SettingsView: View { RecoveryPhraseDisplayView(store: store.backupPhraseStore()) } ) + + if viewStore.isSharingLogs { + UIShareDialogView( + activityItems: [viewStore.tempSDKDir, viewStore.tempWalletDir, viewStore.tempTCADir] + ) { + viewStore.send(.logsShareFinished) + } + // UIShareDialogView only wraps UIActivityViewController presentation + // so frame is set to 0 to not break SwiftUIs layout + .frame(width: 0, height: 0) + } } } } diff --git a/secant/Features/Settings/UIKitBridge/UIShareDialog.swift b/secant/Features/Settings/UIKitBridge/UIShareDialog.swift new file mode 100644 index 0000000..02630e5 --- /dev/null +++ b/secant/Features/Settings/UIKitBridge/UIShareDialog.swift @@ -0,0 +1,54 @@ +// +// UIShareDialog.swift +// secant-testnet +// +// Created by Lukáš Korba on 30.01.2023. +// + +import Foundation +import UIKit +import SwiftUI + +public class UIShareDialog: UIView { + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override init(frame: CGRect) { + super.init(frame: frame) + } +} + +extension UIShareDialog { + public func doInitialSetup(activityItems: [Any], completion: @escaping () -> Void) { + DispatchQueue.main.async { + let activityVC = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + + UIApplication.shared.connectedScenes.map({ $0 as? UIWindowScene }) + .compactMap({ $0 }) + .first?.windows.first?.rootViewController?.present( + activityVC, + animated: true, + completion: completion + ) + } + } +} + +struct UIShareDialogView: UIViewRepresentable { + let activityItems: [Any] + let completion: () -> Void + + public func makeUIView(context: UIViewRepresentableContext) -> UIShareDialog { + let view = UIShareDialog() + view.doInitialSetup(activityItems: activityItems, completion: completion) + return view + } + + public func updateUIView(_ uiView: UIShareDialog, context: UIViewRepresentableContext) { + // We can leave it empty here because the view is just handler how to bridge UIKit's UIActivityViewController + // presentation into SwiftUI. The view itself is not visible, only instantiated, therefore no updates needed. + } + + public typealias UIViewType = UIShareDialog +} diff --git a/secant/SecantApp.swift b/secant/SecantApp.swift index f940b0e..b28fb3c 100644 --- a/secant/SecantApp.swift +++ b/secant/SecantApp.swift @@ -20,6 +20,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + walletLogger = OSLogger_(logLevel: .debug, category: LoggerConstants.walletLogs) // set the default behavior for the NSDecimalNumber NSDecimalNumber.defaultBehavior = Zatoshi.decimalHandler rootViewStore.send(.initialization(.appDelegate(.didFinishLaunching))) diff --git a/secant/Utils/Date+Readable.swift b/secant/Utils/Date+Readable.swift index a126a38..e3d3368 100644 --- a/secant/Utils/Date+Readable.swift +++ b/secant/Utils/Date+Readable.swift @@ -8,12 +8,25 @@ import Foundation extension Date { + static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy/MM/dd HH:mm:ss.SSSS" + return formatter + }() + + static let humanReadableFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + + func timestamp() -> String { + return String(format: "%@", Date.timestampFormatter.string(from: self)) + } + func asHumanReadable() -> String { - let dateFormatter = DateFormatter() - - dateFormatter.dateStyle = .short - dateFormatter.timeStyle = .short - - return dateFormatter.string(from: self) + return Date.humanReadableFormatter.string(from: self) } } diff --git a/secant/Utils/Logging/LogStore.swift b/secant/Utils/Logging/LogStore.swift new file mode 100644 index 0000000..45cd932 --- /dev/null +++ b/secant/Utils/Logging/LogStore.swift @@ -0,0 +1,38 @@ +// +// LogStore.swift +// secant-testnet +// +// Created by Lukáš Korba on 23.01.2023. +// + +import Foundation +import OSLog + +enum LogStore { + static func exportCategory( + _ category: String, + hoursToThePast: TimeInterval = 168, + fileSize: Int = 1_000_000 + ) async throws -> [String]? { + guard let bundle = Bundle.main.bundleIdentifier else { return nil } + + let store = try OSLogStore(scope: .currentProcessIdentifier) + let date = Date.now.addingTimeInterval(-hoursToThePast * 3600) + let position = store.position(date: date) + var res: [String] = [] + var size = 0 + + let entries = try store.getEntries(at: position).reversed() + for entry in entries { + guard let logEntry = entry as? OSLogEntryLog else { continue } + guard logEntry.subsystem == bundle && logEntry.category == category else { continue } + + guard size < fileSize else { break } + + size += logEntry.composedMessage.utf8.count + res.append("[\(logEntry.date.timestamp())] \(logEntry.composedMessage)") + } + + return res + } +} diff --git a/secant/Utils/Logging/OSLogger_.swift b/secant/Utils/Logging/OSLogger_.swift new file mode 100644 index 0000000..7cef8ff --- /dev/null +++ b/secant/Utils/Logging/OSLogger_.swift @@ -0,0 +1,135 @@ +// +// OSLogger_.swift +// secant-testnet +// +// Created by Lukáš Korba on 23.01.2023. +// + +import Foundation +import ZcashLightClientKit +import os + +enum LoggerConstants { + static let sdkLogs = "sdkLogs" + static let tcaLogs = "tcaLogs" + static let walletLogs = "walletLogs" +} + +var walletLogger: ZcashLightClientKit.Logger? + +enum LoggerProxy { + static func debug(_ message: String, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + walletLogger?.debug(message, file: file, function: function, line: line) + } + + static func info(_ message: String, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + walletLogger?.info(message, file: file, function: function, line: line) + } + + static func event(_ message: String, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + walletLogger?.event(message, file: file, function: function, line: line) + } + + static func warn(_ message: String, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + walletLogger?.warn(message, file: file, function: function, line: line) + } + + static func error(_ message: String, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + walletLogger?.error(message, file: file, function: function, line: line) + } +} + +// TODO: [#529] the swiftlint rule as well as OSLogger_ will be removed once secant adopts latest SDK changes https://github.com/zcash/secant-ios-wallet/issues/529 +// swiftlint:disable:next type_name +class OSLogger_: ZcashLightClientKit.Logger { + enum LogLevel: Int { + case debug + case error + case warning + case event + case info + } + + private(set) var oslog: OSLog? + + var level: LogLevel + + init(logLevel: LogLevel, category: String) { + self.level = logLevel + if let bundleName = Bundle.main.bundleIdentifier { + self.oslog = OSLog(subsystem: bundleName, category: category) + } + } + + func debug( + _ message: String, + file: StaticString = #file, + function: StaticString = #function, + line: Int = #line + ) { + guard level.rawValue == LogLevel.debug.rawValue else { return } + log(level: "DEBUG 🐞", message: message, file: file, function: function, line: line) + } + + func error( + _ message: String, + file: StaticString = #file, + function: StaticString = #function, + line: Int = #line + ) { + guard level.rawValue <= LogLevel.error.rawValue else { return } + log(level: "ERROR 💥", message: message, file: file, function: function, line: line) + } + + func warn( + _ message: String, + file: StaticString = #file, + function: StaticString = #function, + line: Int = #line + ) { + guard level.rawValue <= LogLevel.warning.rawValue else { return } + log(level: "WARNING ⚠️", message: message, file: file, function: function, line: line) + } + + func event( + _ message: String, + file: StaticString = #file, + function: StaticString = #function, + line: Int = #line + ) { + guard level.rawValue <= LogLevel.event.rawValue else { return } + log(level: "EVENT ⏱", message: message, file: file, function: function, line: line) + } + + func info( + _ message: String, + file: StaticString = #file, + function: StaticString = #function, + line: Int = #line + ) { + guard level.rawValue <= LogLevel.info.rawValue else { return } + log(level: "INFO ℹ️", message: message, file: file, function: function, line: line) + } + + private func log( + level: String, + message: String, + file: StaticString = #file, + function: StaticString = #function, + line: Int = #line + ) { + guard let oslog else { return } + + let fileName = (String(describing: file) as NSString).lastPathComponent + + os_log( + "[%{public}@] %{public}@ - %{public}@ - Line: %{public}d -> %{public}@", + log: oslog, + level, + fileName, + String(describing: function), + line, + message + ) + } +} diff --git a/secant/Utils/Logging/TCALogger.swift b/secant/Utils/Logging/TCALogger.swift new file mode 100644 index 0000000..a5bcf0b --- /dev/null +++ b/secant/Utils/Logging/TCALogger.swift @@ -0,0 +1,25 @@ +// +// TCALogger.swift +// secant-testnet +// +// Created by Lukáš Korba on 23.01.2023. +// + +import Foundation +import os + +class TCALogger: OSLogger_ { } + +extension TCALogger { + static let live = TCALogger(logLevel: .debug, category: LoggerConstants.tcaLogs) + + func tcaDebug(_ message: String) { + guard let oslog else { return } + + os_log( + "%{public}@", + log: oslog, + message + ) + } +} diff --git a/secant/Utils/Logging/TCALoggerReducer.swift b/secant/Utils/Logging/TCALoggerReducer.swift new file mode 100644 index 0000000..cd3cf00 --- /dev/null +++ b/secant/Utils/Logging/TCALoggerReducer.swift @@ -0,0 +1,73 @@ +// +// TCALoggerReducer.swift +// secant-testnet +// +// Created by Lukáš Korba on 23.01.2023. +// + +import ComposableArchitecture + +extension ReducerProtocol { + @inlinable + public func logging( + _ logger: ReducerLogger? = .tcaLogger + ) -> LogChangesReducer { + LogChangesReducer(base: self, logger: logger) + } +} + +public struct ReducerLogger { + private let _logChange: (_ receivedAction: Action, _ oldState: State, _ newState: State) -> Void + + public init( + logChange: @escaping (_ receivedAction: Action, _ oldState: State, _ newState: State) -> Void + ) { + self._logChange = logChange + } + + public func logChange(receivedAction: Action, oldState: State, newState: State) { + self._logChange(receivedAction, oldState, newState) + } +} + +extension ReducerLogger { + public static var tcaLogger: Self { + Self { receivedAction, oldState, newState in + var target = "" + target.write("received action:\n") + CustomDump.customDump(receivedAction, to: &target, indent: 2) + target.write("\n") + target.write(diff(oldState, newState).map { "\($0)\n" } ?? " (No state changes)\n") + TCALogger.live.tcaDebug("\(target)") + } + } +} + +public struct LogChangesReducer: ReducerProtocol { + @usableFromInline let base: Base + + @usableFromInline let logger: ReducerLogger? + + @usableFromInline + init(base: Base, logger: ReducerLogger?) { + self.base = base + self.logger = logger + } + + @inlinable + public func reduce( + into state: inout Base.State, action: Base.Action + ) -> EffectTask { + guard let logger else { + return self.base.reduce(into: &state, action: action) + } + + let oldState = state + let effects = self.base.reduce(into: &state, action: action) + return effects.merge( + with: .fireAndForget { [newState = state] in + logger.logChange(receivedAction: action, oldState: oldState, newState: newState) + } + ) + } +} diff --git a/secantTests/SettingsTests/SettingsTests.swift b/secantTests/SettingsTests/SettingsTests.swift index 444b54e..dea7021 100644 --- a/secantTests/SettingsTests/SettingsTests.swift +++ b/secantTests/SettingsTests/SettingsTests.swift @@ -99,6 +99,7 @@ class SettingsTests: XCTestCase { func testRescanBlockchain_Cancelling() async throws { let store = TestStore( initialState: SettingsReducer.State( + destination: nil, phraseDisplayState: .init(), rescanDialog: .init( title: TextState("Rescan"), @@ -108,8 +109,7 @@ class SettingsTests: XCTestCase { .default(TextState("Full rescan"), action: .send(.fullRescan)), .cancel(TextState("Cancel")) ] - ), - destination: nil + ) ), reducer: SettingsReducer() ) @@ -122,6 +122,7 @@ class SettingsTests: XCTestCase { func testRescanBlockchain_QuickRescanClearance() async throws { let store = TestStore( initialState: SettingsReducer.State( + destination: nil, phraseDisplayState: .init(), rescanDialog: .init( title: TextState("Rescan"), @@ -131,8 +132,7 @@ class SettingsTests: XCTestCase { .default(TextState("Full rescan"), action: .send(.fullRescan)), .cancel(TextState("Cancel")) ] - ), - destination: nil + ) ), reducer: SettingsReducer() ) @@ -145,6 +145,7 @@ class SettingsTests: XCTestCase { func testRescanBlockchain_FullRescanClearance() async throws { let store = TestStore( initialState: SettingsReducer.State( + destination: nil, phraseDisplayState: .init(), rescanDialog: .init( title: TextState("Rescan"), @@ -154,8 +155,7 @@ class SettingsTests: XCTestCase { .default(TextState("Full rescan"), action: .send(.fullRescan)), .cancel(TextState("Cancel")) ] - ), - destination: nil + ) ), reducer: SettingsReducer() ) @@ -164,4 +164,58 @@ class SettingsTests: XCTestCase { state.rescanDialog = nil } } + + func testExportLogs_ButtonDisableShareEnable() async throws { + let store = TestStore( + initialState: SettingsReducer.State( + destination: nil, + phraseDisplayState: .init(), + rescanDialog: .init( + title: TextState("Rescan"), + message: TextState("Select the rescan you want"), + buttons: [ + .default(TextState("Quick rescan"), action: .send(.quickRescan)), + .default(TextState("Full rescan"), action: .send(.fullRescan)), + .cancel(TextState("Cancel")) + ] + ) + ), + reducer: SettingsReducer() + ) + + store.dependencies.logsHandler = LogsHandlerClient(exportAndStoreLogs: { _, _, _ in }) + + _ = await store.send(.exportLogs) { state in + state.exportLogsDisabled = true + } + + await store.receive(.logsExported) { state in + state.exportLogsDisabled = false + state.isSharingLogs = true + } + } + + func testLogShareFinished() async throws { + let store = TestStore( + initialState: SettingsReducer.State( + destination: nil, + isSharingLogs: true, + phraseDisplayState: .init(), + rescanDialog: .init( + title: TextState("Rescan"), + message: TextState("Select the rescan you want"), + buttons: [ + .default(TextState("Quick rescan"), action: .send(.quickRescan)), + .default(TextState("Full rescan"), action: .send(.fullRescan)), + .cancel(TextState("Cancel")) + ] + ) + ), + reducer: SettingsReducer() + ) + + _ = await store.send(.logsShareFinished) { state in + state.isSharingLogs = false + } + } } diff --git a/secantTests/UtilTests/LoggerTests.swift b/secantTests/UtilTests/LoggerTests.swift new file mode 100644 index 0000000..7ba008e --- /dev/null +++ b/secantTests/UtilTests/LoggerTests.swift @@ -0,0 +1,270 @@ +// +// LoggerTests.swift +// secantTests +// +// Created by Lukáš Korba on 24.01.2023. +// + +import XCTest +@testable import secant_testnet +import OSLog + +class LoggerTests: XCTestCase { + func testOSLogger_loggingAndExport() throws { + let category = "testOSLogger_loggingAndExport" + let osLogger = OSLogger_(logLevel: .debug, category: category) + let testMessage = "test message" + + osLogger.debug(testMessage) + let logs = TestLogStore.exportCategory(category) + + XCTAssertNotNil(logs) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 1) + + let loggedMessage = logs[0].osLoggedMessage() + + XCTAssertEqual(testMessage, loggedMessage) + } + + func testOSLogger_DebugLevel_DebugLog() throws { + let category = "testOSLogger_DebugLevel_DebugLog" + let osLogger = OSLogger_(logLevel: .debug, category: category) + let testMessage = "debug message" + + osLogger.debug(testMessage) + let logs = TestLogStore.exportCategory(category) + + XCTAssertNotNil(logs) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 1) + + let loggedMessage = logs[0].osLoggedMessage() + + XCTAssertEqual(testMessage, loggedMessage) + } + + func testOSLogger_ErrorLevel_ErrorLog() throws { + let category = "testOSLogger_ErrorLevel_ErrorLog" + let osLogger = OSLogger_(logLevel: .debug, category: category) + let testMessage = "error message" + + osLogger.error(testMessage) + let logs = TestLogStore.exportCategory(category) + + XCTAssertNotNil(logs) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 1) + + let loggedMessage = logs[0].osLoggedMessage() + + XCTAssertEqual(testMessage, loggedMessage) + } + + func testOSLogger_WarningLevel_WarningLog() throws { + let category = "testOSLogger_WarningLevel_WarningLog" + let osLogger = OSLogger_(logLevel: .warning, category: category) + let testMessage = "warning message" + + osLogger.warn(testMessage) + let logs = TestLogStore.exportCategory(category) + + XCTAssertNotNil(logs) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 1) + + let loggedMessage = logs[0].osLoggedMessage() + + XCTAssertEqual(testMessage, loggedMessage) + } + + func testOSLogger_EventLevel_EventLog() throws { + let category = "testOSLogger_EventLevel_EventLog" + let osLogger = OSLogger_(logLevel: .event, category: category) + let testMessage = "event message" + + osLogger.event(testMessage) + let logs = TestLogStore.exportCategory(category) + + XCTAssertNotNil(logs) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 1) + + let loggedMessage = logs[0].osLoggedMessage() + + XCTAssertEqual(testMessage, loggedMessage) + } + + func testOSLogger_InfoLevel_InfoLog() throws { + let category = "testOSLogger_InfoLevel_InfoLog" + let osLogger = OSLogger_(logLevel: .info, category: category) + let testMessage = "info message" + + osLogger.info(testMessage) + let logs = TestLogStore.exportCategory(category) + + XCTAssertNotNil(logs) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 1) + + let loggedMessage = logs[0].osLoggedMessage() + + XCTAssertEqual(testMessage, loggedMessage) + } + + func testOSLogger_DebugLevel_OtherLogs() throws { + let category = "testOSLogger_DebugLevel_OtherLogs" + let osLogger = OSLogger_(logLevel: .debug, category: category) + let testMessage = "debug message" + + osLogger.debug(testMessage) + osLogger.error(testMessage) + osLogger.warn(testMessage) + osLogger.event(testMessage) + osLogger.info(testMessage) + + let logs = TestLogStore.exportCategory(category) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 5) + } + + func testOSLogger_ErrorLevel_OtherLogs() throws { + let category = "testOSLogger_ErrorLevel_OtherLogs" + let osLogger = OSLogger_(logLevel: .error, category: category) + let testMessage = "debug message" + + osLogger.debug(testMessage) + osLogger.error(testMessage) + osLogger.warn(testMessage) + osLogger.event(testMessage) + osLogger.info(testMessage) + + let logs = TestLogStore.exportCategory(category) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 4) + } + + func testOSLogger_WarningLevel_OtherLogs() throws { + let category = "testOSLogger_WarningLevel_OtherLogs" + let osLogger = OSLogger_(logLevel: .warning, category: category) + let testMessage = "debug message" + + osLogger.debug(testMessage) + osLogger.error(testMessage) + osLogger.warn(testMessage) + osLogger.event(testMessage) + osLogger.info(testMessage) + + let logs = TestLogStore.exportCategory(category) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 3) + } + + func testOSLogger_EventLevel_OtherLogs() throws { + let category = "testOSLogger_EventLevel_OtherLogs" + let osLogger = OSLogger_(logLevel: .event, category: category) + let testMessage = "debug message" + + osLogger.debug(testMessage) + osLogger.error(testMessage) + osLogger.warn(testMessage) + osLogger.event(testMessage) + osLogger.info(testMessage) + + let logs = TestLogStore.exportCategory(category) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 2) + } + + func testOSLogger_InfoLevel_OtherLogs() throws { + let category = "testOSLogger_InfoLevel_OtherLogs" + let osLogger = OSLogger_(logLevel: .info, category: category) + let testMessage = "debug message" + + osLogger.debug(testMessage) + osLogger.error(testMessage) + osLogger.warn(testMessage) + osLogger.event(testMessage) + osLogger.info(testMessage) + + let logs = TestLogStore.exportCategory(category) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 1) + } + + func testWalletLogger() throws { + let category = "testWalletLogger" + walletLogger = OSLogger_(logLevel: .info, category: category) + let testMessage = "wallet test message" + + LoggerProxy.info(testMessage) + let logs = TestLogStore.exportCategory(category) + + XCTAssertNotNil(logs) + + guard let logs else { return } + + XCTAssertEqual(logs.count, 1) + + let loggedMessage = logs[0].osLoggedMessage() + + XCTAssertEqual(testMessage, loggedMessage) + } +} + +extension String { + func osLoggedMessage() -> String? { + let split = components(separatedBy: "-> ") + + if split.count == 2 { + return split[1] + } + + return nil + } +} + +enum TestLogStore { + static func exportCategory(_ category: String, hoursToThePast: TimeInterval = 24) -> [String]? { + guard let bundle = Bundle.main.bundleIdentifier else { return nil } + + do { + let store = try OSLogStore(scope: .currentProcessIdentifier) + let date = Date.now.addingTimeInterval(-hoursToThePast * 3600) + let position = store.position(date: date) + var entries: [String] = [] + + entries = try store + .getEntries(at: position) + .compactMap { $0 as? OSLogEntryLog } + .filter { $0.subsystem == bundle && $0.category == category } + .map { "[\($0.date.formatted())] \($0.composedMessage)" } + + return entries + } catch { + return nil + } + } +}