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")!)