diff --git a/README.md b/README.md index 645edd6..cce6994 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,13 @@ An iOS and macOS modular web scraping app, under the GPLv3.0 License. - [x] iOS/iPadOS 15.0+ support - [x] macOS support 12.0+ +- [x] Sync via iCloud data - [x] JavaScript module support -- [x] Local Library +- [x] Tracking Services (AniList, Trakt) +- [x] Apple KeyChain support for auth Tokens - [x] Streams support (Jellyfin/Plex like servers) - [x] External Media players (VLC, infuse, Outplayer, nPlayer) -- [x] Tracking Services (AniList, Trakt) +- [x] Background playback and Picture-in-Picture (PiP) support ## Frequently Asked Questions diff --git a/Sora/Sora.entitlements b/Sora/Sora.entitlements index 19411b3..d90dbc3 100644 --- a/Sora/Sora.entitlements +++ b/Sora/Sora.entitlements @@ -3,13 +3,17 @@ com.apple.developer.icloud-container-identifiers - + + iCloud.me.cranci.sora.icloud + com.apple.developer.icloud-services CloudDocuments com.apple.developer.ubiquity-container-identifiers - + + iCloud.me.cranci.sora.icloud + com.apple.developer.ubiquity-kvstore-identifier $(TeamIdentifierPrefix)$(CFBundleIdentifier) com.apple.security.app-sandbox diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 9809d0e..241f442 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -20,7 +20,7 @@ struct SoraApp: App { if isAuthenticated { Logger.shared.log("Trakt authentication is valid") } else { - Logger.shared.log("Trakt authentication required", type: "Warning") + Logger.shared.log("Trakt authentication required", type: "Error") } } } @@ -34,8 +34,9 @@ struct SoraApp: App { .accentColor(settings.accentColor) .onAppear { settings.updateAppearance() - if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") { - Task { + iCloudSyncManager.shared.syncModulesFromiCloud() + Task { + if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") { await moduleManager.refreshModules() } } @@ -97,4 +98,4 @@ struct SoraApp: App { Logger.shared.log("Unknown authentication service", type: "Error") } } -} \ No newline at end of file +} diff --git a/Sora/Utils/Extensions/Notification+Name.swift b/Sora/Utils/Extensions/Notification+Name.swift index 2cfb5e5..d4a3fad 100644 --- a/Sora/Utils/Extensions/Notification+Name.swift +++ b/Sora/Utils/Extensions/Notification+Name.swift @@ -11,4 +11,5 @@ extension Notification.Name { static let iCloudSyncDidComplete = Notification.Name("iCloudSyncDidComplete") static let ContinueWatchingDidUpdate = Notification.Name("ContinueWatchingDidUpdate") static let DownloadManagerStatusUpdate = Notification.Name("DownloadManagerStatusUpdate") + static let modulesSyncDidComplete = Notification.Name("modulesSyncDidComplete") } diff --git a/Sora/Utils/Modules/ModuleManager.swift b/Sora/Utils/Modules/ModuleManager.swift index b3526ba..9c1d48d 100644 --- a/Sora/Utils/Modules/ModuleManager.swift +++ b/Sora/Utils/Modules/ModuleManager.swift @@ -15,6 +15,21 @@ class ModuleManager: ObservableObject { init() { loadModules() + NotificationCenter.default.addObserver(self, selector: #selector(handleModulesSyncCompleted), name: .modulesSyncDidComplete, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func handleModulesSyncCompleted() { + DispatchQueue.main.async { + self.loadModules() + Task { + await self.checkJSModuleFiles() + } + Logger.shared.log("Reloaded modules after iCloud sync") + } } private func getDocumentsDirectory() -> URL { @@ -31,22 +46,45 @@ class ModuleManager: ObservableObject { modules = (try? JSONDecoder().decode([ScrapingModule].self, from: data)) ?? [] Task { - for module in modules { - let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath) - if (!fileManager.fileExists(atPath: localUrl.path)) { - do { - let scriptUrl = URL(string: module.metadata.scriptUrl) - guard let scriptUrl = scriptUrl else { continue } - let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl) - guard let jsContent = String(data: scriptData, encoding: .utf8) else { continue } - try jsContent.write(to: localUrl, atomically: true, encoding: .utf8) - Logger.shared.log("Recovered missing JS file for module: \(module.metadata.sourceName)") - } catch { - Logger.shared.log("Failed to recover JS file for module: \(module.metadata.sourceName) - \(error.localizedDescription)") + await checkJSModuleFiles() + } + } + + func checkJSModuleFiles() async { + Logger.shared.log("Checking JS module files...", type: "Info") + var missingCount = 0 + + for module in modules { + let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath) + if !fileManager.fileExists(atPath: localUrl.path) { + missingCount += 1 + do { + guard let scriptUrl = URL(string: module.metadata.scriptUrl) else { + Logger.shared.log("Invalid script URL for module: \(module.metadata.sourceName)", type: "Error") + continue } + + Logger.shared.log("Downloading missing JS file for: \(module.metadata.sourceName)", type: "Info") + + let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl) + guard let jsContent = String(data: scriptData, encoding: .utf8) else { + Logger.shared.log("Invalid script encoding for module: \(module.metadata.sourceName)", type: "Error") + continue + } + + try jsContent.write(to: localUrl, atomically: true, encoding: .utf8) + Logger.shared.log("Successfully downloaded JS file for module: \(module.metadata.sourceName)") + } catch { + Logger.shared.log("Failed to download JS file for module: \(module.metadata.sourceName) - \(error.localizedDescription)", type: "Error") } } } + + if missingCount > 0 { + Logger.shared.log("Downloaded \(missingCount) missing module JS files", type: "Info") + } else { + Logger.shared.log("All module JS files are present", type: "Info") + } } private func saveModules() { diff --git a/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift b/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift index a7cf958..5a7e768 100644 --- a/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift +++ b/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift @@ -153,31 +153,58 @@ class iCloudSyncManager { } func syncModulesFromiCloud() { - DispatchQueue.global(qos: .background).async { - guard let iCloudURL = self.ubiquityContainerURL else { return } - let localModulesURL = self.getLocalModulesFileURL() - let iCloudModulesURL = iCloudURL.appendingPathComponent(self.modulesFileName) - do { - guard FileManager.default.fileExists(atPath: iCloudModulesURL.path) else { return } + guard let iCloudURL = self.ubiquityContainerURL else { + Logger.shared.log("iCloud container not available", type: "Error") + return + } + + let localModulesURL = self.getLocalModulesFileURL() + let iCloudModulesURL = iCloudURL.appendingPathComponent(self.modulesFileName) + + do { + if !FileManager.default.fileExists(atPath: iCloudModulesURL.path) { + Logger.shared.log("No modules file found in iCloud", type: "Info") - let shouldCopy: Bool if FileManager.default.fileExists(atPath: localModulesURL.path) { - let localData = try Data(contentsOf: localModulesURL) - let iCloudData = try Data(contentsOf: iCloudModulesURL) - shouldCopy = localData != iCloudData + Logger.shared.log("Copying local modules file to iCloud", type: "Info") + try FileManager.default.copyItem(at: localModulesURL, to: iCloudModulesURL) } else { - shouldCopy = true - } - - if shouldCopy { - if FileManager.default.fileExists(atPath: localModulesURL.path) { - try FileManager.default.removeItem(at: localModulesURL) + Logger.shared.log("Creating new empty modules file in iCloud", type: "Info") + let emptyModules: [ScrapingModule] = [] + let emptyData = try JSONEncoder().encode(emptyModules) + try emptyData.write(to: iCloudModulesURL) + + try emptyData.write(to: localModulesURL) + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .modulesSyncDidComplete, object: nil) } - try FileManager.default.copyItem(at: iCloudModulesURL, to: localModulesURL) } - } catch { - Logger.shared.log("iCloud modules fetch error: \(error)", type: "Error") + return } + + let shouldCopy: Bool + if FileManager.default.fileExists(atPath: localModulesURL.path) { + let localData = try Data(contentsOf: localModulesURL) + let iCloudData = try Data(contentsOf: iCloudModulesURL) + shouldCopy = localData != iCloudData + } else { + shouldCopy = true + } + + if shouldCopy { + Logger.shared.log("Syncing modules from iCloud", type: "Info") + if FileManager.default.fileExists(atPath: localModulesURL.path) { + try FileManager.default.removeItem(at: localModulesURL) + } + try FileManager.default.copyItem(at: iCloudModulesURL, to: localModulesURL) + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .modulesSyncDidComplete, object: nil) + } + } + } catch { + Logger.shared.log("iCloud modules sync error: \(error)", type: "Error") } }