diff --git a/Account/Package.swift b/Account/Package.swift index 4e4aa9649..6cb758680 100644 --- a/Account/Package.swift +++ b/Account/Package.swift @@ -12,7 +12,7 @@ let package = Package( targets: ["Account"]), ], dependencies: [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "3.0.0")), .package(url: "https://github.com/Ranchero-Software/RSDatabase.git", .upToNextMajor(from: "2.0.0")), .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")), .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index 82de92c0f..c6c957c66 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -428,23 +428,16 @@ public enum FetchType { await delegate.receiveRemoteNotification(for: self, userInfo: userInfo) } - public func refreshAll(completion: @escaping (Result) -> Void) { - delegate.refreshAll(for: self, completion: completion) + public func refreshAll() async throws { + try await delegate.refreshAll(for: self) } - public func sendArticleStatus(completion: ((Result) -> Void)? = nil) { - delegate.sendArticleStatus(for: self) { result in - switch result { - case .success: - completion?(.success(())) - case .failure(let error): - completion?(.failure(error)) - } - } + public func sendArticleStatus() async throws { + try await delegate.sendArticleStatus(for: self) } - public func syncArticleStatus(completion: ((Result) -> Void)? = nil) { - delegate.syncArticleStatus(for: self, completion: completion) + public func syncArticleStatus() async throws { + try await delegate.syncArticleStatus(for: self) } public func importOPML(_ opmlFile: URL, completion: @escaping (Result) -> Void) { @@ -453,18 +446,23 @@ public enum FetchType { return } - delegate.importOPML(for: self, opmlFile: opmlFile) { [weak self] result in - switch result { - case .success: - guard let self = self else { return } - // Reset the last fetch date to get the article history for the added feeds. - self.metadata.lastArticleFetchStartTime = nil - self.delegate.refreshAll(for: self, completion: completion) - case .failure(let error): - completion(.failure(error)) + delegate.importOPML(for: self, opmlFile: opmlFile) { result in + Task { @MainActor in + switch result { + case .success: + // Reset the last fetch date to get the article history for the added feeds. + self.metadata.lastArticleFetchStartTime = nil + do { + try await self.delegate.refreshAll(for: self) + completion(result) + } catch { + completion(.failure(error)) + } + case .failure: + completion(result) + } } } - } public func suspendNetwork() { diff --git a/Account/Sources/Account/AccountDelegate.swift b/Account/Sources/Account/AccountDelegate.swift index 227c0dd5c..e1097b460 100644 --- a/Account/Sources/Account/AccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegate.swift @@ -25,10 +25,10 @@ import Secrets func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any]) async - func refreshAll(for account: Account, completion: @escaping (Result) -> Void) - func syncArticleStatus(for account: Account, completion: ((Result) -> Void)?) - func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) - func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) + func refreshAll(for account: Account) async throws + func syncArticleStatus(for account: Account) async throws + func sendArticleStatus(for account: Account) async throws + func refreshArticleStatus(for account: Account) async throws func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) diff --git a/Account/Sources/Account/AccountDelegates/FeedbinAccountDelegate.swift b/Account/Sources/Account/AccountDelegates/FeedbinAccountDelegate.swift index 73d33070b..42540234b 100644 --- a/Account/Sources/Account/AccountDelegates/FeedbinAccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegates/FeedbinAccountDelegate.swift @@ -25,7 +25,7 @@ public enum FeedbinAccountDelegateError: String, Error { @MainActor final class FeedbinAccountDelegate: AccountDelegate, Logging { private let database: SyncDatabase - + private let caller: FeedbinAPICaller let behaviors: AccountBehaviors = [.disallowFeedCopyInRootFolder] @@ -75,58 +75,73 @@ public enum FeedbinAccountDelegateError: String, Error { return } - func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { - + func refreshAll(for account: Account) async throws { + refreshProgress.addToNumberOfTasksAndRemaining(5) - refreshAccount(account) { result in - switch result { - case .success(): + try await withCheckedThrowingContinuation { continuation in + refreshAccount(account) { result in + switch result { + case .success(): - self.refreshArticlesAndStatuses(account) { result in - switch result { - case .success(): - completion(.success(())) - case .failure(let error): - DispatchQueue.main.async { - self.refreshProgress.clear() - let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) - completion(.failure(wrappedError)) + self.refreshArticlesAndStatuses(account) { result in + switch result { + case .success(): + continuation.resume() + case .failure(let error): + Task { @MainActor in + self.refreshProgress.clear() + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) + continuation.resume(throwing: wrappedError) + } } } - } - - case .failure(let error): - DispatchQueue.main.async { - self.refreshProgress.clear() - let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) - completion(.failure(wrappedError)) - } - } - - } - - } - func syncArticleStatus(for account: Account, completion: ((Result) -> Void)? = nil) { - sendArticleStatus(for: account) { result in - switch result { - case .success: - self.refreshArticleStatus(for: account) { result in - switch result { - case .success: - completion?(.success(())) - case .failure(let error): - completion?(.failure(error)) + case .failure(let error): + Task { @MainActor in + self.refreshProgress.clear() + let wrappedError = WrappedAccountError(accountID: account.accountID, accountNameForDisplay: account.nameForDisplay, underlyingError: error) + continuation.resume(throwing: wrappedError) } } - case .failure(let error): - completion?(.failure(error)) + } + } + } + + func syncArticleStatus(for account: Account) async throws { + try await withCheckedThrowingContinuation { continuation in + sendArticleStatus(for: account) { result in + switch result { + case .success: + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + case .failure(let error): + continuation.resume(throwing: error) + } } } } - func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { + func sendArticleStatus(for account: Account) async throws { + try await withCheckedThrowingContinuation { continuation in + self.sendArticleStatus(for: account) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { Task { @MainActor in logger.debug("Sending article statuses") @@ -188,7 +203,21 @@ public enum FeedbinAccountDelegateError: String, Error { } } - func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { + func refreshArticleStatus(for account: Account) async throws { + + try await withCheckedThrowingContinuation { continuation in + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { logger.debug("Refreshing article statuses...") diff --git a/Account/Sources/Account/AccountDelegates/LocalAccountDelegate.swift b/Account/Sources/Account/AccountDelegates/LocalAccountDelegate.swift index a6759bd15..0e1553c86 100644 --- a/Account/Sources/Account/AccountDelegates/LocalAccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegates/LocalAccountDelegate.swift @@ -24,7 +24,7 @@ public enum LocalAccountDelegateError: String, Error { final class LocalAccountDelegate: AccountDelegate, Logging { weak var account: Account? - + private lazy var refresher: LocalAccountRefresher? = { let refresher = LocalAccountRefresher() refresher.delegate = self @@ -44,43 +44,46 @@ final class LocalAccountDelegate: AccountDelegate, Logging { return } - func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + func refreshAll(for account: Account) async throws { guard refreshProgress.isComplete else { - completion(.success(())) return } - let feeds = account.flattenedFeeds() - let feedURLs = Set(feeds.map{ $0.url }) - refreshProgress.addToNumberOfTasksAndRemaining(feedURLs.count) + try await withCheckedThrowingContinuation { continuation in + Task { @MainActor in + let feeds = account.flattenedFeeds() + let feedURLs = Set(feeds.map{ $0.url }) + refreshProgress.addToNumberOfTasksAndRemaining(feedURLs.count) - let group = DispatchGroup() + let group = DispatchGroup() - group.enter() - refresher?.refreshFeedURLs(feedURLs) { - group.leave() - } + group.enter() + refresher?.refreshFeedURLs(feedURLs) { + group.leave() + } - group.notify(queue: DispatchQueue.main) { - self.refreshProgress.clear() - account.metadata.lastArticleFetchEndTime = Date() - completion(.success(())) - } + group.notify(queue: DispatchQueue.main) { + self.refreshProgress.clear() + account.metadata.lastArticleFetchEndTime = Date() + continuation.resume() + } + } + } } - func syncArticleStatus(for account: Account, completion: ((Result) -> Void)? = nil) { - completion?(.success(())) + func syncArticleStatus(for account: Account) async throws { + return } - func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { - completion(.success(())) + func sendArticleStatus(for account: Account) async throws { + return } - func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { - completion(.success(())) + func refreshArticleStatus(for account: Account) async throws { + return } - + func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { var fileData: Data? diff --git a/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate+Internal.swift b/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate+Internal.swift index b93d06dfb..610dca162 100644 --- a/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate+Internal.swift +++ b/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate+Internal.swift @@ -469,9 +469,9 @@ extension NewsBlurAccountDelegate { // Download the initial articles downloadFeed(account: account, feed: feed, page: 1) { result in - self.refreshArticleStatus(for: account) { result in - switch result { - case .success: + Task { @MainActor in + do { + try await self.refreshArticleStatus(for: account) self.refreshMissingStories(for: account) { result in switch result { case .success: @@ -485,8 +485,7 @@ extension NewsBlurAccountDelegate { completion(.failure(error)) } } - - case .failure(let error): + } catch { completion(.failure(error)) } } diff --git a/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate.swift b/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate.swift index 2b7829ed6..bcada0350 100644 --- a/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegates/NewsBlurAccountDelegate.swift @@ -66,7 +66,7 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging { return } - func refreshAll(for account: Account, completion: @escaping (Result) -> ()) { + private func refreshAll(for account: Account, completion: @escaping (Result) -> ()) { self.refreshProgress.addToNumberOfTasksAndRemaining(4) refreshFeeds(for: account) { result in @@ -118,25 +118,54 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging { } } - func syncArticleStatus(for account: Account, completion: ((Result) -> Void)? = nil) { - sendArticleStatus(for: account) { result in - switch result { - case .success: - self.refreshArticleStatus(for: account) { result in - switch result { - case .success: - completion?(.success(())) - case .failure(let error): - completion?(.failure(error)) - } + func refreshAll(for account: Account) async throws { + try await withCheckedThrowingContinuation { continuation in + self.refreshAll(for: account) { result in + switch result { + case .success(): + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + func syncArticleStatus(for account: Account) async throws { + try await withCheckedThrowingContinuation { continuation in + sendArticleStatus(for: account) { result in + switch result { + case .success: + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + case .failure(let error): + continuation.resume(throwing: error) } - case .failure(let error): - completion?(.failure(error)) } } } - func sendArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { + func sendArticleStatus(for account: Account) async throws { + + try await withCheckedThrowingContinuation { continuation in + self.sendArticleStatus(for: account) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func sendArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { Task { @MainActor in logger.debug("Sending story statuses") @@ -208,7 +237,21 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging { } } - func refreshArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { + func refreshArticleStatus(for account: Account) async throws { + + try await withCheckedThrowingContinuation { continuation in + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func refreshArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { logger.debug("Refreshing story statuses...") let group = DispatchGroup() diff --git a/Account/Sources/Account/AccountDelegates/ReaderAPIAccountDelegate.swift b/Account/Sources/Account/AccountDelegates/ReaderAPIAccountDelegate.swift index 06a37d45a..e1993d908 100644 --- a/Account/Sources/Account/AccountDelegates/ReaderAPIAccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegates/ReaderAPIAccountDelegate.swift @@ -39,7 +39,7 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { @MainActor final class ReaderAPIAccountDelegate: AccountDelegate, Logging { private let variant: ReaderAPIVariant - + private let database: SyncDatabase private let caller: ReaderAPICaller @@ -166,35 +166,61 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { } } } - } - } - func syncArticleStatus(for account: Account, completion: ((Result) -> Void)? = nil) { - guard variant != .inoreader else { - completion?(.success(())) - return - } - - sendArticleStatus(for: account) { result in - switch result { - case .success: - self.refreshArticleStatus(for: account) { result in - switch result { - case .success: - completion?(.success(())) - case .failure(let error): - completion?(.failure(error)) - } + func refreshAll(for account: Account) async throws { + try await withCheckedThrowingContinuation { continuation in + self.refreshAll(for: account) { result in + switch result { + case .success(): + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) } - case .failure(let error): - completion?(.failure(error)) } } } - func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { + func syncArticleStatus(for account: Account) async throws { + guard variant != .inoreader else { + return + } + + try await withCheckedThrowingContinuation { continuation in + sendArticleStatus(for: account) { result in + switch result { + case .success: + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + func sendArticleStatus(for account: Account) async throws { + + try await withCheckedThrowingContinuation { continuation in + self.sendArticleStatus(for: account) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { logger.debug("Sending article statuses") @MainActor func processStatuses(_ syncStatuses: [SyncStatus]) { @@ -241,7 +267,21 @@ public enum ReaderAPIAccountDelegateError: LocalizedError { } } - func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { + func refreshArticleStatus(for account: Account) async throws { + + try await withCheckedThrowingContinuation { continuation in + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { logger.debug("Refreshing article statuses...") let group = DispatchGroup() diff --git a/Account/Sources/Account/AccountManager.swift b/Account/Sources/Account/AccountManager.swift index ab9750e30..92512c4d5 100644 --- a/Account/Sources/Account/AccountManager.swift +++ b/Account/Sources/Account/AccountManager.swift @@ -245,37 +245,34 @@ import RSDatabase } } - public func refreshAll(errorHandler: @escaping @MainActor (Error) -> Void, completion: (() -> Void)? = nil) { + public func refreshAll(errorHandler: @escaping @MainActor (Error) -> Void) async { + guard let reachability = try? Reachability(hostname: "apple.com"), reachability.connection != .unavailable else { return } - let group = DispatchGroup() - - for account in activeAccounts { - group.enter() - account.refreshAll() { result in - group.leave() - switch result { - case .success: - break - case .failure(let error): - Task { @MainActor in - errorHandler(error) - } + await withTaskGroup(of: Void.self) { group in + for account in activeAccounts { + group.addTask { + do { + try await account.refreshAll() + } catch { + Task { @MainActor in + errorHandler(error) + } + } } } - } - - group.notify(queue: DispatchQueue.main) { - completion?() + await group.waitForAll() } } public func sendArticleStatusAll(completion: (() -> Void)? = nil) { let group = DispatchGroup() - + for account in activeAccounts { group.enter() - account.sendArticleStatus() { _ in + + Task { @MainActor in + try? await account.sendArticleStatus() group.leave() } } @@ -285,18 +282,17 @@ import RSDatabase } } - public func syncArticleStatusAll(completion: (() -> Void)? = nil) { - let group = DispatchGroup() - - for account in activeAccounts { - group.enter() - account.syncArticleStatus() { _ in - group.leave() - } - } - group.notify(queue: DispatchQueue.global(qos: .background)) { - completion?() + public func syncArticleStatusAll() async { + + await withTaskGroup(of: Void.self) { group in + for account in activeAccounts { + group.addTask { + try? await account.syncArticleStatus() + } + } + + await group.waitForAll() } } diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift index 063778e55..3566058e9 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift @@ -78,56 +78,54 @@ final class CloudKitAccountDelegate: AccountDelegate, Logging { } } - func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + func refreshAll(for account: Account) async throws { guard refreshProgress.isComplete else { - completion(.success(())) return } let reachability = SCNetworkReachabilityCreateWithName(nil, "apple.com") var flags = SCNetworkReachabilityFlags() guard SCNetworkReachabilityGetFlags(reachability!, &flags), flags.contains(.reachable) else { - completion(.success(())) return } - - standardRefreshAll(for: account, completion: completion) + + try await withCheckedThrowingContinuation { continuation in + standardRefreshAll(for: account) { result in + switch result { + case .success(): + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } } - func syncArticleStatus(for account: Account, completion: ((Result) -> Void)? = nil) { - sendArticleStatus(for: account) { result in - switch result { - case .success: - self.refreshArticleStatus(for: account) { result in - switch result { - case .success: - completion?(.success(())) - case .failure(let error): - completion?(.failure(error)) - } + func syncArticleStatus(for account: Account) async throws { + try await sendArticleStatus(for: account) + } + + func sendArticleStatus(for account: Account) async throws { + try await sendArticleStatus(for: account, showProgress: false) + } + + func refreshArticleStatus(for account: Account) async throws { + + try await withCheckedThrowingContinuation { continuation in + + let op = CloudKitReceiveStatusOperation(articlesZone: self.articlesZone) + op.completionBlock = { mainThreadOperaion in + if mainThreadOperaion.isCanceled { + continuation.resume(throwing: CloudKitAccountDelegateError.unknown) + } else { + continuation.resume() } - case .failure(let error): - completion?(.failure(error)) } + + mainThreadOperationQueue.add(op) } } - func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { - sendArticleStatus(for: account, showProgress: false, completion: completion) - } - - func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { - let op = CloudKitReceiveStatusOperation(articlesZone: articlesZone) - op.completionBlock = { mainThreadOperaion in - if mainThreadOperaion.isCanceled { - completion(.failure(CloudKitAccountDelegateError.unknown)) - } else { - completion(.success(())) - } - } - mainThreadOperationQueue.add(op) - } - func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { guard refreshProgress.isComplete else { completion(.success(())) @@ -444,7 +442,7 @@ final class CloudKitAccountDelegate: AccountDelegate, Logging { try? await self.database.insertStatuses(syncStatuses) if let count = try? await self.database.selectPendingCount(), count > 100 { - self.sendArticleStatus(for: account, showProgress: false) { _ in } + try? await self.sendArticleStatus(for: account, showProgress: false) } completion(.success(())) case .failure(let error): @@ -507,52 +505,92 @@ final class CloudKitAccountDelegate: AccountDelegate, Logging { } private extension CloudKitAccountDelegate { - - func initialRefreshAll(for account: Account, completion: @escaping (Result) -> Void) { - - func fail(_ error: Error) { - self.processAccountError(account, error) - self.refreshProgress.clear() - completion(.failure(error)) - } - - refreshProgress.isIndeterminate = true - refreshProgress.addToNumberOfTasksAndRemaining(3) - accountZone.fetchChangesInZone() { result in - self.refreshProgress.completeTask() - let feeds = account.flattenedFeeds() - self.refreshProgress.addToNumberOfTasksAndRemaining(feeds.count) + func fetchChangesInZone() async throws { - switch result { - case .success: - self.refreshArticleStatus(for: account) { result in - self.refreshProgress.completeTask() - self.refreshProgress.isIndeterminate = false - switch result { - case .success: - - self.combinedRefresh(account, feeds) { result in - self.refreshProgress.clear() - switch result { - case .success: - account.metadata.lastArticleFetchEndTime = Date() - case .failure(let error): - fail(error) - } - } - - case .failure(let error): - fail(error) - } + try await withCheckedThrowingContinuation { continuation in + self.accountZone.fetchChangesInZone { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) } - case .failure(let error): - fail(error) } } - } + @MainActor func initialRefreshAll(for account : Account) async throws { + + refreshProgress.isIndeterminate = true + refreshProgress.addToNumberOfTasksAndRemaining(3) + + do { + try await fetchChangesInZone() + refreshProgress.completeTask() + + let feeds = account.flattenedFeeds() + refreshProgress.addToNumberOfTasksAndRemaining(feeds.count) + + try await refreshArticleStatus(for: account) + refreshProgress.completeTask() + refreshProgress.isIndeterminate = false + + await combinedRefresh(account, feeds) + refreshProgress.clear() + account.metadata.lastArticleFetchEndTime = Date() + } catch { + processAccountError(account, error) + refreshProgress.clear() + throw error + } + } + +// func initialRefreshAll(for account: Account, completion: @escaping (Result) -> Void) { +// +// func fail(_ error: Error) { +// self.processAccountError(account, error) +// self.refreshProgress.clear() +// completion(.failure(error)) +// } +// +// refreshProgress.isIndeterminate = true +// refreshProgress.addToNumberOfTasksAndRemaining(3) +// accountZone.fetchChangesInZone() { result in +// self.refreshProgress.completeTask() +// +// let feeds = account.flattenedFeeds() +// self.refreshProgress.addToNumberOfTasksAndRemaining(feeds.count) +// +// switch result { +// case .success: +// self.refreshArticleStatus(for: account) { result in +// self.refreshProgress.completeTask() +// self.refreshProgress.isIndeterminate = false +// switch result { +// case .success: +// +// self.combinedRefresh(account, feeds) { result in +// self.refreshProgress.clear() +// switch result { +// case .success: +// account.metadata.lastArticleFetchEndTime = Date() +// case .failure(let error): +// fail(error) +// } +// } +// +// case .failure(let error): +// fail(error) +// } +// } +// case .failure(let error): +// fail(error) +// } +// } +// +// } + func standardRefreshAll(for account: Account, completion: @escaping (Result) -> Void) { let intialFeedsCount = account.flattenedFeeds().count @@ -573,6 +611,36 @@ private extension CloudKitAccountDelegate { let feeds = account.flattenedFeeds() self.refreshProgress.addToNumberOfTasksAndRemaining(feeds.count - intialFeedsCount) + Task { @MainActor in + do { + try await self.refreshArticleStatus(for: account) + self.refreshProgress.completeTask() + self.refreshProgress.isIndeterminate = false + + self.combinedRefresh(account, feeds) { result in + do { + try await self.sendArticleStatus(for: account, showProgress: true) + if case .failure(let error) = result { + fail(error) + } else { + account.metadata.lastArticleFetchEndTime = Date() + completion(.success(())) + } + } catch { + fail(error) + } + } + + } catch { + self.refreshProgress.completeTask() + self.refreshProgress.isIndeterminate = false + } + + + + + } + self.refreshArticleStatus(for: account) { result in switch result { case .success: @@ -601,20 +669,31 @@ private extension CloudKitAccountDelegate { } - func combinedRefresh(_ account: Account, _ feeds: Set, completion: @escaping (Result) -> Void) { + func combinedRefresh(_ account: Account, _ feeds: Set) async { let feedURLs = Set(feeds.map{ $0.url }) - let group = DispatchGroup() + + try await withCheckedContinuation { continuation in + refresher.refreshFeedURLs(feedURLs) { + continuation.resume() + } + } + } - group.enter() - refresher.refreshFeedURLs(feedURLs) { - group.leave() - } - - group.notify(queue: DispatchQueue.main) { - completion(.success(())) - } - } +// func combinedRefresh(_ account: Account, _ feeds: Set, completion: @escaping (Result) -> Void) { +// +// let feedURLs = Set(feeds.map{ $0.url }) +// let group = DispatchGroup() +// +// group.enter() +// refresher.refreshFeedURLs(feedURLs) { +// group.leave() +// } +// +// group.notify(queue: DispatchQueue.main) { +// completion(.success(())) +// } +// } func createRSSFeed(for account: Account, url: URL, editedName: String?, container: Container, validateFeed: Bool, completion: @escaping (Result) -> Void) { @@ -729,12 +808,13 @@ private extension CloudKitAccountDelegate { case .success(let articles): self.storeArticleChanges(new: articles, updated: Set
(), deleted: Set
()) { self.refreshProgress.completeTask() - self.sendArticleStatus(for: account, showProgress: true) { result in - switch result { - case .success: - self.refreshArticleStatus(for: account) { _ in } - case .failure(let error): - self.logger.error("CloudKit Feed send articles error: \(error.localizedDescription, privacy: .public)") + + Task { @MainActor in + do { + try await self.sendArticleStatus(for: account, showProgress: true) + try await self.refreshArticleStatus(for: account) + } catch { + self.logger.error("CloudKit Feed send articles error: \(error.localizedDescription, privacy: .public)") } } } @@ -795,20 +875,24 @@ private extension CloudKitAccountDelegate { } } - func sendArticleStatus(for account: Account, showProgress: Bool, completion: @escaping ((Result) -> Void)) { - let op = CloudKitSendStatusOperation(account: account, - articlesZone: articlesZone, - refreshProgress: refreshProgress, - showProgress: showProgress, - database: database) - op.completionBlock = { mainThreadOperaion in - if mainThreadOperaion.isCanceled { - completion(.failure(CloudKitAccountDelegateError.unknown)) - } else { - completion(.success(())) + func sendArticleStatus(for account: Account, showProgress: Bool) async throws { + + try await withCheckedThrowingContinuation { continuation in + let op = CloudKitSendStatusOperation(account: account, + articlesZone: self.articlesZone, + refreshProgress: self.refreshProgress, + showProgress: showProgress, + database: self.database) + op.completionBlock = { mainThreadOperation in + if mainThreadOperation.isCanceled { + continuation.resume(throwing: CloudKitAccountDelegateError.unknown) + } else { + continuation.resume() + } } + + mainThreadOperationQueue.add(op) } - mainThreadOperationQueue.add(op) } diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift index 812fb90f8..bbb3e0e15 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift @@ -253,6 +253,29 @@ final class CloudKitAccountZone: CloudKitZone { } } + func findOrCreateAccount() async throws -> String { + + guard let database else { + return + } + + let predicate = NSPredicate(format: "isAccount = \"1\"") + let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate) + + do { + let records = try await database.perform(ckQuery, inZoneWith: zoneID) + if records.count > 0 { + return records[0].externalID + } else { + createContainer(name: "Account", isAccount: true, completion: completion) + } + } catch { + switch CloudKitZoneResult.resolve(error) { + } + } + + } + func findOrCreateAccount(completion: @escaping (Result) -> Void) { let predicate = NSPredicate(format: "isAccount = \"1\"") let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate) @@ -343,6 +366,21 @@ private extension CloudKitAccountZone { return record } + func createContainer(name: String, isAccount: Bool) async throws -> String { + let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) + record[CloudKitContainer.Fields.name] = name + record[CloudKitContainer.Fields.isAccount] = isAccount ? "1" : "0" + + save(record) { result in + switch result { + case .success: + completion(.success(record.externalID)) + case .failure(let error): + completion(.failure(error)) + } + } + } + func createContainer(name: String, isAccount: Bool, completion: @escaping (Result) -> Void) { let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) record[CloudKitContainer.Fields.name] = name diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift index bd676c6d9..6ccd89865 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -31,32 +31,19 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { self.articlesZone = articlesZone } - @MainActor func cloudKitWasChanged(updated: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { - for deletedRecordKey in deleted { - switch deletedRecordKey.recordType { - case CloudKitAccountZone.CloudKitFeed.recordType: - removeFeed(deletedRecordKey.recordID.externalID) - case CloudKitAccountZone.CloudKitContainer.recordType: - removeContainer(deletedRecordKey.recordID.externalID) - default: - assertionFailure("Unknown record type: \(deletedRecordKey.recordType)") + @MainActor func cloudKitWasChanged(updated: [CKRecord], deleted: [CloudKitRecordKey]) async throws { + try await withCheckedThrowingContinuation { continuation in + self.cloudKitWasChanged(updated: updated, deleted: deleted) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } } } - - for changedRecord in updated { - switch changedRecord.recordType { - case CloudKitAccountZone.CloudKitFeed.recordType: - addOrUpdateFeed(changedRecord) - case CloudKitAccountZone.CloudKitContainer.recordType: - addOrUpdateContainer(changedRecord) - default: - assertionFailure("Unknown record type: \(changedRecord.recordType)") - } - } - - completion(.success(())) } - + @MainActor func addOrUpdateFeed(_ record: CKRecord) { guard let account = account, let urlString = record[CloudKitAccountZone.CloudKitFeed.Fields.url] as? String, @@ -140,7 +127,33 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { private extension CloudKitAcountZoneDelegate { - @MainActor func updateFeed(_ feed: Feed, name: String?, editedName: String?, homePageURL: String?, containerExternalIDs: [String]) { + @MainActor func cloudKitWasChanged(updated: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { + for deletedRecordKey in deleted { + switch deletedRecordKey.recordType { + case CloudKitAccountZone.CloudKitFeed.recordType: + removeFeed(deletedRecordKey.recordID.externalID) + case CloudKitAccountZone.CloudKitContainer.recordType: + removeContainer(deletedRecordKey.recordID.externalID) + default: + assertionFailure("Unknown record type: \(deletedRecordKey.recordType)") + } + } + + for changedRecord in updated { + switch changedRecord.recordType { + case CloudKitAccountZone.CloudKitFeed.recordType: + addOrUpdateFeed(changedRecord) + case CloudKitAccountZone.CloudKitContainer.recordType: + addOrUpdateContainer(changedRecord) + default: + assertionFailure("Unknown record type: \(changedRecord.recordType)") + } + } + + completion(.success(())) + } + + @MainActor func updateFeed(_ feed: Feed, name: String?, editedName: String?, homePageURL: String?, containerExternalIDs: [String]) { guard let account = account else { return } feed.name = name diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift index a565ab9c8..590b25d37 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift @@ -84,7 +84,30 @@ final class CloudKitArticlesZone: CloudKitZone { self.save(compressedRecords, completion: completion) } } - + + @MainActor func saveNewArticles(_ articles: Set
) async throws { + guard !articles.isEmpty else { + return + } + + let records: [CKRecord] = { + var recordsAccumulator = [CKRecord]() + + let saveArticles = articles.filter { $0.status.read == false || $0.status.starred == true } + for saveArticle in saveArticles { + recordsAccumulator.append(makeStatusRecord(saveArticle)) + recordsAccumulator.append(makeArticleRecord(saveArticle)) + } + return recordsAccumulator + }() + + compressionQueue.async { + let compressedRecords = self.compressArticleRecords(records) + self.save(compressedRecords, completion: completion) + } + } + + func deleteArticles(_ feedExternalID: String, completion: @escaping ((Result) -> Void)) { let predicate = NSPredicate(format: "webFeedExternalID = %@", feedExternalID) let ckQuery = CKQuery(recordType: CloudKitArticleStatus.recordType, predicate: predicate) diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index a86568a25..4af872619 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -28,6 +28,22 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate, Logging { self.articlesZone = articlesZone } + func cloudKitWasChanged(updated: [CKRecord], deleted: [CloudKitRecordKey]) async throws { + try await withCheckedThrowingContinuation { continuation in + cloudKitWasChanged(updated: updated, deleted: deleted) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } +} + +private extension CloudKitArticlesZoneDelegate { + func cloudKitWasChanged(updated: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { Task { @MainActor in @@ -54,9 +70,6 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate, Logging { } } } -} - -private extension CloudKitArticlesZoneDelegate { func delete(recordKeys: [CloudKitRecordKey], pendingStarredStatusArticleIDs: Set, completion: @escaping (Error?) -> Void) { let receivedRecordIDs = recordKeys.filter({ $0.recordType == CloudKitArticlesZone.CloudKitArticleStatus.recordType }).map({ $0.recordID }) diff --git a/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift b/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift index 91d0f0789..f58963c00 100644 --- a/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift +++ b/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift @@ -32,16 +32,15 @@ class CloudKitReceiveStatusOperation: MainThreadOperation, Logging { logger.debug("Refreshing article statuses...") - articlesZone.fetchChangesInZone() { result in - self.logger.debug("Done refreshing article statuses.") - switch result { - case .success: + Task { @MainActor in + do { + try await articlesZone.fetchChangesInZone() + self.logger.debug("Done refreshing article statuses.") self.operationDelegate?.operationDidComplete(self) - case .failure(let error): - self.logger.error("Receive status error: \(error.localizedDescription, privacy: .public)") + } catch { + self.logger.error("Receive status error: \(error.localizedDescription, privacy: .public)") self.operationDelegate?.cancelOperation(self) } } } - } diff --git a/Account/Sources/Account/CloudKit/CloudKitRemoteNotificationOperation.swift b/Account/Sources/Account/CloudKit/CloudKitRemoteNotificationOperation.swift index 73833af34..62ff6c5f0 100644 --- a/Account/Sources/Account/CloudKit/CloudKitRemoteNotificationOperation.swift +++ b/Account/Sources/Account/CloudKit/CloudKitRemoteNotificationOperation.swift @@ -36,13 +36,11 @@ class CloudKitRemoteNotificationOperation: MainThreadOperation, Logging { logger.debug("Processing remote notification...") - accountZone.receiveRemoteNotification(userInfo: userInfo) { - articlesZone.receiveRemoteNotification(userInfo: self.userInfo) { - self.logger.debug("Done processing remote notification.") - self.operationDelegate?.operationDidComplete(self) - } + Task { @MainActor in + await accountZone.receiveRemoteNotification(userInfo: self.userInfo) + await articlesZone.receiveRemoteNotification(userInfo: self.userInfo) + self.logger.debug("Done processing remote notification.") + self.operationDelegate?.operationDidComplete(self) } - } - } diff --git a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift index 73ed72576..188af7369 100644 --- a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift @@ -107,19 +107,17 @@ final class FeedlyAccountDelegate: AccountDelegate, Logging { return } - func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + func refreshAll(for account: Account) async throws { assert(Thread.isMainThread) guard currentSyncAllOperation == nil else { self.logger.debug("Ignoring refreshAll: Feedly sync already in progress.") - completion(.success(())) return } guard let credentials = credentials else { self.logger.debug("Ignoring refreshAll: Feedly account has no credentials.") - completion(.failure(FeedlyAccountDelegateError.notLoggedIn)) - return + throw FeedlyAccountDelegateError.notLoggedIn } let syncAllOperation = FeedlySyncAllOperation(account: account, feedlyUserID: credentials.username, caller: caller, database: database, lastSuccessfulFetchStartDate: accountMetadata?.lastArticleFetchStartTime, downloadProgress: refreshProgress) @@ -127,51 +125,62 @@ final class FeedlyAccountDelegate: AccountDelegate, Logging { syncAllOperation.downloadProgress = refreshProgress let date = Date() - syncAllOperation.syncCompletionHandler = { [weak self] result in - if case .success = result { - self?.accountMetadata?.lastArticleFetchStartTime = date - self?.accountMetadata?.lastArticleFetchEndTime = Date() - } - - self?.logger.debug("Sync took \(-date.timeIntervalSinceNow, privacy: .public) seconds.") - completion(result) - } - - currentSyncAllOperation = syncAllOperation - - operationQueue.add(syncAllOperation) - } - - func syncArticleStatus(for account: Account, completion: ((Result) -> Void)? = nil) { - sendArticleStatus(for: account) { result in - switch result { - case .success: - self.refreshArticleStatus(for: account) { result in - switch result { - case .success: - completion?(.success(())) - case .failure(let error): - self.logger.error("Failed to refresh article status for account \(String(describing: account.type), privacy: .public): \(error.localizedDescription, privacy: .public)") - completion?(.failure(error)) - } + + try await withCheckedThrowingContinuation { continuation in + syncAllOperation.syncCompletionHandler = { result in + self.logger.debug("Sync took \(-date.timeIntervalSinceNow, privacy: .public) seconds.") + + switch result { + case .success(): + self.accountMetadata?.lastArticleFetchStartTime = date + self.accountMetadata?.lastArticleFetchEndTime = Date() + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) } - case .failure(let error): - self.logger.error("Failed to send article status for account \(String(describing: account.type), privacy: .public): \(error.localizedDescription, privacy: .public)") - completion?(.failure(error)) } + + currentSyncAllOperation = syncAllOperation + + operationQueue.add(syncAllOperation) } } - func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { + func syncArticleStatus(for account: Account) async throws { + + do { + try await sendArticleStatus(for: account) + try await refreshArticleStatus(for: account) + } catch { + self.logger.error("Failed to send article status for account \(String(describing: account.type), privacy: .public): \(error.localizedDescription, privacy: .public)") + throw error + } + } + + func sendArticleStatus(for account: Account) async throws { // Ensure remote articles have the same status as they do locally. - let send = FeedlySendArticleStatusesOperation(database: database, service: caller) - send.completionBlock = { operation in - // TODO: not call with success if operation was canceled? Not sure. - DispatchQueue.main.async { - completion(.success(())) + + try await withCheckedThrowingContinuation { continuation in + let send = FeedlySendArticleStatusesOperation(database: database, service: caller) + send.completionBlock = { operation in + continuation.resume() + } + + operationQueue.add(send) + } + } + + func refreshArticleStatus(for account: Account) async throws { + try await withCheckedThrowingContinuation { continuation in + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } } } - operationQueue.add(send) } /// Attempts to ensure local articles have the same status as they do remotely. @@ -180,7 +189,7 @@ final class FeedlyAccountDelegate: AccountDelegate, Logging { /// /// - Parameter account: The account whose articles have a remote status. /// - Parameter completion: Call on the main queue. - func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { + private func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { guard let credentials = credentials else { return completion(.success(())) } @@ -550,7 +559,7 @@ final class FeedlyAccountDelegate: AccountDelegate, Logging { try await self.database.insertStatuses(syncStatuses) let count = try await self.database.selectPendingCount() if count > 100 { - self.sendArticleStatus(for: account) { _ in } + try? await self.sendArticleStatus(for: account) } completion(.success(())) } catch { diff --git a/Appcasts/netnewswire-beta.xml b/Appcasts/netnewswire-beta.xml index 0b47f217f..18d275e7f 100755 --- a/Appcasts/netnewswire-beta.xml +++ b/Appcasts/netnewswire-beta.xml @@ -6,7 +6,21 @@ Most recent NetNewsWire changes with links to updates. en - + + NetNewsWire 6.1.5b1 + This builds adds a new setting — you can turn on/off JavaScript for the article pane. It’s on by default, which matches previous behavior.

+

Note that some content — videos and embedded social media posts, for instance — will often require JavaScript to be on in order to work properly.

+

However, for those who want or need greater security and privacy, we’ve made this setting available.

+ +

This build also fixes a case where images might not load in the article pane.

+ ]]>
+ Fri, 01 Dec 2023 17:30:00 -0700 + + 11.0.0 +
+ + NetNewsWire 6.1.4b1 This build removes Reddit API integration! Don’t install it if you’re not ready for that to happen!

diff --git a/Articles/Package.swift b/Articles/Package.swift index 2de47b65a..e76db645c 100644 --- a/Articles/Package.swift +++ b/Articles/Package.swift @@ -11,7 +11,7 @@ let package = Package( targets: ["Articles"]), ], dependencies: [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "3.0.0")), ], targets: [ .target( diff --git a/ArticlesDatabase/Package.swift b/ArticlesDatabase/Package.swift index 321bdf053..1eca4bf7f 100644 --- a/ArticlesDatabase/Package.swift +++ b/ArticlesDatabase/Package.swift @@ -4,7 +4,7 @@ import PackageDescription var dependencies: [Package.Dependency] = [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "3.0.0")), .package(url: "https://github.com/Ranchero-Software/RSDatabase.git", .upToNextMajor(from: "2.0.0")), .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")), ] diff --git a/FeedFinder/Package.swift b/FeedFinder/Package.swift index ffc034913..ede1458cd 100644 --- a/FeedFinder/Package.swift +++ b/FeedFinder/Package.swift @@ -13,7 +13,7 @@ let package = Package( targets: ["FeedFinder"]), ], dependencies: [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "3.0.0")), .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")), .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), .package(path: "../AccountError"), diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 4cc1e907c..912b1b723 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -581,7 +581,9 @@ var appDelegate: AppDelegate! } @IBAction func refreshAll(_ sender: Any?) { - AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present) + Task { + await AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present) + } } @IBAction func showAddFeedWindow(_ sender: Any?) { diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift index e5d814b3c..1ed8d2ddd 100644 --- a/Mac/MainWindow/Detail/DetailWebViewController.swift +++ b/Mac/MainWindow/Detail/DetailWebViewController.swift @@ -100,6 +100,18 @@ protocol DetailWebViewControllerDelegate: AnyObject { userContentController.add(self, name: MessageName.windowDidScroll) userContentController.add(self, name: MessageName.mouseDidEnter) userContentController.add(self, name: MessageName.mouseDidExit) + + let baseURL = ArticleRenderer.page.baseURL + let appScriptsWorld = WKContentWorld.world(name: "NetNewsWire") + for fileName in ["main.js", "main_mac.js", "newsfoot.js"] { + userContentController.addUserScript( + .init(source: try! String(contentsOf: baseURL.appending(path: fileName, + directoryHint: .notDirectory)), + injectionTime: .atDocumentStart, + forMainFrameOnly: true, + in: appScriptsWorld)) + } + configuration.userContentController = userContentController webView = DetailWebView(frame: NSRect.zero, configuration: configuration) @@ -323,7 +335,7 @@ private extension DetailWebViewController { ] let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions) - webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL) + webView.loadHTMLString(html, baseURL: URL(string: rendering.baseURL)) } func scrollInfo() async -> ScrollInfo? { diff --git a/Mac/MainWindow/Detail/page.html b/Mac/MainWindow/Detail/page.html index ceaa13f7f..3d71f2c98 100644 --- a/Mac/MainWindow/Detail/page.html +++ b/Mac/MainWindow/Detail/page.html @@ -4,14 +4,6 @@ - - - - diff --git a/Mac/MainWindow/MainWindow.swift b/Mac/MainWindow/MainWindow.swift index 18409f76a..6dd8abf8f 100644 --- a/Mac/MainWindow/MainWindow.swift +++ b/Mac/MainWindow/MainWindow.swift @@ -14,19 +14,31 @@ import Foundation // Since the Toolbar intercepts right clicks we need to stop it from doing that here // so that the ArticleExtractorButton can receive right click events. - if event.isRightClick, - let frameView = contentView?.superview, - let view = frameView.hitTest(frameView.convert(event.locationInWindow, from: nil)), - type(of: view).description() == "NSToolbarView" { + if #available(macOS 14.0, *) { + if event.isRightClick, + let frameView = contentView?.superview, + let view = frameView.hitTest(frameView.convert(event.locationInWindow, from: nil)), + let articleExtractorButton = view as? ArticleExtractorButton { - for subview in view.subviews { - for subsubview in subview.subviews { - let candidateView = subsubview.hitTest(subsubview.convert(event.locationInWindow, from: nil)) - if candidateView is ArticleExtractorButton { - candidateView?.rightMouseDown(with: event) - return + articleExtractorButton.rightMouseDown(with: event) + return + } + } else { + if event.isRightClick, + let frameView = contentView?.superview, + let view = frameView.hitTest(frameView.convert(event.locationInWindow, from: nil)), + type(of: view).description() == "NSToolbarView" { + + for subview in view.subviews { + for subsubview in subview.subviews { + let candidateView = subsubview.hitTest(subsubview.convert(event.locationInWindow, from: nil)) + if candidateView is ArticleExtractorButton { + candidateView?.rightMouseDown(with: event) + return + } } } + } } diff --git a/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift b/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift index 61328690d..d2fdf7c5f 100644 --- a/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift @@ -94,13 +94,10 @@ import Secrets try self.account?.removeCredentials(type: .basic) try self.account?.storeCredentials(validatedCredentials) - self.account?.refreshAll() { result in - switch result { - case .success: - break - case .failure(let error): - NSApplication.shared.presentError(error) - } + do { + try await self.account?.refreshAll() + } catch { + NSApplication.shared.presentError(error) } self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) diff --git a/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift b/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift index fe9602069..ac9b86646 100644 --- a/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift @@ -92,13 +92,10 @@ import Secrets try self.account?.storeCredentials(credentials) try self.account?.storeCredentials(validatedCredentials) - self.account?.refreshAll() { result in - switch result { - case .success: - break - case .failure(let error): - NSApplication.shared.presentError(error) - } + do { + try await self.account?.refreshAll() + } catch { + NSApplication.shared.presentError(error) } self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) diff --git a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift index 05aaa2dff..b2f77f2ee 100644 --- a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift +++ b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift @@ -272,12 +272,11 @@ extension AccountsPreferencesViewController: OAuthAccountAuthorizationOperationD // because the user probably wants to see the result of authorizing NetNewsWire to act on their behalf. NSApp.activate(ignoringOtherApps: true) - account.refreshAll { [weak self] result in - switch result { - case .success: - break - case .failure(let error): - self?.presentError(error) + Task { + do { + try await account.refreshAll() + } catch { + self.presentError(error) } } } diff --git a/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift b/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift index 7f5b52031..4e431d12f 100644 --- a/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift @@ -151,13 +151,10 @@ import ReaderAPI try self.account?.storeCredentials(credentials) try self.account?.storeCredentials(validatedCredentials) - self.account?.refreshAll() { result in - switch result { - case .success: - break - case .failure(let error): - NSApplication.shared.presentError(error) - } + do { + try await self.account?.refreshAll() + } catch { + NSApplication.shared.presentError(error) } self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 67839fd41..46587d44f 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -5296,7 +5296,7 @@ repositoryURL = "https://github.com/Ranchero-Software/RSCore.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.0.1; + minimumVersion = 3.0.0; }; }; 510ECA4024D1DCD0001C31A6 /* XCRemoteSwiftPackageReference "RSTree" */ = { diff --git a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0fa8ca3b9..9238f1837 100644 --- a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Ranchero-Software/RSCore.git", "state" : { - "revision" : "dcaa40ceb2c8acd182fbcd69d1f8e56df97e38b1", - "version" : "2.0.1" + "revision" : "cee6d96e036cc4ad08ad5f79364f87f9c291c43c", + "version" : "3.0.0" } }, { diff --git a/README.md b/README.md index a72210a5b..df749891b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It supports [RSS](https://cyber.harvard.edu/rss/rss.html), [Atom](https://datatr More info: [https://netnewswire.com/](https://netnewswire.com/) -Also see the [Technotes](Technotes/) and the [Roadmap](Technotes/Roadmap.md). +Also see the [Technotes](Technotes/) and the [Roadmap/Milestones](https://github.com/Ranchero-Software/NetNewsWire/milestones). Note: NetNewsWire’s Help menu has a bunch of these links, so you don’t have to remember to come back to this page. diff --git a/Shared/Article Rendering/main.js b/Shared/Article Rendering/main.js index 13f90b638..1cf76b59c 100644 --- a/Shared/Article Rendering/main.js +++ b/Shared/Article Rendering/main.js @@ -168,3 +168,8 @@ function processPage() { removeWpSmiley() postRenderProcessing(); } + +document.addEventListener("DOMContentLoaded", function(event) { + window.scrollTo(0, [[windowScrollY]]); + processPage(); +}) diff --git a/Shared/Article Rendering/stylesheet.css b/Shared/Article Rendering/stylesheet.css index eabf7a21b..0d17ad8bd 100644 --- a/Shared/Article Rendering/stylesheet.css +++ b/Shared/Article Rendering/stylesheet.css @@ -263,6 +263,10 @@ figure { margin-top: 1em; } +figure > * + * { + margin-top: 0.5em; +} + figcaption { font-size: 14px; line-height: 1.3em; diff --git a/Shared/Timer/AccountRefreshTimer.swift b/Shared/Timer/AccountRefreshTimer.swift index 9b5310fc1..4ec6e0da8 100644 --- a/Shared/Timer/AccountRefreshTimer.swift +++ b/Shared/Timer/AccountRefreshTimer.swift @@ -73,6 +73,8 @@ import Account lastTimedRefresh = Date() update() - AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log, completion: nil) + Task { + await AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) + } } } diff --git a/Shared/Timer/ArticleStatusSyncTimer.swift b/Shared/Timer/ArticleStatusSyncTimer.swift index 5e0d8ffae..0bbe178ce 100644 --- a/Shared/Timer/ArticleStatusSyncTimer.swift +++ b/Shared/Timer/ArticleStatusSyncTimer.swift @@ -68,8 +68,8 @@ class ArticleStatusSyncTimer { lastTimedRefresh = Date() update() - AccountManager.shared.syncArticleStatusAll() - + Task { + await AccountManager.shared.syncArticleStatusAll() + } } - } diff --git a/SyncClients/Feedbin/Package.swift b/SyncClients/Feedbin/Package.swift index 78fb37d03..74a2ec88b 100644 --- a/SyncClients/Feedbin/Package.swift +++ b/SyncClients/Feedbin/Package.swift @@ -15,7 +15,7 @@ let package = Package( dependencies: [ .package(path: "../../Secrets"), .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "3.0.0")), .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")) ], targets: [ diff --git a/SyncClients/LocalAccount/Package.swift b/SyncClients/LocalAccount/Package.swift index be98a5944..2b80c9a6a 100644 --- a/SyncClients/LocalAccount/Package.swift +++ b/SyncClients/LocalAccount/Package.swift @@ -13,7 +13,7 @@ let package = Package( targets: ["LocalAccount"]), ], dependencies: [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "3.0.0")), .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")), ], diff --git a/SyncClients/NewsBlur/Package.swift b/SyncClients/NewsBlur/Package.swift index 6b151adbe..c31427dcd 100644 --- a/SyncClients/NewsBlur/Package.swift +++ b/SyncClients/NewsBlur/Package.swift @@ -15,7 +15,7 @@ let package = Package( dependencies: [ .package(path: "../../Secrets"), .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "3.0.0")), .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")) ], targets: [ diff --git a/SyncClients/ReaderAPI/Package.swift b/SyncClients/ReaderAPI/Package.swift index 32102ad88..045a21569 100644 --- a/SyncClients/ReaderAPI/Package.swift +++ b/SyncClients/ReaderAPI/Package.swift @@ -16,7 +16,7 @@ let package = Package( .package(path: "../../Secrets"), .package(path: "../../AccountError"), .package(url: "https://github.com/Ranchero-Software/RSWeb.git", .upToNextMajor(from: "1.0.0")), - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "3.0.0")), .package(url: "https://github.com/Ranchero-Software/RSParser.git", .upToNextMajor(from: "2.0.2")) ], targets: [ diff --git a/SyncDatabase/Package.swift b/SyncDatabase/Package.swift index ea12311b5..49f6e2dcd 100644 --- a/SyncDatabase/Package.swift +++ b/SyncDatabase/Package.swift @@ -2,7 +2,7 @@ import PackageDescription var dependencies: [Package.Dependency] = [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "2.0.1")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "3.0.0")), .package(url: "https://github.com/Ranchero-Software/RSDatabase.git", .upToNextMajor(from: "2.0.0")), ] diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index 530108f6a..c87b5a870 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -529,6 +529,20 @@ private extension WebViewController { } configuration.setURLSchemeHandler(articleIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme) + let userContentController = WKUserContentController() + let baseURL = ArticleRenderer.page.baseURL + let appScriptsWorld = WKContentWorld.world(name: "NetNewsWire") + for fileName in ["main.js", "main_ios.js", "newsfoot.js"] { + userContentController.addUserScript( + .init(source: try! String(contentsOf: baseURL.appending(path: fileName, + directoryHint: .notDirectory)), + injectionTime: .atDocumentStart, + forMainFrameOnly: true, + in: appScriptsWorld)) + } + + configuration.userContentController = userContentController + let webView = WKWebView(frame: self.view.bounds, configuration: configuration) webView.isOpaque = false; webView.backgroundColor = .clear; @@ -591,8 +605,8 @@ private extension WebViewController { ] let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions) - webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL) - + webView.loadHTMLString(html, baseURL: URL(string: rendering.baseURL)) + } func finalScrollPosition(scrollingUp: Bool) -> CGFloat { diff --git a/iOS/Resources/page.html b/iOS/Resources/page.html index 686c4612d..b6513009a 100644 --- a/iOS/Resources/page.html +++ b/iOS/Resources/page.html @@ -5,15 +5,6 @@ - - - -