From 7202a95bb2a09572dbabeae79cdf8773406af218 Mon Sep 17 00:00:00 2001 From: kingbri Date: Tue, 7 Mar 2023 17:58:04 -0500 Subject: [PATCH] Ferrite: Parallel tasks and logging Make all tasks run in parallel to increase responsiveness and efficiency when fetching new data. However, parallel tasks means that toast errors are no longer feasible. Instead, add a logging system which has a more detailed view of app messages and direct the user there if there is an error. Signed-off-by: kingbri --- Ferrite.xcodeproj/project.pbxproj | 16 +- Ferrite/Classes/Logger.swift | 65 +++ Ferrite/FerriteApp.swift | 14 +- Ferrite/Models/KodiModels.swift | 3 + Ferrite/Models/PluginModels.swift | 5 + Ferrite/Models/SearchModels.swift | 11 + Ferrite/ViewModels/BackupManager.swift | 25 +- Ferrite/ViewModels/DebridManager.swift | 55 +- Ferrite/ViewModels/LoggingManager.swift | 136 +++++ Ferrite/ViewModels/NavigationViewModel.swift | 2 +- Ferrite/ViewModels/PluginManager.swift | 194 ++++--- Ferrite/ViewModels/ScrapingViewModel.swift | 533 +++++++++++------- Ferrite/ViewModels/ToastViewModel.swift | 70 --- .../Library/HistoryActionsView.swift | 4 +- .../Library/HistoryButtonView.swift | 7 +- .../Settings/DefaultActionPickerView.swift | 6 +- .../Settings/SettingsAppVersionView.swift | 6 +- .../Settings/SettingsDebridInfoView.swift | 4 +- .../Settings/SettingsKodiView.swift | 2 +- .../Settings/SettingsLogView.swift | 33 ++ Ferrite/Views/ContentView.swift | 23 +- Ferrite/Views/MainView.swift | 50 +- Ferrite/Views/SettingsView.swift | 7 + 23 files changed, 840 insertions(+), 431 deletions(-) create mode 100644 Ferrite/Classes/Logger.swift create mode 100644 Ferrite/ViewModels/LoggingManager.swift delete mode 100644 Ferrite/ViewModels/ToastViewModel.swift create mode 100644 Ferrite/Views/ComponentViews/Settings/SettingsLogView.swift diff --git a/Ferrite.xcodeproj/project.pbxproj b/Ferrite.xcodeproj/project.pbxproj index b3dd605..dded14c 100644 --- a/Ferrite.xcodeproj/project.pbxproj +++ b/Ferrite.xcodeproj/project.pbxproj @@ -49,6 +49,8 @@ 0C50B7D0299DF63C00A9FA3C /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */; }; 0C54D36328C5086E00BFEEE2 /* History+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */; }; 0C54D36428C5086E00BFEEE2 /* History+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */; }; + 0C5708E929B8E61C00BE07F9 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708E829B8E61C00BE07F9 /* Logger.swift */; }; + 0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */; }; 0C572D4C2993FC2A003EEC05 /* ViewDidAppearHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */; }; 0C572D4E299403B7003EEC05 /* ViewDidAppear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */; }; 0C57D4CC289032ED008534E8 /* SearchResultInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */; }; @@ -103,7 +105,7 @@ 0CA148E3288903F000DE2211 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CB288903F000DE2211 /* Task.swift */; }; 0CA148E5288903F000DE2211 /* DebridManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CD288903F000DE2211 /* DebridManager.swift */; }; 0CA148E6288903F000DE2211 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CE288903F000DE2211 /* WebView.swift */; }; - 0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CF288903F000DE2211 /* ToastViewModel.swift */; }; + 0CA148E7288903F000DE2211 /* LoggingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148CF288903F000DE2211 /* LoggingManager.swift */; }; 0CA148E8288903F000DE2211 /* RealDebridWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */; }; 0CA148E9288903F000DE2211 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D1288903F000DE2211 /* MainView.swift */; }; 0CA148EC288903F000DE2211 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D4288903F000DE2211 /* ContentView.swift */; }; @@ -179,6 +181,8 @@ 0C50B7CF299DF63C00A9FA3C /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; 0C54D36128C5086E00BFEEE2 /* History+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataClass.swift"; sourceTree = ""; }; 0C54D36228C5086E00BFEEE2 /* History+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "History+CoreDataProperties.swift"; sourceTree = ""; }; + 0C5708E829B8E61C00BE07F9 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLogView.swift; sourceTree = ""; }; 0C572D4B2993FC2A003EEC05 /* ViewDidAppearHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppearHandler.swift; sourceTree = ""; }; 0C572D4D299403B7003EEC05 /* ViewDidAppear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewDidAppear.swift; sourceTree = ""; }; 0C57D4CB289032ED008534E8 /* SearchResultInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultInfoView.swift; sourceTree = ""; }; @@ -230,7 +234,7 @@ 0CA148CB288903F000DE2211 /* Task.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; 0CA148CD288903F000DE2211 /* DebridManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebridManager.swift; sourceTree = ""; }; 0CA148CE288903F000DE2211 /* WebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; - 0CA148CF288903F000DE2211 /* ToastViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToastViewModel.swift; sourceTree = ""; }; + 0CA148CF288903F000DE2211 /* LoggingManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggingManager.swift; sourceTree = ""; }; 0CA148D0288903F000DE2211 /* RealDebridWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RealDebridWrapper.swift; sourceTree = ""; }; 0CA148D1288903F000DE2211 /* MainView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; 0CA148D4288903F000DE2211 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -387,6 +391,7 @@ isa = PBXGroup; children = ( 0C44E2A728D4DDDC007711AE /* Application.swift */, + 0C5708E829B8E61C00BE07F9 /* Logger.swift */, ); path = Classes; sourceTree = ""; @@ -450,6 +455,7 @@ 0C10848A28BD9A38008F0BA6 /* SettingsAppVersionView.swift */, 0C6771F529B3B602005D38D2 /* SettingsKodiView.swift */, 0C6771FD29B521F1005D38D2 /* SettingsDebridInfoView.swift */, + 0C5708EA29B8F89300BE07F9 /* SettingsLogView.swift */, ); path = Settings; sourceTree = ""; @@ -544,7 +550,7 @@ children = ( 0CA148CD288903F000DE2211 /* DebridManager.swift */, 0CA148C3288903F000DE2211 /* ScrapingViewModel.swift */, - 0CA148CF288903F000DE2211 /* ToastViewModel.swift */, + 0CA148CF288903F000DE2211 /* LoggingManager.swift */, 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */, 0CA05458288EE9E600850554 /* PluginManager.swift */, 0C44E2AC28D51C63007711AE /* BackupManager.swift */, @@ -753,6 +759,7 @@ 0CA429F828C5098D000D0610 /* DateFormatter.swift in Sources */, 0C5005522992B6750064606A /* PluginTagsView.swift in Sources */, 0CE66B3A28E640D200F69346 /* Backport.swift in Sources */, + 0C5708E929B8E61C00BE07F9 /* Logger.swift in Sources */, 0C42B5962932F2D5008057A0 /* DebridPickerView.swift in Sources */, 0C2886D72960C50900D6FC16 /* RealDebridCloudView.swift in Sources */, 0C794B6B289DACF100DD1CC8 /* PluginCatalogButtonView.swift in Sources */, @@ -781,6 +788,7 @@ 0CA148E1288903F000DE2211 /* Collection.swift in Sources */, 0C750744289B003E004B3906 /* SourceRssParser+CoreDataClass.swift in Sources */, 0C794B69289DACC800DD1CC8 /* InstalledPluginButtonView.swift in Sources */, + 0C5708EB29B8F89300BE07F9 /* SettingsLogView.swift in Sources */, 0C79DC082899AF3C003F1C5A /* SourceSeedLeech+CoreDataProperties.swift in Sources */, 0C6771FE29B521F1005D38D2 /* SettingsDebridInfoView.swift in Sources */, 0CD4CAC628C980EB0046E1DC /* HistoryActionsView.swift in Sources */, @@ -793,7 +801,7 @@ 0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */, 0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */, 0CA148E3288903F000DE2211 /* Task.swift in Sources */, - 0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */, + 0CA148E7288903F000DE2211 /* LoggingManager.swift in Sources */, 0C6771FC29B3E0DB005D38D2 /* HybridSecureField.swift in Sources */, 0C6C7C9D29315292002DF910 /* AllDebridModels.swift in Sources */, 0C6C7C9B2931521B002DF910 /* AllDebridWrapper.swift in Sources */, diff --git a/Ferrite/Classes/Logger.swift b/Ferrite/Classes/Logger.swift new file mode 100644 index 0000000..8d7c485 --- /dev/null +++ b/Ferrite/Classes/Logger.swift @@ -0,0 +1,65 @@ +// +// Logger.swift +// Ferrite +// +// Created by Brian Dashore on 3/8/23. +// + +import Foundation + +public class Logger { + var messageArray: [Log] = [] + + struct Log: Hashable { + let level: LogLevel + let description: String + let timeStamp: Date = .init() + + func toMessage() -> String { + "[\(level.rawValue)]: \(description)" + } + } + + enum LogLevel: String, Identifiable { + var id: Int { + hashValue + } + + case info = "INFO" + case warn = "WARN" + case error = "ERROR" + } + + func info(_ message: String) { + let log = Log( + level: .info, + description: message + ) + + messageArray.append(log) + + print("LOG: \(log.toMessage())") + } + + func warn(_ message: String) { + let log = Log( + level: .warn, + description: message + ) + + messageArray.append(log) + + print("LOG: \(log.toMessage())") + } + + func error(_ message: String) { + let log = Log( + level: .error, + description: message + ) + + messageArray.append(log) + + print("LOG: \(log.toMessage())") + } +} diff --git a/Ferrite/FerriteApp.swift b/Ferrite/FerriteApp.swift index 7e5fcc8..0562d3c 100644 --- a/Ferrite/FerriteApp.swift +++ b/Ferrite/FerriteApp.swift @@ -12,7 +12,7 @@ struct FerriteApp: App { let persistenceController = PersistenceController.shared @StateObject var scrapingModel: ScrapingViewModel = .init() - @StateObject var toastModel: ToastViewModel = .init() + @StateObject var logManager: LoggingManager = .init() @StateObject var debridManager: DebridManager = .init() @StateObject var navModel: NavigationViewModel = .init() @StateObject var pluginManager: PluginManager = .init() @@ -22,15 +22,15 @@ struct FerriteApp: App { WindowGroup { MainView() .backport.onAppear { - scrapingModel.toastModel = toastModel - debridManager.toastModel = toastModel - pluginManager.toastModel = toastModel - backupManager.toastModel = toastModel - navModel.toastModel = toastModel + scrapingModel.logManager = logManager + debridManager.logManager = logManager + pluginManager.logManager = logManager + backupManager.logManager = logManager + navModel.logManager = logManager } .environmentObject(debridManager) .environmentObject(scrapingModel) - .environmentObject(toastModel) + .environmentObject(logManager) .environmentObject(navModel) .environmentObject(pluginManager) .environmentObject(backupManager) diff --git a/Ferrite/Models/KodiModels.swift b/Ferrite/Models/KodiModels.swift index 323d096..5735cd1 100644 --- a/Ferrite/Models/KodiModels.swift +++ b/Ferrite/Models/KodiModels.swift @@ -16,6 +16,7 @@ extension Kodi { } // MARK: - RPC payload + struct RPCPayload: Encodable { let jsonrpc: String = "2.0" let id: String = "1" @@ -24,11 +25,13 @@ extension Kodi { } // MARK: - RPC Params + struct Params: Codable { let item: Item } // MARK: - RPC Item + struct Item: Codable { let file: String } diff --git a/Ferrite/Models/PluginModels.swift b/Ferrite/Models/PluginModels.swift index ee97b3f..afe79fe 100644 --- a/Ferrite/Models/PluginModels.swift +++ b/Ferrite/Models/PluginModels.swift @@ -29,4 +29,9 @@ extension PluginManager { enum PluginManagerError: Error { case ListAddition(description: String) } + + struct AvailablePlugins { + let availableSources: [SourceJson] + let availableActions: [ActionJson] + } } diff --git a/Ferrite/Models/SearchModels.swift b/Ferrite/Models/SearchModels.swift index d09e0fa..3fcc861 100644 --- a/Ferrite/Models/SearchModels.swift +++ b/Ferrite/Models/SearchModels.swift @@ -7,6 +7,7 @@ import Foundation +// A raw search result structure displayed on the UI public struct SearchResult: Codable, Hashable, Sendable { let title: String? let source: String @@ -15,3 +16,13 @@ public struct SearchResult: Codable, Hashable, Sendable { let seeders: String? let leechers: String? } + +extension ScrapingViewModel { + // Contains both search results and magnet links for scalability + struct SearchRequestResult: Sendable { + let results: [SearchResult] + let magnets: [Magnet] + } + + struct ScrapingError: Error {} +} diff --git a/Ferrite/ViewModels/BackupManager.swift b/Ferrite/ViewModels/BackupManager.swift index 70fd27a..72662f2 100644 --- a/Ferrite/ViewModels/BackupManager.swift +++ b/Ferrite/ViewModels/BackupManager.swift @@ -11,7 +11,7 @@ public class BackupManager: ObservableObject { // Constant variable for backup versions let latestBackupVersion: Int = 2 - var toastModel: ToastViewModel? + var logManager: LoggingManager? @Published var showRestoreAlert = false @Published var showRestoreCompletedAlert = false @@ -110,17 +110,18 @@ public class BackupManager: ObservableObject { await updateBackupUrls(newUrl: writeUrl) } catch { - await toastModel?.updateToastDescription("Backup error: \(error)") - print("Backup error: \(error)") + await logManager?.error("Backup: \(error)") } } // Backup is in local documents directory, so no need to restore it from the shared URL - // Pass the pluginManager reference since it's not used throughout the class like toastModel + // Pass the pluginManager reference since it's not used throughout the class like logManager func restoreBackup(pluginManager: PluginManager, doOverwrite: Bool) async { guard let backupUrl = selectedBackupUrl else { - await toastModel?.updateToastDescription("Could not find the selected backup in the local directory.") - print("Backup restore error: Could not find backup in app directory.") + await logManager?.error( + "Backup restore: Could not find backup in app directory.", + description: "Could not find the selected backup in the local directory." + ) return } @@ -194,8 +195,10 @@ public class BackupManager: ObservableObject { await toggleRestoreCompletedAlert() } } catch { - await toastModel?.updateToastDescription("Backup restore error: \(error)") - print("Backup restore error: \(error)") + await logManager?.error( + "Backup restore: \(error)", + description: "A backup restore error was logged" + ) } } @@ -212,8 +215,8 @@ public class BackupManager: ObservableObject { } } catch { Task { - await toastModel?.updateToastDescription("Backup removal error: \(error)") - print("Backup removal error: \(error)") + await logManager?.error("Backup removal: \(error)") + print("Backup removal: \(error)") } } } @@ -242,7 +245,7 @@ public class BackupManager: ObservableObject { selectedBackupUrl = localBackupPath } catch { Task { - await toastModel?.updateToastDescription("Backup copy: \(error)") + await logManager?.error("Backup copy: \(error)") } } } diff --git a/Ferrite/ViewModels/DebridManager.swift b/Ferrite/ViewModels/DebridManager.swift index d253d4d..f2dcffc 100644 --- a/Ferrite/ViewModels/DebridManager.swift +++ b/Ferrite/ViewModels/DebridManager.swift @@ -11,7 +11,7 @@ import SwiftUI @MainActor public class DebridManager: ObservableObject { // Linked classes - var toastModel: ToastViewModel? + var logManager: LoggingManager? let realDebrid: RealDebrid = .init() let allDebrid: AllDebrid = .init() let premiumize: Premiumize = .init() @@ -122,17 +122,30 @@ public class DebridManager: ObservableObject { // Wrapper function to match error descriptions // Error can be suppressed to end user but must be printed in logs - func sendDebridError(_ error: Error, prefix: String, presentError: Bool = true, cancelString: String? = nil) async { + func sendDebridError( + _ error: Error, + prefix: String, + presentError: Bool = true, + cancelString: String? = nil + ) async { let error = error as NSError if presentError { - if let cancelString, error.code == -999 { - toastModel?.updateToastDescription(cancelString, newToastType: .info) - } else if error.code != -999 { - toastModel?.updateToastDescription("\(prefix): \(error)") + switch error.code { + case -1009: + logManager?.info( + "DebridManager: The connection is offline", + description: "The connection is offline" + ) + case -999: + if let cancelString { + logManager?.info(cancelString, description: cancelString) + } else { + break + } + default: + logManager?.error("\(prefix): \(error)") } } - - print("\(prefix): \(error)") } // Cleans all cached IA values in the event of a full IA refresh @@ -273,7 +286,7 @@ public class DebridManager: ObservableObject { public func selectDebridResult(magnet: Magnet) -> Bool { guard let magnetHash = magnet.hash else { - toastModel?.updateToastDescription("Could not find the torrent magnet hash") + logManager?.error("DebridManager: Could not find the torrent magnet hash") return false } @@ -283,7 +296,7 @@ public class DebridManager: ObservableObject { selectedRealDebridItem = realDebridItem return true } else { - toastModel?.updateToastDescription("Could not find the associated RealDebrid entry for magnet hash \(magnetHash)") + logManager?.error("DebridManager: Could not find the associated RealDebrid entry for magnet hash \(magnetHash)") return false } case .allDebrid: @@ -291,7 +304,7 @@ public class DebridManager: ObservableObject { selectedAllDebridItem = allDebridItem return true } else { - toastModel?.updateToastDescription("Could not find the associated AllDebrid entry for magnet hash \(magnetHash)") + logManager?.error("DebridManager: Could not find the associated AllDebrid entry for magnet hash \(magnetHash)") return false } case .premiumize: @@ -299,7 +312,7 @@ public class DebridManager: ObservableObject { selectedPremiumizeItem = premiumizeItem return true } else { - toastModel?.updateToastDescription("Could not find the associated Premiumize entry for magnet hash \(magnetHash)") + logManager?.error("DebridManager: Could not find the associated Premiumize entry for magnet hash \(magnetHash)") return false } case .none: @@ -353,7 +366,7 @@ public class DebridManager: ObservableObject { // Wrapper function to validate and present an auth URL to the user @discardableResult func validateAuthUrl(_ url: URL?, useAuthSession: Bool = false) -> Bool { guard let url else { - toastModel?.updateToastDescription("Authentication Error: Invalid URL created: \(String(describing: url))") + logManager?.error("DebridManager: Authentication: Invalid URL created: \(String(describing: url))") return false } @@ -475,7 +488,10 @@ public class DebridManager: ObservableObject { allDebrid.deleteTokens() enabledDebrids.remove(.allDebrid) - toastModel?.updateToastDescription("Please manually delete the AllDebrid API key", newToastType: .info) + logManager?.info( + "AllDebrid: Logged out, API key needs to be removed", + description: "Please manually delete the AllDebrid API key" + ) } private func logoutPm() { @@ -490,10 +506,10 @@ public class DebridManager: ObservableObject { public func fetchDebridDownload(magnet: Magnet?, cloudInfo: String? = nil) async { defer { currentDebridTask = nil - toastModel?.hideIndeterminateToast() + logManager?.hideIndeterminateToast() } - toastModel?.updateIndeterminateToast("Loading content", cancelAction: { + logManager?.updateIndeterminateToast("Loading content", cancelAction: { self.currentDebridTask?.cancel() self.currentDebridTask = nil }) @@ -553,7 +569,10 @@ public class DebridManager: ObservableObject { downloadUrl = downloadLink } else { - toastModel?.updateToastDescription("Could not cache this torrent. Aborting.") + logManager?.error( + "RealDebrid: Could not cache torrent with hash \(String(describing: magnet.hash))", + description: "Could not cache this torrent. Aborting." + ) } } else { throw RealDebrid.RDError.FailedRequest(description: "Could not fetch your file from RealDebrid's cache or API") @@ -571,7 +590,7 @@ public class DebridManager: ObservableObject { await deleteRdTorrent(torrentID: selectedRealDebridID, presentError: false) } - toastModel?.hideIndeterminateToast() + logManager?.hideIndeterminateToast() } } diff --git a/Ferrite/ViewModels/LoggingManager.swift b/Ferrite/ViewModels/LoggingManager.swift new file mode 100644 index 0000000..8db85ef --- /dev/null +++ b/Ferrite/ViewModels/LoggingManager.swift @@ -0,0 +1,136 @@ +// +// ToastViewModel.swift +// Ferrite +// +// Created by Brian Dashore on 7/19/22. +// + +import SwiftUI + +@MainActor +class LoggingManager: ObservableObject { + struct Log: Hashable { + let level: LogLevel + let message: String + let timeStamp: Date = .init() + + func toMessage() -> String { + "[\(level.rawValue)]: \(message)" + } + } + + enum LogLevel: String, Identifiable { + var id: Int { + hashValue + } + + case info = "INFO" + case warn = "WARN" + case error = "ERROR" + } + + @Published var messageArray: [Log] = [] + + // Toast variables + @Published var toastDescription: String? = nil { + didSet { + Task { + try? await Task.sleep(seconds: 0.1) + showToast = true + + try? await Task.sleep(seconds: 3) + + showToast = false + toastType = .error + } + } + } + + @Published var showToast: Bool = false + // Default the toast type to error since the majority of toasts are errors + @Published var toastType: Logger.LogLevel = .error + var showErrorToasts: Bool { + UserDefaults.standard.bool(forKey: "Debug.ShowErrorToasts") + } + + @Published var indeterminateToastDescription: String? = nil + @Published var indeterminateCancelAction: (() -> Void)? = nil + @Published var showIndeterminateToast: Bool = false + + // MARK: - Logging functions + + public func info(_ message: String, + description: String? = nil) + { + let log = Log( + level: .info, + message: message + ) + + if let description { + toastType = .info + toastDescription = description + } + + messageArray.append(log) + + print("LOG: \(log.toMessage())") + } + + public func warn(_ message: String, + description: String? = nil) + { + let log = Log( + level: .warn, + message: message + ) + + if let description { + toastType = .warn + toastDescription = description + } + + messageArray.append(log) + + print("LOG: \(log.toMessage())") + } + + public func error(_ message: String, + description: String? = nil, + showToast: Bool = true) + { + let log = Log( + level: .error, + message: message + ) + + // If a task is run in parallel, don't show a toast on error + if showToast && showErrorToasts { + toastDescription = description.map { $0 } ?? "An error was logged" + } + + messageArray.append(log) + + print("LOG: \(log.toMessage())") + } + + // MARK: - Indeterminate functions + + public func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) { + indeterminateToastDescription = description + + if let cancelAction { + indeterminateCancelAction = cancelAction + } + + if !showIndeterminateToast { + showIndeterminateToast = true + } + } + + public func hideIndeterminateToast() { + showIndeterminateToast = false + indeterminateToastDescription = "" + indeterminateCancelAction = nil + } +} diff --git a/Ferrite/ViewModels/NavigationViewModel.swift b/Ferrite/ViewModels/NavigationViewModel.swift index 45e4c57..0e07b99 100644 --- a/Ferrite/ViewModels/NavigationViewModel.swift +++ b/Ferrite/ViewModels/NavigationViewModel.swift @@ -9,7 +9,7 @@ import SwiftUI @MainActor public class NavigationViewModel: ObservableObject { - var toastModel: ToastViewModel? + var logManager: LoggingManager? // Used between SearchResultsView and MagnetChoiceView public enum ChoiceSheetType: Identifiable { diff --git a/Ferrite/ViewModels/PluginManager.swift b/Ferrite/ViewModels/PluginManager.swift index b94ea16..1d5e133 100644 --- a/Ferrite/ViewModels/PluginManager.swift +++ b/Ferrite/ViewModels/PluginManager.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI public class PluginManager: ObservableObject { - var toastModel: ToastViewModel? + var logManager: LoggingManager? let kodi: Kodi = .init() @Published var availableSources: [SourceJson] = [] @@ -22,79 +22,134 @@ public class PluginManager: ObservableObject { @Published var actionSuccessAlertMessage: String = "" @MainActor + func cleanAvailablePlugins() { + availableSources = [] + availableActions = [] + } + + @MainActor + func updateAvailablePlugins(_ newPlugins: AvailablePlugins) { + availableSources += newPlugins.availableSources + availableActions += newPlugins.availableActions + } + public func fetchPluginsFromUrl() async { let pluginListRequest = PluginList.fetchRequest() - do { - let pluginLists = try PersistenceController.shared.backgroundContext.fetch(pluginListRequest) + guard let pluginLists = try? PersistenceController.shared.backgroundContext.fetch(pluginListRequest) else { + await logManager?.error("PluginManager: No plugin lists found") + return + } - // Clean availablePlugin arrays for repopulation - availableSources = [] - availableActions = [] + // Clean availablePlugin arrays for repopulation + await cleanAvailablePlugins() + await logManager?.info("Starting fetch of plugin lists") + + await withTaskGroup(of: (AvailablePlugins?, String).self) { group in for pluginList in pluginLists { guard let url = URL(string: pluginList.urlString) else { return } - // Always get the up-to-date source list - let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) + group.addTask { + var availablePlugins: AvailablePlugins? - let (data, _) = try await URLSession.shared.data(for: request) - let pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data) + do { + availablePlugins = try await self.fetchPluginList(pluginList: pluginList, url: url) + } catch { + let error = error as NSError - if let sources = pluginResponse.sources { - // Faster and more performant to map instead of a for loop - availableSources += sources.compactMap { inputJson in - if checkAppVersion(minVersion: inputJson.minVersion) { - return SourceJson( - name: inputJson.name, - version: inputJson.version, - minVersion: inputJson.minVersion, - baseUrl: inputJson.baseUrl, - fallbackUrls: inputJson.fallbackUrls, - dynamicBaseUrl: inputJson.dynamicBaseUrl, - trackers: inputJson.trackers, - api: inputJson.api, - jsonParser: inputJson.jsonParser, - rssParser: inputJson.rssParser, - htmlParser: inputJson.htmlParser, - author: pluginList.author, - listId: pluginList.id, - tags: inputJson.tags - ) - } else { - return nil + switch error.code { + case -999: + await self.logManager?.info("PluginManager: \(pluginList.name): List fetch cancelled") + case -1009: + await self.logManager?.info("PluginManager: \(pluginList.name): The connection is offline") + default: + await self.logManager?.error("Plugin fetch: \(pluginList.name): \(error)", showToast: false) } } - } - if let actions = pluginResponse.actions { - availableActions += actions.compactMap { inputJson in - if checkAppVersion(minVersion: inputJson.minVersion) { - return ActionJson( - name: inputJson.name, - version: inputJson.version, - minVersion: inputJson.minVersion, - requires: inputJson.requires, - deeplink: inputJson.deeplink, - author: pluginList.author, - listId: pluginList.id, - tags: inputJson.tags - ) - } else { - return nil - } - } + return (availablePlugins, pluginList.name) } } - } catch { - let error = error as NSError - if error.code != -999 { - toastModel?.updateToastDescription("Plugin fetch error: \(error)") + + var failedLists: [String] = [] + for await (availablePlugins, pluginListName) in group { + if let availablePlugins { + await updateAvailablePlugins(availablePlugins) + } else { + failedLists.append(pluginListName) + } } - print("Plugin fetch error: \(error)") + if !failedLists.isEmpty { + let joinedLists = failedLists.joined(separator: ", ") + await logManager?.info( + "Plugins: Errors in plugin lists \(joinedLists). See above.", + description: "There were errors in plugin lists \(joinedLists). Check the logs for more details." + ) + } } + + await logManager?.info("Plugin list fetch finished") + } + + func fetchPluginList(pluginList: PluginList, url: URL) async throws -> AvailablePlugins? { + var tempSources: [SourceJson] = [] + var tempActions: [ActionJson] = [] + + // Always get the up-to-date source list + let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) + + let (data, _) = try await URLSession.shared.data(for: request) + let pluginResponse = try JSONDecoder().decode(PluginListJson.self, from: data) + + if let sources = pluginResponse.sources { + // Faster and more performant to map instead of a for loop + tempSources += sources.compactMap { inputJson in + if checkAppVersion(minVersion: inputJson.minVersion) { + return SourceJson( + name: inputJson.name, + version: inputJson.version, + minVersion: inputJson.minVersion, + baseUrl: inputJson.baseUrl, + fallbackUrls: inputJson.fallbackUrls, + dynamicBaseUrl: inputJson.dynamicBaseUrl, + trackers: inputJson.trackers, + api: inputJson.api, + jsonParser: inputJson.jsonParser, + rssParser: inputJson.rssParser, + htmlParser: inputJson.htmlParser, + author: pluginList.author, + listId: pluginList.id, + tags: inputJson.tags + ) + } else { + return nil + } + } + } + + if let actions = pluginResponse.actions { + tempActions += actions.compactMap { inputJson in + if checkAppVersion(minVersion: inputJson.minVersion) { + return ActionJson( + name: inputJson.name, + version: inputJson.version, + minVersion: inputJson.minVersion, + requires: inputJson.requires, + deeplink: inputJson.deeplink, + author: pluginList.author, + listId: pluginList.id, + tags: inputJson.tags + ) + } else { + return nil + } + } + } + + return AvailablePlugins(availableSources: tempSources, availableActions: tempActions) } // forType required to guide generic inferences @@ -285,23 +340,19 @@ public class PluginManager: ObservableObject { public func installAction(actionJson: ActionJson?, doUpsert: Bool = false) async { guard let actionJson else { - await toastModel?.updateToastDescription("Action addition error: No action present. Contact the app dev!") + await logManager?.error("Action addition: No action present. Contact the app dev!") return } let backgroundContext = PersistenceController.shared.backgroundContext if actionJson.requires.count < 1 { - await toastModel?.updateToastDescription("Action addition error: actions must require an input. Please contact the action dev!") - print("Action name \(actionJson.name) does not have a requires parameter") - + await logManager?.error("Action addition: actions must require an input. Please contact the action dev!") return } guard let deeplink = actionJson.deeplink else { - await toastModel?.updateToastDescription("Action addition error: only deeplink actions can be added to Ferrite iOS. Please contact the action dev!") - print("Action name \(actionJson.name) did not have a deeplink") - + await logManager?.error("Action addition: only deeplink actions can be added to Ferrite iOS. Please contact the action dev!") return } @@ -313,8 +364,7 @@ public class PluginManager: ObservableObject { if doUpsert { PersistenceController.shared.delete(existingAction, context: backgroundContext) } else { - await toastModel?.updateToastDescription("Could not install action with name \(actionJson.name) because it is already installed") - print("Action name \(actionJson.name) already exists in user's DB") + await logManager?.error("Action addition: Could not install action with name \(actionJson.name) because it is already installed") return } @@ -344,14 +394,13 @@ public class PluginManager: ObservableObject { do { try backgroundContext.save() } catch { - await toastModel?.updateToastDescription("Action addition error: \(error)") - print("Action addition error: \(error)") + await logManager?.error("Action addition: \(error)") } } public func installSource(sourceJson: SourceJson?, doUpsert: Bool = false) async { guard let sourceJson else { - await toastModel?.updateToastDescription("Source addition error: No source present. Contact the app dev!") + await logManager?.error("Source addition: No source present. Contact the app dev!") return } @@ -360,9 +409,7 @@ public class PluginManager: ObservableObject { // If there's no base URL and it isn't dynamic, return before any transactions occur let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false if !dynamicBaseUrl, sourceJson.baseUrl == nil { - await toastModel?.updateToastDescription("Not adding this source because base URL parameters are malformed. Please contact the source dev.") - print("Not adding source \(sourceJson.name) because base URL parameters are malformed") - + await logManager?.error("Not adding this source because base URL parameters are malformed. Please contact the source dev.") return } @@ -375,9 +422,7 @@ public class PluginManager: ObservableObject { if doUpsert { PersistenceController.shared.delete(existingSource, context: backgroundContext) } else { - await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.") - print("Source name \(sourceJson.name) already exists") - + await logManager?.error("Source addition: Could not install source with name \(sourceJson.name) because it is already installed.") return } } @@ -435,8 +480,7 @@ public class PluginManager: ObservableObject { do { try backgroundContext.save() } catch { - await toastModel?.updateToastDescription("Source addition error: \(error)") - print("Source addition error: \(error)") + await logManager?.error("Source addition error: \(error)") } } diff --git a/Ferrite/ViewModels/ScrapingViewModel.swift b/Ferrite/ViewModels/ScrapingViewModel.swift index 097a13d..8a2bfad 100644 --- a/Ferrite/ViewModels/ScrapingViewModel.swift +++ b/Ferrite/ViewModels/ScrapingViewModel.swift @@ -13,18 +13,21 @@ import SwiftyJSON class ScrapingViewModel: ObservableObject { // Link the toast view model for single-directional communication - var toastModel: ToastViewModel? + var logManager: LoggingManager? let byteCountFormatter: ByteCountFormatter = .init() var runningSearchTask: Task? + func cancelCurrentTask() { + runningSearchTask?.cancel() + runningSearchTask = nil + } + @Published var searchResults: [SearchResult] = [] - @Published var filteredSource: Source? - @Published var currentSourceName: String? // Only add results with valid magnet hashes to the search results array @MainActor func updateSearchResults(newResults: [SearchResult]) { - searchResults += newResults.filter { $0.magnet.hash != nil } + searchResults += newResults } @MainActor @@ -32,170 +35,238 @@ class ScrapingViewModel: ObservableObject { searchResults = [] } - func cancelCurrentTask() { - runningSearchTask?.cancel() - runningSearchTask = nil + @Published var currentSourceNames: Set = [] + @MainActor + func updateCurrentSourceNames(_ newName: String) { + currentSourceNames.insert(newName) + logManager?.updateIndeterminateToast( + "Loading \(currentSourceNames.joined(separator: ", "))", + cancelAction: nil + ) } + @MainActor + func removeCurrentSourceName(_ removedName: String) { + currentSourceNames.remove(removedName) + logManager?.updateIndeterminateToast( + "Loading \(currentSourceNames.joined(separator: ", "))", + cancelAction: nil + ) + } + + @MainActor + func clearCurrentSourceNames() { + currentSourceNames = [] + logManager?.updateIndeterminateToast("Loading sources", cancelAction: nil) + } + + @Published var filteredSource: Source? + // Utility function to print source specific errors - func sendSourceError(_ description: String, newToastType: ToastViewModel.ToastType? = nil) async { - let newDescription = "\(currentSourceName ?? "No source given"): \(description)" - await toastModel?.updateToastDescription( - newDescription, - newToastType: newToastType - ) - - print(newDescription) + func sendSourceError(_ description: String) async { + await logManager?.error(description, showToast: false) } - public func scanSources(sources: [Source], searchText: String) async { - if sources.isEmpty { - await toastModel?.updateToastDescription("There are no sources to search!", newToastType: .info) + public func scanSources(sources: [Source], searchText: String, debridManager: DebridManager) async { + await logManager?.info("Started scanning sources for query \"\(searchText)\"") + + if sources.isEmpty { + await logManager?.info( + "ScrapingModel: No sources found", + description: "There are no sources to search!" + ) - print("There are no sources to search!") return } + if await !debridManager.enabledDebrids.isEmpty { + await debridManager.clearIAValues() + } + await clearSearchResults() - await toastModel?.updateIndeterminateToast("Loading", cancelAction: { + await logManager?.updateIndeterminateToast("Loading sources", cancelAction: { self.cancelCurrentTask() }) - for source in sources { - // If the search is cancelled, return - if let runningSearchTask, runningSearchTask.isCancelled { - return + // Run all tasks in parallel for speed + await withTaskGroup(of: (SearchRequestResult?, String).self) { group in + // TODO: Maybe chunk sources to groups of 5 to not overwhelm the app + for source in sources { + // If the search is cancelled, return + if let runningSearchTask, runningSearchTask.isCancelled { + return + } + + if source.enabled { + group.addTask { + await self.updateCurrentSourceNames(source.name) + let requestResult = await self.executeParser(source: source, searchText: searchText) + + return (requestResult, source.name) + } + } } - if source.enabled { - await toastModel?.updateIndeterminateToast("Loading \(source.name)", cancelAction: nil) + // Let the user know that there was an error in the source + var failedSourceNames: [String] = [] + for await (requestResult, sourceName) in group { + if let requestResult { + if await !debridManager.enabledDebrids.isEmpty { + await debridManager.populateDebridIA(requestResult.magnets) + } - guard let baseUrl = source.baseUrl else { - await toastModel?.updateToastDescription("The base URL could not be found for source \(source.name)") - - print("The base URL could not be found for source \(source.name)") - continue + await self.updateSearchResults(newResults: requestResult.results) + } else { + failedSourceNames.append(sourceName) } - // Default to HTML scraping - let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none + await removeCurrentSourceName(sourceName) + } - guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - await sendSourceError("Could not process search query, invalid characters present.") - - continue - } - - switch preferredParser { - case .scraping: - if let htmlParser = source.htmlParser { - let replacedSearchUrl = htmlParser.searchUrl - .replacingOccurrences(of: "{query}", with: encodedQuery) - - let data = await handleUrls( - baseUrl: baseUrl, - replacedSearchUrl: replacedSearchUrl, - fallbackUrls: source.fallbackUrls - ) - - if let data, - let html = String(data: data, encoding: .utf8) - { - let sourceResults = await scrapeHtml(source: source, baseUrl: baseUrl, html: html) - await updateSearchResults(newResults: sourceResults) - } - } - case .rss: - if let rssParser = source.rssParser { - let replacedSearchUrl = rssParser.searchUrl - .replacingOccurrences(of: "{secret}", with: source.api?.clientSecret?.value ?? "") - .replacingOccurrences(of: "{query}", with: encodedQuery) - - // Do not use fallback URLs if the base URL isn't used - let data: Data? - if let rssUrl = rssParser.rssUrl { - data = await fetchWebsiteData(urlString: rssUrl + replacedSearchUrl) - } else { - data = await handleUrls( - baseUrl: baseUrl, - replacedSearchUrl: replacedSearchUrl, - fallbackUrls: source.fallbackUrls - ) - } - - if let data, - let rss = String(data: data, encoding: .utf8) - { - let sourceResults = await scrapeRss(source: source, rss: rss) - await updateSearchResults(newResults: sourceResults) - } - } - case .siteApi: - if let jsonParser = source.jsonParser { - var replacedSearchUrl = jsonParser.searchUrl - .replacingOccurrences(of: "{query}", with: encodedQuery) - - // Handle anything API related including tokens, client IDs, and appending the API URL - // The source API key is for APIs that require extra credentials or use a different URL - if let sourceApi = source.api { - if let clientIdInfo = sourceApi.clientId { - if let newSearchUrl = await handleApiCredential(clientIdInfo, - replacement: "{clientId}", - searchUrl: replacedSearchUrl, - apiUrl: sourceApi.apiUrl, - baseUrl: baseUrl) - { - replacedSearchUrl = newSearchUrl - } - } - - // Works exactly the same as the client ID check - if let clientSecretInfo = sourceApi.clientSecret { - if let newSearchUrl = await handleApiCredential(clientSecretInfo, - replacement: "{secret}", - searchUrl: replacedSearchUrl, - apiUrl: sourceApi.apiUrl, - baseUrl: baseUrl) - { - replacedSearchUrl = newSearchUrl - } - } - } - - let passedUrl = source.api?.apiUrl ?? baseUrl - let data = await handleUrls( - baseUrl: passedUrl, - replacedSearchUrl: replacedSearchUrl, - fallbackUrls: source.fallbackUrls - ) - - if let data { - let sourceResults = await scrapeJson(source: source, jsonData: data) - await updateSearchResults(newResults: sourceResults) - } - } - case .none: - continue - } + if !failedSourceNames.isEmpty { + let joinedSourceNames = failedSourceNames.joined(separator: ", ") + await logManager?.info( + "Scraping: Errors in sources \(joinedSourceNames). See above.", + description: "There were errors in sources \(joinedSourceNames). Check the logs for more details." + ) } } + await clearCurrentSourceNames() + await logManager?.info("Source scan finished") + // If the search is cancelled, return if let searchTask = runningSearchTask, searchTask.isCancelled { return } } + func executeParser(source: Source, searchText: String) async -> SearchRequestResult? { + guard let baseUrl = source.baseUrl else { + await logManager?.error("Scraping: The base URL could not be found for source \(source.name)") + + return nil + } + + // Default to HTML scraping + let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none + + guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + await sendSourceError("\(source.name): Could not process search query, invalid characters present.") + + return nil + } + + switch preferredParser { + case .scraping: + if let htmlParser = source.htmlParser { + let replacedSearchUrl = htmlParser.searchUrl + .replacingOccurrences(of: "{query}", with: encodedQuery) + + let data = await handleUrls( + baseUrl: baseUrl, + replacedSearchUrl: replacedSearchUrl, + fallbackUrls: source.fallbackUrls, + sourceName: source.name + ) + + if let data, + let html = String(data: data, encoding: .utf8) + { + return await scrapeHtml(source: source, baseUrl: baseUrl, html: html) + } + } + case .rss: + if let rssParser = source.rssParser { + let replacedSearchUrl = rssParser.searchUrl + .replacingOccurrences(of: "{secret}", with: source.api?.clientSecret?.value ?? "") + .replacingOccurrences(of: "{query}", with: encodedQuery) + + // Do not use fallback URLs if the base URL isn't used + let data: Data? + if let rssUrl = rssParser.rssUrl { + data = await fetchWebsiteData( + urlString: rssUrl + replacedSearchUrl, + sourceName: source.name + ) + } else { + data = await handleUrls( + baseUrl: baseUrl, + replacedSearchUrl: replacedSearchUrl, + fallbackUrls: source.fallbackUrls, + sourceName: source.name + ) + } + + if let data, + let rss = String(data: data, encoding: .utf8) + { + return await scrapeRss(source: source, rss: rss) + } + } + case .siteApi: + if let jsonParser = source.jsonParser { + var replacedSearchUrl = jsonParser.searchUrl + .replacingOccurrences(of: "{query}", with: encodedQuery) + + // Handle anything API related including tokens, client IDs, and appending the API URL + // The source API key is for APIs that require extra credentials or use a different URL + if let sourceApi = source.api { + if let clientIdInfo = sourceApi.clientId { + if let newSearchUrl = await handleApiCredential(clientIdInfo, + replacement: "{clientId}", + searchUrl: replacedSearchUrl, + apiUrl: sourceApi.apiUrl, + baseUrl: baseUrl, + sourceName: source.name) + { + replacedSearchUrl = newSearchUrl + } + } + + // Works exactly the same as the client ID check + if let clientSecretInfo = sourceApi.clientSecret { + if let newSearchUrl = await handleApiCredential(clientSecretInfo, + replacement: "{secret}", + searchUrl: replacedSearchUrl, + apiUrl: sourceApi.apiUrl, + baseUrl: baseUrl, + sourceName: source.name) + { + replacedSearchUrl = newSearchUrl + } + } + } + + let passedUrl = source.api?.apiUrl ?? baseUrl + let data = await handleUrls( + baseUrl: passedUrl, + replacedSearchUrl: replacedSearchUrl, + fallbackUrls: source.fallbackUrls, + sourceName: source.name + ) + + if let data { + return await scrapeJson(source: source, jsonData: data) + } + } + case .none: + return nil + } + + return nil + } + // Checks the base URL for any website data then iterates through the fallback URLs - func handleUrls(baseUrl: String, replacedSearchUrl: String, fallbackUrls: [String]?) async -> Data? { - if let data = await fetchWebsiteData(urlString: baseUrl + replacedSearchUrl) { + func handleUrls(baseUrl: String, replacedSearchUrl: String, fallbackUrls: [String]?, sourceName: String) async -> Data? { + if let data = await fetchWebsiteData(urlString: baseUrl + replacedSearchUrl, sourceName: sourceName) { return data } if let fallbackUrls { for fallbackUrl in fallbackUrls { - if let data = await fetchWebsiteData(urlString: fallbackUrl + replacedSearchUrl) { + if let data = await fetchWebsiteData(urlString: fallbackUrl + replacedSearchUrl, sourceName: sourceName) { return data } } @@ -208,7 +279,8 @@ class ScrapingViewModel: ObservableObject { replacement: String, searchUrl: String, apiUrl: String?, - baseUrl: String) async -> String? + baseUrl: String, + sourceName: String) async -> String? { // Is the credential expired var isExpired = false @@ -227,7 +299,8 @@ class ScrapingViewModel: ObservableObject { let credentialUrl = credential.urlString, let newValue = await fetchApiCredential( urlString: (apiUrl ?? baseUrl) + credentialUrl, - credential: credential + credential: credential, + sourceName: sourceName ) { let backgroundContext = PersistenceController.shared.backgroundContext @@ -244,12 +317,12 @@ class ScrapingViewModel: ObservableObject { return nil } - public func fetchApiCredential(urlString: String, credential: SourceApiCredential) async -> String? { + public func fetchApiCredential(urlString: String, + credential: SourceApiCredential, + sourceName: String) async -> String? + { guard let url = URL(string: urlString) else { - Task { @MainActor in - toastModel?.updateToastDescription("This token URL is invalid.") - } - print("Token url \(urlString) is invalid!") + await sendSourceError("\(sourceName): Token URL \(urlString) is invalid.") return nil } @@ -276,11 +349,13 @@ class ScrapingViewModel: ObservableObject { switch error.code { case -999: - await toastModel?.updateToastDescription("Search cancelled", newToastType: .info) + await logManager?.info("Scraping: Search cancelled") case -1001: - await sendSourceError("Credentials request timed out") + await sendSourceError("\(sourceName): Credentials request timed out") + case -1009: + await logManager?.info("\(sourceName): The connection is offline") default: - await sendSourceError("Error in fetching an API credential \(error)") + await sendSourceError("\(sourceName): Error in fetching an API credential \(error)") } return nil @@ -288,9 +363,9 @@ class ScrapingViewModel: ObservableObject { } // Fetches the data for a URL - public func fetchWebsiteData(urlString: String) async -> Data? { + public func fetchWebsiteData(urlString: String, sourceName: String) async -> Data? { guard let url = URL(string: urlString) else { - await sendSourceError("Source doesn't contain a valid URL, contact the source dev!") + await sendSourceError("\(sourceName): Source doesn't contain a valid URL, contact the source dev!") return nil } @@ -305,22 +380,22 @@ class ScrapingViewModel: ObservableObject { switch error.code { case -999: - await toastModel?.updateToastDescription("Search cancelled", newToastType: .info) + await logManager?.info("Scraping: Search cancelled") case -1001: - await sendSourceError("Data request timed out. Trying fallback URLs if present.") + await sendSourceError("\(sourceName): Data request timed out. Trying fallback URLs if present.") + case -1009: + await logManager?.info("\(sourceName): The connection is offline") default: - await sendSourceError("Error in fetching website data \(error)") + await sendSourceError("\(sourceName): Error in fetching website data \(error)") } return nil } } - public func scrapeJson(source: Source, jsonData: Data) async -> [SearchResult] { - var tempResults: [SearchResult] = [] - + public func scrapeJson(source: Source, jsonData: Data) async -> SearchRequestResult? { guard let jsonParser = source.jsonParser else { - return tempResults + return nil } var jsonResults: [JSON] = [] @@ -335,19 +410,20 @@ class ScrapingViewModel: ObservableObject { } } catch { if let api = source.api { - await cleanApiCreds(api: api) - - print("JSON parsing error, couldn't fetch results: \(error)") + await sendSourceError("\(source.name): JSON parsing, couldn't fetch results: \(error)") + await cleanApiCreds(api: api, sourceName: source.name) } } // If there are no results and the client secret isn't dynamic, just clear out the token if let api = source.api, jsonResults.isEmpty { - await cleanApiCreds(api: api) - - print("JSON results were empty!") + await sendSourceError("\(source.name): JSON results were empty") + await cleanApiCreds(api: api, sourceName: source.name) } + var tempResults: [SearchResult] = [] + var magnets: [Magnet] = [] + // Iterate through results and grab what we can for result in jsonResults { var subResults: [JSON] = [] @@ -358,6 +434,7 @@ class ScrapingViewModel: ObservableObject { // Otherwise append the applied result if it exists // Better to be redundant with checks rather than another for loop or filter if let subResultsQuery = jsonParser.subResults { + // TODO: Add a for loop with subResultsQueries for further drilling into JSON subResults = result[subResultsQuery.components(separatedBy: ".")].arrayValue for subResult in subResults { @@ -373,6 +450,7 @@ class ScrapingViewModel: ObservableObject { !tempResults.contains(newSearchResult) { tempResults.append(newSearchResult) + magnets.append(newSearchResult.magnet) } } } else if @@ -382,13 +460,18 @@ class ScrapingViewModel: ObservableObject { !tempResults.contains(searchResult) { tempResults.append(searchResult) + magnets.append(searchResult.magnet) } } - return tempResults + return SearchRequestResult(results: tempResults, magnets: magnets) } - public func parseJsonResult(_ result: JSON, jsonParser: SourceJsonParser, source: Source, existingSearchResult: SearchResult? = nil) -> SearchResult? { + public func parseJsonResult(_ result: JSON, + jsonParser: SourceJsonParser, + source: Source, + existingSearchResult: SearchResult? = nil) -> SearchResult? + { var magnetHash: String? = existingSearchResult?.magnet.hash if let magnetHashParser = jsonParser.magnetHash { let rawHash = result[magnetHashParser.query.components(separatedBy: ".")].rawValue @@ -398,6 +481,12 @@ class ScrapingViewModel: ObservableObject { } } + var link: String? = existingSearchResult?.magnet.link + if let magnetLinkParser = jsonParser.magnetLink, link == nil { + let rawLink = result[magnetLinkParser.query.components(separatedBy: ".")].rawValue + link = rawLink is NSNull ? nil : String(describing: rawLink) + } + var title: String? = existingSearchResult?.title if let titleParser = jsonParser.title { if let existingTitle = existingSearchResult?.title, @@ -414,18 +503,18 @@ class ScrapingViewModel: ObservableObject { } } + // Return if no magnet hash exists + let magnet = Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers) + if magnet.hash == nil { + return nil + } + var subName: String? if let subNameParser = jsonParser.subName { let rawSubName = result[subNameParser.query.components(separatedBy: ".")].rawValue subName = rawSubName is NSNull ? nil : String(describing: rawSubName) } - var link: String? = existingSearchResult?.magnet.link - if let magnetLinkParser = jsonParser.magnetLink, link == nil { - let rawLink = result[magnetLinkParser.query.components(separatedBy: ".")].rawValue - link = rawLink is NSNull ? nil : String(describing: rawLink) - } - var size: String? = existingSearchResult?.size if let sizeParser = jsonParser.size, existingSearchResult?.size == nil { let rawSize = result[sizeParser.query.components(separatedBy: ".")].rawValue @@ -455,7 +544,7 @@ class ScrapingViewModel: ObservableObject { title: title, source: subName.map { "\(source.name) - \($0)" } ?? source.name, size: size, - magnet: Magnet(hash: magnetHash, link: link, title: title, trackers: source.trackers), + magnet: magnet, seeders: seeders, leechers: leechers ) @@ -464,11 +553,9 @@ class ScrapingViewModel: ObservableObject { } // RSS feed scraper - public func scrapeRss(source: Source, rss: String) async -> [SearchResult] { - var tempResults: [SearchResult] = [] - + public func scrapeRss(source: Source, rss: String) async -> SearchRequestResult? { guard let rssParser = source.rssParser else { - return tempResults + return nil } var items = Elements() @@ -477,11 +564,14 @@ class ScrapingViewModel: ObservableObject { let document = try SwiftSoup.parse(rss, "", Parser.xmlParser()) items = try document.getElementsByTag(rssParser.items) } catch { - await sendSourceError("RSS scraping error, couldn't fetch items: \(error)") + await sendSourceError("\(source.name): RSS scraping error, couldn't fetch items: \(error)") - return tempResults + return nil } + var tempResults: [SearchResult] = [] + var magnets: [Magnet] = [] + for item in items { // Parse magnet link or translate hash var magnetHash: String? @@ -495,6 +585,28 @@ class ScrapingViewModel: ObservableObject { ) } + var href: String? + if let magnetLinkParser = rssParser.magnetLink { + href = try? runRssComplexQuery( + item: item, + query: magnetLinkParser.query, + attribute: magnetLinkParser.attribute, + discriminator: magnetLinkParser.discriminator, + regexString: magnetLinkParser.regex + ) + } + + var title: String? + if let titleParser = rssParser.title { + title = try? runRssComplexQuery( + item: item, + query: titleParser.query, + attribute: titleParser.attribute, + discriminator: titleParser.discriminator, + regexString: titleParser.regex + ) + } + // Fetches the subName for the source if there is one var subName: String? if let subNameParser = rssParser.subName { @@ -507,26 +619,11 @@ class ScrapingViewModel: ObservableObject { ) } - var title: String? - if let titleParser = rssParser.title { - title = try? runRssComplexQuery( - item: item, - query: titleParser.query, - attribute: titleParser.attribute, - discriminator: titleParser.discriminator, - regexString: titleParser.regex - ) - } - - var href: String? - if let magnetLinkParser = rssParser.magnetLink { - href = try? runRssComplexQuery( - item: item, - query: magnetLinkParser.query, - attribute: magnetLinkParser.attribute, - discriminator: magnetLinkParser.discriminator, - regexString: magnetLinkParser.regex - ) + // Continue if the magnet isn't valid + // TODO: Possibly append magnet to a separate magnet array for debrid IA check + let magnet = Magnet(hash: magnetHash, link: href, title: title, trackers: source.trackers) + if magnet.hash == nil { + continue } var size: String? @@ -572,21 +669,27 @@ class ScrapingViewModel: ObservableObject { title: title ?? "No title", source: subName.map { "\(source.name) - \($0)" } ?? source.name, size: size ?? "", - magnet: Magnet(hash: magnetHash, link: href, title: title, trackers: source.trackers), + magnet: magnet, seeders: seeders, leechers: leechers ) if !tempResults.contains(result) { tempResults.append(result) + magnets.append(result.magnet) } } - return tempResults + return SearchRequestResult(results: tempResults, magnets: magnets) } // Complex query parsing for RSS scraping - func runRssComplexQuery(item: Element, query: String, attribute: String, discriminator: String?, regexString: String?) throws -> String? { + func runRssComplexQuery(item: Element, + query: String, + attribute: String, + discriminator: String?, + regexString: String?) throws -> String? + { var parsedValue: String? switch attribute { @@ -615,11 +718,9 @@ class ScrapingViewModel: ObservableObject { } // HTML scraper - public func scrapeHtml(source: Source, baseUrl: String, html: String) async -> [SearchResult] { - var tempResults: [SearchResult] = [] - + public func scrapeHtml(source: Source, baseUrl: String, html: String) async -> SearchRequestResult? { guard let htmlParser = source.htmlParser else { - return tempResults + return nil } var rows = Elements() @@ -628,11 +729,14 @@ class ScrapingViewModel: ObservableObject { let document = try SwiftSoup.parse(html) rows = try document.select(htmlParser.rows) } catch { - await sendSourceError("Scraping error, couldn't fetch rows: \(error)") + await sendSourceError("\(source.name): couldn't fetch rows: \(error)") - return tempResults + return nil } + var tempResults: [SearchResult] = [] + var magnets: [Magnet] = [] + // If there's an error, continue instead of returning with nothing for row in rows { do { @@ -647,7 +751,7 @@ class ScrapingViewModel: ObservableObject { if let externalMagnetQuery = magnetParser.externalLinkQuery, !externalMagnetQuery.isEmpty { guard let externalMagnetLink = try row.select(externalMagnetQuery).first()?.attr("href"), - let data = await fetchWebsiteData(urlString: baseUrl + externalMagnetLink), + let data = await fetchWebsiteData(urlString: baseUrl + externalMagnetLink, sourceName: source.name), let magnetHtml = String(data: data, encoding: .utf8) else { continue @@ -676,6 +780,12 @@ class ScrapingViewModel: ObservableObject { href = link } + // Continue if the magnet isn't valid + let magnet = Magnet(hash: nil, link: href) + if magnet.hash == nil { + continue + } + // Fetches the episode/movie title var title: String? if let titleParser = htmlParser.title { @@ -752,26 +862,31 @@ class ScrapingViewModel: ObservableObject { title: title ?? "No title", source: subName.map { "\(source.name) - \($0)" } ?? source.name, size: size ?? "", - magnet: Magnet(hash: nil, link: href), + magnet: magnet, seeders: seeders, leechers: leechers ) if !tempResults.contains(result) { tempResults.append(result) + magnets.append(result.magnet) } } catch { - await sendSourceError("Scraping error: \(error)") + await sendSourceError("\(source.name): \(error)") continue } } - return tempResults + return SearchRequestResult(results: tempResults, magnets: magnets) } // Complex query parsing for HTML scraping - func runHtmlComplexQuery(row: Element, query: String, attribute: String, regexString: String?) throws -> String? { + func runHtmlComplexQuery(row: Element, + query: String, + attribute: String, + regexString: String?) throws -> String? + { var parsedValue: String? let result = try row.select(query).first() @@ -816,7 +931,7 @@ class ScrapingViewModel: ObservableObject { } } - func cleanApiCreds(api: SourceApi) async { + func cleanApiCreds(api: SourceApi, sourceName: String) async { let backgroundContext = PersistenceController.shared.backgroundContext let hasCredentials = api.clientId != nil || api.clientSecret != nil @@ -861,7 +976,7 @@ class ScrapingViewModel: ObservableObject { } } - await sendSourceError(responseArray.joined(separator: " ")) + await sendSourceError("\(sourceName): \(responseArray.joined(separator: " "))") PersistenceController.shared.save(backgroundContext) } diff --git a/Ferrite/ViewModels/ToastViewModel.swift b/Ferrite/ViewModels/ToastViewModel.swift deleted file mode 100644 index 9319ae0..0000000 --- a/Ferrite/ViewModels/ToastViewModel.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// ToastViewModel.swift -// Ferrite -// -// Created by Brian Dashore on 7/19/22. -// - -import SwiftUI - -@MainActor -class ToastViewModel: ObservableObject { - enum ToastType: Identifiable { - var id: Int { - hashValue - } - - case info - case error - } - - // Toast variables - @Published var toastDescription: String? = nil { - didSet { - Task { - try? await Task.sleep(seconds: 0.1) - showToast = true - - try? await Task.sleep(seconds: 5) - - showToast = false - toastType = .error - } - } - } - - @Published var showToast: Bool = false - - @Published var indeterminateToastDescription: String? = nil - @Published var indeterminateCancelAction: (() -> Void)? = nil - @Published var showIndeterminateToast: Bool = false - - public func updateToastDescription(_ description: String, newToastType: ToastType? = nil) { - if let newToastType { - toastType = newToastType - } - - toastDescription = description - } - - public func updateIndeterminateToast(_ description: String, cancelAction: (() -> Void)?) { - indeterminateToastDescription = description - - if let cancelAction { - indeterminateCancelAction = cancelAction - } - - if !showIndeterminateToast { - showIndeterminateToast = true - } - } - - public func hideIndeterminateToast() { - showIndeterminateToast = false - indeterminateToastDescription = "" - indeterminateCancelAction = nil - } - - // Default the toast type to error since the majority of toasts are errors - @Published var toastType: ToastType = .error -} diff --git a/Ferrite/Views/ComponentViews/Library/HistoryActionsView.swift b/Ferrite/Views/ComponentViews/Library/HistoryActionsView.swift index 083b094..a9aa115 100644 --- a/Ferrite/Views/ComponentViews/Library/HistoryActionsView.swift +++ b/Ferrite/Views/ComponentViews/Library/HistoryActionsView.swift @@ -8,7 +8,7 @@ import SwiftUI struct HistoryActionsView: View { - @EnvironmentObject var toastModel: ToastViewModel + @EnvironmentObject var logManager: LoggingManager @State private var showActionSheet = false @@ -42,7 +42,7 @@ struct HistoryActionsView: View { do { try PersistenceController.shared.batchDeleteHistory(range: deleteRange) } catch { - toastModel.updateToastDescription("History delete error: \(error)") + logManager.error("History delete error: \(error)") } } } diff --git a/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift index 7713c68..7cda667 100644 --- a/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift +++ b/Ferrite/Views/ComponentViews/Library/HistoryButtonView.swift @@ -8,7 +8,7 @@ import SwiftUI struct HistoryButtonView: View { - @EnvironmentObject var toastModel: ToastViewModel + @EnvironmentObject var logManager: LoggingManager @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var pluginManager: PluginManager @@ -40,7 +40,10 @@ struct HistoryButtonView: View { ) } } else { - toastModel.updateToastDescription("URL invalid. Cannot load this history entry. Please delete it.") + logManager.error( + "History: URL for name \(String(describing: entry.name)) is invalid", + description: "URL invalid. Cannot load this history entry. Please delete it." + ) } } label: { VStack(alignment: .leading, spacing: 10) { diff --git a/Ferrite/Views/ComponentViews/Settings/DefaultActionPickerView.swift b/Ferrite/Views/ComponentViews/Settings/DefaultActionPickerView.swift index def50f6..c863724 100644 --- a/Ferrite/Views/ComponentViews/Settings/DefaultActionPickerView.swift +++ b/Ferrite/Views/ComponentViews/Settings/DefaultActionPickerView.swift @@ -8,7 +8,7 @@ import SwiftUI struct DefaultActionPickerView: View { - @EnvironmentObject var toastModel: ToastViewModel + @EnvironmentObject var logManager: LoggingManager let actionRequirement: ActionRequirement @Binding var defaultActionName: String? @@ -38,8 +38,8 @@ struct DefaultActionPickerView: View { defaultActionName = action.name defaultActionList = actionListId } else { - toastModel.updateToastDescription( - "Default action error: This action doesn't have a corresponding plugin list! Please uninstall the action" + logManager.error( + "Default action: This action doesn't have a corresponding plugin list! Please uninstall the action" ) } } label: { diff --git a/Ferrite/Views/ComponentViews/Settings/SettingsAppVersionView.swift b/Ferrite/Views/ComponentViews/Settings/SettingsAppVersionView.swift index 69b05f8..1e595f6 100644 --- a/Ferrite/Views/ComponentViews/Settings/SettingsAppVersionView.swift +++ b/Ferrite/Views/ComponentViews/Settings/SettingsAppVersionView.swift @@ -8,7 +8,7 @@ import SwiftUI struct SettingsAppVersionView: View { - @EnvironmentObject var toastModel: ToastViewModel + @EnvironmentObject var logManager: LoggingManager @State private var viewTask: Task? @State private var releases: [Github.Release] = [] @@ -36,10 +36,10 @@ struct SettingsAppVersionView: View { if let fetchedReleases = try await Github().fetchReleases() { releases = fetchedReleases } else { - toastModel.updateToastDescription("Github error: No releases found") + logManager.error("Github: No releases found") } } catch { - toastModel.updateToastDescription("Github error: \(error)") + logManager.error("Github: \(error)") } withAnimation { diff --git a/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift b/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift index dc53506..05c9feb 100644 --- a/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift +++ b/Ferrite/Views/ComponentViews/Settings/SettingsDebridInfoView.swift @@ -38,8 +38,8 @@ struct SettingsDebridInfoView: View { } label: { Text( debridManager.enabledDebrids.contains(debridType) - ? "Logout" - : (debridManager.getAuthProcessingBool(debridType: debridType) ? "Processing" : "Login") + ? "Logout" + : (debridManager.getAuthProcessingBool(debridType: debridType) ? "Processing" : "Login") ) .foregroundColor(debridManager.enabledDebrids.contains(debridType) ? .red : .blue) } diff --git a/Ferrite/Views/ComponentViews/Settings/SettingsKodiView.swift b/Ferrite/Views/ComponentViews/Settings/SettingsKodiView.swift index 5268fce..7e96f64 100644 --- a/Ferrite/Views/ComponentViews/Settings/SettingsKodiView.swift +++ b/Ferrite/Views/ComponentViews/Settings/SettingsKodiView.swift @@ -30,7 +30,7 @@ struct SettingsKodiView: View { footer: Text("Enter your Kodi server's http URL here including the port.") ) { TextField("http://...", text: $kodiUrl, onEditingChanged: { isFocused in - if !isFocused && kodiUrl.last == "/" { + if !isFocused, kodiUrl.last == "/" { kodiUrl = String(kodiUrl.dropLast()) } }) diff --git a/Ferrite/Views/ComponentViews/Settings/SettingsLogView.swift b/Ferrite/Views/ComponentViews/Settings/SettingsLogView.swift new file mode 100644 index 0000000..b18d24d --- /dev/null +++ b/Ferrite/Views/ComponentViews/Settings/SettingsLogView.swift @@ -0,0 +1,33 @@ +// +// SettingsLogView.swift +// Ferrite +// +// Created by Brian Dashore on 3/8/23. +// + +import SwiftUI + +struct SettingsLogView: View { + @EnvironmentObject var logManager: LoggingManager + + var body: some View { + NavView { + List { + ForEach(logManager.messageArray, id: \.self) { log in + Text(log.toMessage()) + .font(.caption) + .foregroundColor(.secondary) + } + } + .listStyle(.plain) + .navigationTitle("Logs") + .navigationBarTitleDisplayMode(.inline) + } + } +} + +struct SettingsLogView_Previews: PreviewProvider { + static var previews: some View { + SettingsLogView() + } +} diff --git a/Ferrite/Views/ContentView.swift b/Ferrite/Views/ContentView.swift index db60f78..e8d57a1 100644 --- a/Ferrite/Views/ContentView.swift +++ b/Ferrite/Views/ContentView.swift @@ -13,7 +13,7 @@ struct ContentView: View { @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var navModel: NavigationViewModel @EnvironmentObject var pluginManager: PluginManager - @EnvironmentObject var toastModel: ToastViewModel + @EnvironmentObject var logManager: LoggingManager @AppStorage("Behavior.UsesRandomSearchText") var usesRandomSearchText: Bool = false @@ -44,7 +44,11 @@ struct ContentView: View { .listStyle(.insetGrouped) .inlinedList(inset: Application.shared.osVersion.majorVersion > 14 ? 20 : -20) .overlay { - if scrapingModel.searchResults.isEmpty, isSearching, scrapingModel.runningSearchTask == nil { + if + scrapingModel.searchResults.isEmpty, + isSearching, + scrapingModel.runningSearchTask == nil + { Text("No results found") } } @@ -85,16 +89,13 @@ struct ContentView: View { isSearching = true let sources = pluginManager.fetchInstalledSources() - await scrapingModel.scanSources(sources: sources, searchText: searchText) + await scrapingModel.scanSources( + sources: sources, + searchText: searchText, + debridManager: debridManager + ) - if debridManager.enabledDebrids.count > 0, !scrapingModel.searchResults.isEmpty { - debridManager.clearIAValues() - - let magnets = scrapingModel.searchResults.map(\.magnet) - await debridManager.populateDebridIA(magnets) - } - - toastModel.hideIndeterminateToast() + logManager.hideIndeterminateToast() scrapingModel.runningSearchTask = nil } } diff --git a/Ferrite/Views/MainView.swift b/Ferrite/Views/MainView.swift index 4de4345..b130b82 100644 --- a/Ferrite/Views/MainView.swift +++ b/Ferrite/Views/MainView.swift @@ -10,7 +10,7 @@ import SwiftUIX struct MainView: View { @EnvironmentObject var navModel: NavigationViewModel - @EnvironmentObject var toastModel: ToastViewModel + @EnvironmentObject var logManager: LoggingManager @EnvironmentObject var debridManager: DebridManager @EnvironmentObject var scrapingModel: ScrapingViewModel @EnvironmentObject var backupManager: BackupManager @@ -77,10 +77,19 @@ struct MainView: View { autoUpdateNotifs, Application.shared.osVersion.majorVersion >= Application.shared.minVersion.majorVersion { + // MARK: If scope bar duplication happens, this may be the problem + logManager.info("Ferrite started") + viewTask = Task { + // Sleep for 2 seconds to allow for view layout and app init + try? await Task.sleep(seconds: 2) + do { guard let latestRelease = try await Github().fetchLatestRelease() else { - toastModel.updateToastDescription("Github error: No releases found") + logManager.error( + "Github: No releases found", + description: "Github error: No releases found" + ) return } @@ -91,8 +100,22 @@ struct MainView: View { showUpdateAlert.toggle() } } catch { - toastModel.updateToastDescription("Github error: \(error)") + let error = error as NSError + + if error.code == -1009 { + logManager.info( + "Github: The connection is offline", + description: "The connection is offline" + ) + } else { + logManager.error( + "Github: \(error)", + description: "A Github error was logged" + ) + } } + + logManager.info("Github release updates checked") } } } @@ -159,13 +182,15 @@ struct MainView: View { .overlay { VStack { Spacer() - if toastModel.showToast { + if logManager.showToast { Group { - switch toastModel.toastType { + switch logManager.toastType { case .info: - Text(toastModel.toastDescription ?? "This shouldn't be showing up... Contact the dev!") + Text(logManager.toastDescription ?? "This shouldn't be showing up... Contact the dev!") + case .warn: + Text("Warn: \(logManager.toastDescription ?? "This shouldn't be showing up... Contact the dev!")") case .error: - Text("Error: \(toastModel.toastDescription ?? "This shouldn't be showing up... Contact the dev!")") + Text("Error: \(logManager.toastDescription ?? "This shouldn't be showing up... Contact the dev!")") } } .padding(12) @@ -176,17 +201,18 @@ struct MainView: View { .cornerRadius(10) } - if toastModel.showIndeterminateToast { + if logManager.showIndeterminateToast { VStack { - Text(toastModel.indeterminateToastDescription ?? "Loading...") + Text(logManager.indeterminateToastDescription ?? "Loading...") + .lineLimit(1) HStack { IndeterminateProgressView() - if let cancelAction = toastModel.indeterminateCancelAction { + if let cancelAction = logManager.indeterminateCancelAction { Button("Cancel") { cancelAction() - toastModel.hideIndeterminateToast() + logManager.hideIndeterminateToast() } } } @@ -204,7 +230,7 @@ struct MainView: View { .foregroundColor(.clear) .frame(height: 60) } - .animation(.easeInOut(duration: 0.3), value: toastModel.showToast || toastModel.showIndeterminateToast) + .animation(.easeInOut(duration: 0.3), value: logManager.showToast || logManager.showIndeterminateToast) } } } diff --git a/Ferrite/Views/SettingsView.swift b/Ferrite/Views/SettingsView.swift index 7fc0dae..991aa82 100644 --- a/Ferrite/Views/SettingsView.swift +++ b/Ferrite/Views/SettingsView.swift @@ -28,6 +28,8 @@ struct SettingsView: View { @AppStorage("Actions.DefaultMagnetName") var defaultMagnetActionName: String? @AppStorage("Actions.DefaultMagnetList") var defaultMagnetActionList: String? + @AppStorage("Debug.ShowErrorToasts") var showErrorToasts = true + var body: some View { NavView { Form { @@ -131,6 +133,11 @@ struct SettingsView: View { ListRowLinkView(text: "Report issues", link: "https://github.com/bdashore3/Ferrite/issues") NavigationLink("About", destination: AboutView()) } + + Section(header: InlineHeader("Debug")) { + NavigationLink("Logs", destination: SettingsLogView()) + Toggle("Show error alerts", isOn: $showErrorToasts) + } } .sheet(isPresented: $debridManager.showWebView) { LoginWebView(url: debridManager.authUrl ?? URL(string: "https://google.com")!)