diff --git a/Account/Package.swift b/Account/Package.swift index e80cec9d3..cafc3933c 100644 --- a/Account/Package.swift +++ b/Account/Package.swift @@ -2,7 +2,7 @@ import PackageDescription var dependencies: [Package.Dependency] = [ - .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/Ranchero-Software/RSCore.git", .upToNextMajor(from: "1.1.0")), .package(url: "https://github.com/Ranchero-Software/RSDatabase.git", .upToNextMajor(from: "1.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 24f2a29f2..2f56cac0c 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -153,7 +153,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, public var sortedFolders: [Folder]? { if let folders = folders { - return Array(folders).sorted(by: { $0.nameForDisplay.caseInsensitiveCompare($1.nameForDisplay) == .orderedAscending }) + return folders.sorted() } return nil } @@ -517,10 +517,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, addOPMLItems(OPMLNormalizer.normalize(items)) } - public func markArticles(_ articles: Set
, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result) -> Void) { - delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag, completion: completion) - } - func existingContainer(withExternalID externalID: String) -> Container? { guard self.externalID != externalID else { return self @@ -639,6 +635,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, delegate.restoreFolder(for: self, folder: folder, completion: completion) } + public func mark(articles: Set
, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result) -> Void) { + delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag, completion: completion) + } + func clearWebFeedMetadata(_ feed: WebFeed) { webFeedMetadata[feed.url] = nil } @@ -832,48 +832,46 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, completion?(nil) } } - + /// Mark articleIDs statuses based on statusKey and flag. /// Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. - /// Returns a set of new article statuses. - func markAndFetchNew(articleIDs: Set, statusKey: ArticleStatus.Key, flag: Bool, completion: ArticleIDsCompletionBlock? = nil) { + func mark(articleIDs: Set, statusKey: ArticleStatus.Key, flag: Bool, completion: DatabaseCompletionBlock? = nil) { guard !articleIDs.isEmpty else { - completion?(.success(Set())) + completion?(nil) return } - database.markAndFetchNew(articleIDs: articleIDs, statusKey: statusKey, flag: flag) { result in - switch result { - case .success(let newArticleStatusIDs): + database.mark(articleIDs: articleIDs, statusKey: statusKey, flag: flag) { databaseError in + if let databaseError = databaseError { + completion?(databaseError) + } else { self.noteStatusesForArticleIDsDidChange(articleIDs: articleIDs, statusKey: statusKey, flag: flag) - completion?(.success(newArticleStatusIDs)) - case .failure(let databaseError): - completion?(.failure(databaseError)) + completion?(nil) } } } /// Mark articleIDs as read. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. /// Returns a set of new article statuses. - func markAsRead(_ articleIDs: Set, completion: ArticleIDsCompletionBlock? = nil) { - markAndFetchNew(articleIDs: articleIDs, statusKey: .read, flag: true, completion: completion) + func markAsRead(_ articleIDs: Set, completion: DatabaseCompletionBlock? = nil) { + mark(articleIDs: articleIDs, statusKey: .read, flag: true, completion: completion) } /// Mark articleIDs as unread. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. /// Returns a set of new article statuses. - func markAsUnread(_ articleIDs: Set, completion: ArticleIDsCompletionBlock? = nil) { - markAndFetchNew(articleIDs: articleIDs, statusKey: .read, flag: false, completion: completion) + func markAsUnread(_ articleIDs: Set, completion: DatabaseCompletionBlock? = nil) { + mark(articleIDs: articleIDs, statusKey: .read, flag: false, completion: completion) } /// Mark articleIDs as starred. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. /// Returns a set of new article statuses. - func markAsStarred(_ articleIDs: Set, completion: ArticleIDsCompletionBlock? = nil) { - markAndFetchNew(articleIDs: articleIDs, statusKey: .starred, flag: true, completion: completion) + func markAsStarred(_ articleIDs: Set, completion: DatabaseCompletionBlock? = nil) { + mark(articleIDs: articleIDs, statusKey: .starred, flag: true, completion: completion) } /// Mark articleIDs as unstarred. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. /// Returns a set of new article statuses. - func markAsUnstarred(_ articleIDs: Set, completion: ArticleIDsCompletionBlock? = nil) { - markAndFetchNew(articleIDs: articleIDs, statusKey: .starred, flag: false, completion: completion) + func markAsUnstarred(_ articleIDs: Set, completion: DatabaseCompletionBlock? = nil) { + mark(articleIDs: articleIDs, statusKey: .starred, flag: false, completion: completion) } // Delete the articles associated with the given set of articleIDs diff --git a/Account/Sources/Account/AccountManager.swift b/Account/Sources/Account/AccountManager.swift index 07bd893d9..836cf0603 100644 --- a/Account/Sources/Account/AccountManager.swift +++ b/Account/Sources/Account/AccountManager.swift @@ -70,6 +70,9 @@ public final class AccountManager: UnreadCountProvider { if lastArticleFetchEndTime == nil || lastArticleFetchEndTime! < accountLastArticleFetchEndTime { lastArticleFetchEndTime = accountLastArticleFetchEndTime } + } else { + lastArticleFetchEndTime = nil + break } } return lastArticleFetchEndTime diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift index bcd4689bf..07079b5fe 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift @@ -426,6 +426,7 @@ final class CloudKitAccountDelegate: AccountDelegate, Logging { func accountDidInitialize(_ account: Account) { self.account = account + refreshProgress.name = account.nameForDisplay accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress, articlesZone: articlesZone) articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone) @@ -484,6 +485,7 @@ private extension CloudKitAccountDelegate { completion(.failure(error)) } + refreshProgress.isIndeterminate = true refreshProgress.addToNumberOfTasksAndRemaining(3) accountZone.fetchChangesInZone() { result in self.refreshProgress.completeTask() @@ -495,6 +497,7 @@ private extension CloudKitAccountDelegate { case .success: self.refreshArticleStatus(for: account) { result in self.refreshProgress.completeTask() + self.refreshProgress.isIndeterminate = false switch result { case .success: @@ -522,6 +525,7 @@ private extension CloudKitAccountDelegate { func standardRefreshAll(for account: Account, completion: @escaping (Result) -> Void) { let intialWebFeedsCount = account.flattenedWebFeeds().count + refreshProgress.isIndeterminate = true refreshProgress.addToNumberOfTasksAndRemaining(3 + intialWebFeedsCount) func fail(_ error: Error) { @@ -542,6 +546,7 @@ private extension CloudKitAccountDelegate { switch result { case .success: self.refreshProgress.completeTask() + self.refreshProgress.isIndeterminate = false self.combinedRefresh(account, webFeeds) { result in self.sendArticleStatus(for: account, showProgress: true) { _ in self.refreshProgress.clear() @@ -799,7 +804,7 @@ private extension CloudKitAccountDelegate { self.sendArticleStatus(for: account, showProgress: true) { result in switch result { case .success: - self.articlesZone.fetchChangesInZone() { _ in } + self.refreshArticleStatus(for: account) { _ in } case .failure(let error): self.logger.error("CloudKit Feed send articles error: \(error.localizedDescription, privacy: .public)") } diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift index e5f971c55..e25f23d8c 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift @@ -23,8 +23,6 @@ final class CloudKitAccountZone: CloudKitZone { var zoneID: CKRecordZone.ID - var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") - weak var container: CKContainer? weak var database: CKDatabase? var delegate: CloudKitZoneDelegate? diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift index bc1224836..91dbc5169 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -31,7 +31,7 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { self.articlesZone = articlesZone } - func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { + func cloudKitWasChanged(updated: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { for deletedRecordKey in deleted { switch deletedRecordKey.recordType { case CloudKitAccountZone.CloudKitWebFeed.recordType: @@ -43,7 +43,7 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } } - for changedRecord in changed { + for changedRecord in updated { switch changedRecord.recordType { case CloudKitAccountZone.CloudKitWebFeed.recordType: addOrUpdateWebFeed(changedRecord) diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift index 0553513e1..91cd788b9 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift @@ -19,8 +19,6 @@ final class CloudKitArticlesZone: CloudKitZone { var zoneID: CKRecordZone.ID - var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") - weak var container: CKContainer? weak var database: CKDatabase? var delegate: CloudKitZoneDelegate? = nil @@ -64,28 +62,6 @@ final class CloudKitArticlesZone: CloudKitZone { migrateChangeToken() } - func refreshArticles(completion: @escaping ((Result) -> Void)) { - fetchChangesInZone() { result in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - if case CloudKitZoneError.userDeletedZone = error { - self.createZoneRecord() { result in - switch result { - case .success: - self.refreshArticles(completion: completion) - case .failure(let error): - completion(.failure(error)) - } - } - } else { - completion(.failure(error)) - } - } - } - } - func saveNewArticles(_ articles: Set
, completion: @escaping ((Result) -> Void)) { guard !articles.isEmpty else { completion(.success(())) diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index 95f607f07..613640046 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -28,7 +28,7 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate, Logging { self.articlesZone = articlesZone } - func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { + func cloudKitWasChanged(updated: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { database.selectPendingReadStatusArticleIDs() { result in switch result { @@ -37,14 +37,16 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate, Logging { self.database.selectPendingStarredStatusArticleIDs() { result in switch result { case .success(let pendingStarredStatusArticleIDs): - - self.delete(recordKeys: deleted, pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs) { - self.update(records: changed, - pendingReadStatusArticleIDs: pendingReadStatusArticleIDs, - pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs, - completion: completion) + self.delete(recordKeys: deleted, pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs) { error in + if let error = error { + completion(.failure(error)) + } else { + self.update(records: updated, + pendingReadStatusArticleIDs: pendingReadStatusArticleIDs, + pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs, + completion: completion) + } } - case .failure(let error): self.logger.error("Error occurred getting pending starred records: \(error.localizedDescription, privacy: .public)") completion(.failure(CloudKitZoneError.unknown)) @@ -63,19 +65,27 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate, Logging { private extension CloudKitArticlesZoneDelegate { - func delete(recordKeys: [CloudKitRecordKey], pendingStarredStatusArticleIDs: Set, completion: @escaping () -> Void) { + func delete(recordKeys: [CloudKitRecordKey], pendingStarredStatusArticleIDs: Set, completion: @escaping (Error?) -> Void) { let receivedRecordIDs = recordKeys.filter({ $0.recordType == CloudKitArticlesZone.CloudKitArticleStatus.recordType }).map({ $0.recordID }) let receivedArticleIDs = Set(receivedRecordIDs.map({ stripPrefix($0.externalID) })) let deletableArticleIDs = receivedArticleIDs.subtracting(pendingStarredStatusArticleIDs) guard !deletableArticleIDs.isEmpty else { - completion() + completion(nil) return } - database.deleteSelectedForProcessing(Array(deletableArticleIDs)) { _ in - self.account?.delete(articleIDs: deletableArticleIDs) { _ in - completion() + database.deleteSelectedForProcessing(Array(deletableArticleIDs)) { databaseError in + if let databaseError = databaseError { + completion(databaseError) + } else { + self.account?.delete(articleIDs: deletableArticleIDs) { databaseError in + if let databaseError = databaseError { + completion(databaseError) + } else { + completion(nil) + } + } } } } @@ -96,8 +106,8 @@ private extension CloudKitArticlesZoneDelegate { let group = DispatchGroup() group.enter() - account?.markAsUnread(updateableUnreadArticleIDs) { result in - if case .failure(let databaseError) = result { + account?.markAsUnread(updateableUnreadArticleIDs) { databaseError in + if let databaseError = databaseError { errorOccurred = true self.logger.error("Error occurred while storing unread statuses: \(databaseError.localizedDescription, privacy: .public)") } @@ -105,8 +115,8 @@ private extension CloudKitArticlesZoneDelegate { } group.enter() - account?.markAsRead(updateableReadArticleIDs) { result in - if case .failure(let databaseError) = result { + account?.markAsRead(updateableReadArticleIDs) { databaseError in + if let databaseError = databaseError { errorOccurred = true self.logger.error("Error occurred while storing read statuses: \(databaseError.localizedDescription, privacy: .public)") } @@ -114,8 +124,8 @@ private extension CloudKitArticlesZoneDelegate { } group.enter() - account?.markAsUnstarred(updateableUnstarredArticleIDs) { result in - if case .failure(let databaseError) = result { + account?.markAsUnstarred(updateableUnstarredArticleIDs) { databaseError in + if let databaseError = databaseError { errorOccurred = true self.logger.error("Error occurred while storing unstarred statuses: \(databaseError.localizedDescription, privacy: .public)") } @@ -123,8 +133,8 @@ private extension CloudKitArticlesZoneDelegate { } group.enter() - account?.markAsStarred(updateableStarredArticleIDs) { result in - if case .failure(let databaseError) = result { + account?.markAsStarred(updateableStarredArticleIDs) { databaseError in + if let databaseError = databaseError { errorOccurred = true self.logger.error("Error occurred while stroing starred records: \(databaseError.localizedDescription, privacy: .public)") } diff --git a/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift b/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift index 700bcef17..91d0f0789 100644 --- a/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift +++ b/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift @@ -32,7 +32,7 @@ class CloudKitReceiveStatusOperation: MainThreadOperation, Logging { logger.debug("Refreshing article statuses...") - articlesZone.refreshArticles() { result in + articlesZone.fetchChangesInZone() { result in self.logger.debug("Done refreshing article statuses.") switch result { case .success: diff --git a/Account/Sources/Account/CombinedRefreshProgress.swift b/Account/Sources/Account/CombinedRefreshProgress.swift index 803fb9e1c..0020b94a6 100644 --- a/Account/Sources/Account/CombinedRefreshProgress.swift +++ b/Account/Sources/Account/CombinedRefreshProgress.swift @@ -18,25 +18,63 @@ public struct CombinedRefreshProgress { public let numberRemaining: Int public let numberCompleted: Int public let isComplete: Bool + public let isIndeterminate: Bool + public let label: String - init(numberOfTasks: Int, numberRemaining: Int, numberCompleted: Int) { + init(numberOfTasks: Int, numberRemaining: Int, numberCompleted: Int, isIndeterminate: Bool, label: String) { self.numberOfTasks = max(numberOfTasks, 0) self.numberRemaining = max(numberRemaining, 0) self.numberCompleted = max(numberCompleted, 0) self.isComplete = numberRemaining < 1 + self.isIndeterminate = isIndeterminate + self.label = label } public init(downloadProgressArray: [DownloadProgress]) { + var numberOfDownloadsPossible = 0 + var numberOfDownloadsActive = 0 var numberOfTasks = 0 var numberRemaining = 0 var numberCompleted = 0 - + var isIndeterminate = false + var isInprecise = false + for downloadProgress in downloadProgressArray { + numberOfDownloadsPossible += 1 + numberOfDownloadsActive += downloadProgress.isComplete ? 0 : 1 numberOfTasks += downloadProgress.numberOfTasks numberRemaining += downloadProgress.numberRemaining numberCompleted += downloadProgress.numberCompleted + + if downloadProgress.isIndeterminate { + isIndeterminate = true + } + + if !downloadProgress.isPrecise { + isInprecise = true + } } - self.init(numberOfTasks: numberOfTasks, numberRemaining: numberRemaining, numberCompleted: numberCompleted) + var label = "" + + if numberOfDownloadsActive > 0 { + if isInprecise { + if numberOfDownloadsActive == 1 { + if let activeName = downloadProgressArray.first(where: { $0.isComplete == false })?.name { + let formatString = NSLocalizedString("Syncing %@", comment: "Status bar progress") + label = NSString(format: formatString as NSString, activeName) as String + } + } else { + let formatString = NSLocalizedString("Syncing %@ accounts", comment: "Status bar progress") + label = NSString(format: formatString as NSString, NSNumber(value: numberOfDownloadsActive)) as String + } + } else { + let formatString = NSLocalizedString("%@ of %@", comment: "Status bar progress") + label = NSString(format: formatString as NSString, NSNumber(value: numberCompleted), NSNumber(value: numberOfTasks)) as String + } + } + + self.init(numberOfTasks: numberOfTasks, numberRemaining: numberRemaining, numberCompleted: numberCompleted, isIndeterminate: isIndeterminate, label: label) } + } diff --git a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift index 334f02e4b..c73b5ef05 100644 --- a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift @@ -576,6 +576,7 @@ final class FeedbinAccountDelegate: AccountDelegate, Logging { func accountDidInitialize(_ account: Account) { credentials = try? account.retrieveCredentials(type: .basic) + refreshProgress.name = account.nameForDisplay } func accountWillBeDeleted(_ account: Account) { @@ -1406,27 +1407,29 @@ private extension FeedbinAccountDelegate { completion(.failure(FeedbinAccountDelegateError.invalidParameter)) return } + + func complete() { + DispatchQueue.main.async { + account.clearWebFeedMetadata(feed) + account.removeWebFeed(feed) + if let folders = account.folders { + for folder in folders { + folder.removeWebFeed(feed) + } + } + completion(.success(())) + } + } refreshProgress.addToNumberOfTasksAndRemaining(1) caller.deleteSubscription(subscriptionID: subscriptionID) { result in self.refreshProgress.completeTask() switch result { case .success: - DispatchQueue.main.async { - account.clearWebFeedMetadata(feed) - account.removeWebFeed(feed) - if let folders = account.folders { - for folder in folders { - folder.removeWebFeed(feed) - } - } - completion(.success(())) - } + complete() case .failure(let error): - DispatchQueue.main.async { - let wrappedError = AccountError.wrappedError(error: error, account: account) - completion(.failure(wrappedError)) - } + self.logger.error("Unable to remove feed from Feedbin. Removing locally and continue processing: \(error.localizedDescription, privacy: .public)") + complete() } } diff --git a/Account/Sources/Account/Feedly/FeedlyAPICaller.swift b/Account/Sources/Account/Feedly/FeedlyAPICaller.swift index 40942b2f8..e84b801bd 100644 --- a/Account/Sources/Account/Feedly/FeedlyAPICaller.swift +++ b/Account/Sources/Account/Feedly/FeedlyAPICaller.swift @@ -7,6 +7,7 @@ // import Foundation +import RSCore import RSWeb import Secrets @@ -820,33 +821,48 @@ extension FeedlyAPICaller: FeedlyMarkArticlesService { fatalError("\(components) does not produce a valid URL.") } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) - request.addValue("application/json", forHTTPHeaderField: "Accept-Type") - request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) - - do { - let body = MarkerEntriesBody(action: action.actionValue, entryIds: Array(articleIds)) - let encoder = JSONEncoder() - let data = try encoder.encode(body) - request.httpBody = data - } catch { - return DispatchQueue.main.async { - completion(.failure(error)) + let articleIdChunks = Array(articleIds).chunked(into: 300) + let dispatchGroup = DispatchGroup() + var groupError: Error? = nil + + for articleIdChunk in articleIdChunks { + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType) + request.addValue("application/json", forHTTPHeaderField: "Accept-Type") + request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization) + + do { + let body = MarkerEntriesBody(action: action.actionValue, entryIds: Array(articleIdChunk)) + let encoder = JSONEncoder() + let data = try encoder.encode(body) + request.httpBody = data + } catch { + return DispatchQueue.main.async { + completion(.failure(error)) + } + } + + dispatchGroup.enter() + send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in + switch result { + case .success(let (httpResponse, _)): + if httpResponse.statusCode != 200 { + groupError = URLError(.cannotDecodeContentData) + } + case .failure(let error): + groupError = error + } + dispatchGroup.leave() } } - send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in - switch result { - case .success(let (httpResponse, _)): - if httpResponse.statusCode == 200 { - completion(.success(())) - } else { - completion(.failure(URLError(.cannotDecodeContentData))) - } - case .failure(let error): - completion(.failure(error)) + dispatchGroup.notify(queue: .main) { + if let groupError = groupError { + completion(.failure(groupError)) + } else { + completion(.success(())) } } } diff --git a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift index ba4f133d3..4294c4726 100644 --- a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift @@ -527,6 +527,7 @@ final class FeedlyAccountDelegate: AccountDelegate, Logging { func accountDidInitialize(_ account: Account) { initializedAccount = account credentials = try? account.retrieveCredentials(type: .oauthAccessToken) + refreshProgress.name = account.nameForDisplay } func accountWillBeDeleted(_ account: Account) { diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift index 29022c3fa..a38ee238f 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift @@ -123,18 +123,18 @@ final class FeedlyIngestStarredArticleIdsOperation: FeedlyOperation, Logging { let results = StarredStatusResults() group.enter() - account.markAsStarred(remoteStarredArticleIDs) { result in - if case .failure(let error) = result { - results.markAsStarredError = error + account.markAsStarred(remoteStarredArticleIDs) { databaseError in + if let databaseError = databaseError { + results.markAsStarredError = databaseError } group.leave() } let deltaUnstarredArticleIDs = localStarredArticleIDs.subtracting(remoteStarredArticleIDs) group.enter() - account.markAsUnstarred(deltaUnstarredArticleIDs) { result in - if case .failure(let error) = result { - results.markAsUnstarredError = error + account.markAsUnstarred(deltaUnstarredArticleIDs) { databaseError in + if let databaseError = databaseError { + results.markAsUnstarredError = databaseError } group.leave() } diff --git a/Account/Sources/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift b/Account/Sources/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift index 72ec9f075..fa618fe70 100644 --- a/Account/Sources/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift +++ b/Account/Sources/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift @@ -123,18 +123,18 @@ final class FeedlyIngestUnreadArticleIdsOperation: FeedlyOperation, Logging { let results = ReadStatusResults() group.enter() - account.markAsUnread(remoteUnreadArticleIDs) { result in - if case .failure(let error) = result { - results.markAsUnreadError = error + account.markAsUnread(remoteUnreadArticleIDs) { databaseError in + if let databaseError = databaseError { + results.markAsUnreadError = databaseError } group.leave() } let articleIDsToMarkRead = localUnreadArticleIDs.subtracting(remoteUnreadArticleIDs) group.enter() - account.markAsRead(articleIDsToMarkRead) { result in - if case .failure(let error) = result { - results.markAsReadError = error + account.markAsRead(articleIDsToMarkRead) { databaseError in + if let databaseError = databaseError { + results.markAsReadError = databaseError } group.leave() } diff --git a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift index d19608c36..cfe18af77 100644 --- a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift @@ -222,6 +222,8 @@ final class LocalAccountDelegate: AccountDelegate, Logging { func accountDidInitialize(_ account: Account) { self.account = account + refreshProgress.name = account.nameForDisplay + refreshProgress.isPrecise = true } func accountWillBeDeleted(_ account: Account) { diff --git a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift index 37966a2af..443ffb535 100644 --- a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -603,6 +603,7 @@ final class NewsBlurAccountDelegate: AccountDelegate, Logging { func accountDidInitialize(_ account: Account) { credentials = try? account.retrieveCredentials(type: .newsBlurSessionId) + refreshProgress.name = account.nameForDisplay } func accountWillBeDeleted(_ account: Account) { diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index ae2c4ae49..813317083 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -628,6 +628,7 @@ final class ReaderAPIAccountDelegate: AccountDelegate, Logging { func accountDidInitialize(_ account: Account) { credentials = try? account.retrieveCredentials(type: .readerAPIKey) + refreshProgress.name = account.nameForDisplay } func accountWillBeDeleted(_ account: Account) { @@ -1058,7 +1059,7 @@ private extension ReaderAPIAccountDelegate { uniqueID: entry.uniqueID(variant: variant), feedURL: streamID, url: nil, - externalURL: entry.alternates.first?.url, + externalURL: entry.alternates?.first?.url, title: entry.title, language: nil, contentHTML: entry.summary.content, diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift b/Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift index 5d8575945..93de146f8 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPIEntry.swift @@ -58,7 +58,7 @@ struct ReaderAPIEntry: Codable { let timestampUsec: String? let summary: ReaderAPIArticleSummary - let alternates: [ReaderAPIAlternateLocation] + let alternates: [ReaderAPIAlternateLocation]? let categories: [String] let origin: ReaderAPIEntryOrigin diff --git a/Appcasts/netnewswire-beta.xml b/Appcasts/netnewswire-beta.xml index 0fcfa3416..c4699ebef 100755 --- a/Appcasts/netnewswire-beta.xml +++ b/Appcasts/netnewswire-beta.xml @@ -5,6 +5,39 @@ https://ranchero.com/downloads/netnewswire-beta.xml Most recent NetNewsWire changes with links to updates. en + + + NetNewsWire 6.1.1b1 + Fixed a bug that could prevent users from accessing BazQux if an article was missing a field

+

Fixed an issue that could prevent Feedly users from syncing if they tried to mark too many articles as read at the same time

+ ]]>
+ Wed, 02 Nov 2022 21:20:00 -0700 + + 10.15.0 +
+ + + NetNewsWire 6.1 + Article themes. Several themes ship with the app, and you can create your own. You can change the theme in Preferences or by adding the theme switcher to the toolbar

+

Copy URLs using repaired, rather than raw, feed links

+

Restore article scroll position on relaunching app

+

Added Copy Article URL and Copy External URL commands to the Edit menu

+

Fixed a bug where using cmd-Q wouldn’t always quit the app as quickly as one might prefer

+

Disallow creation of iCloud account in the app if iCloud and iCloud Drive aren’t both enabled

+

Fixed bug showing quote tweets that only included an image

+

Added a hidden pref to suppress downloading/syncing on start: `defaults write com.ranchero.NetNewsWire-Evergreen DevroeSuppressSyncOnLaunch -bool true`

+

Video autoplay is now disallowed

+

Article view now supports RTL layout

+

Fixed a few font and sizing issues

+

Updated built-in feeds

+

Better alignment for items in General Preferences pane

+ ]]>
+ Thu, 07 Apr 2022 10:05:00 -0700 + + 10.15.0 +
NetNewsWire 6.1b5 diff --git a/Appcasts/netnewswire-release.xml b/Appcasts/netnewswire-release.xml index 1781af04d..7c9cc240e 100755 --- a/Appcasts/netnewswire-release.xml +++ b/Appcasts/netnewswire-release.xml @@ -6,6 +6,28 @@ Most recent NetNewsWire releases (not test builds). en + + NetNewsWire 6.1 + Article themes. Several themes ship with the app, and you can create your own. You can change the theme in Preferences or by adding the theme switcher to the toolbar

+

Copy URLs using repaired, rather than raw, feed links

+

Restore article scroll position on relaunching app

+

Added Copy Article URL and Copy External URL commands to the Edit menu

+

Fixed a bug where using cmd-Q wouldn’t always quit the app as quickly as one might prefer

+

Disallow creation of iCloud account in the app if iCloud and iCloud Drive aren’t both enabled

+

Fixed bug showing quote tweets that only included an image

+

Added a hidden pref to suppress downloading/syncing on start: `defaults write com.ranchero.NetNewsWire-Evergreen DevroeSuppressSyncOnLaunch -bool true`

+

Video autoplay is now disallowed

+

Article view now supports RTL layout

+

Fixed a few font and sizing issues

+

Updated built-in feeds

+

Better alignment for items in General Preferences pane

+ ]]>
+ Thu, 07 Apr 2022 10:05:00 -0700 + + 10.15.0 +
+ NetNewsWire 6.0.3 , statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping ArticleIDsCompletionBlock) { - articlesTable.markAndFetchNew(articleIDs, statusKey, flag, completion) + public func mark(articleIDs: Set, statusKey: ArticleStatus.Key, flag: Bool, completion: DatabaseCompletionBlock?) { + articlesTable.mark(articleIDs, statusKey, flag, completion) } /// Create statuses for specified articleIDs. For existing statuses, don’t do anything. diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift index e7768af8d..e41a57a25 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift @@ -221,7 +221,7 @@ final class ArticlesTable: DatabaseTable { func makeDatabaseCalls(_ database: FMDatabase) { let articleIDs = parsedItems.articleIDs() - let (statusesDictionary, _) = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1 + let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1 assert(statusesDictionary.count == articleIDs.count) let incomingArticles = Article.articlesWithParsedItems(parsedItems, webFeedID, self.accountID, statusesDictionary) //2 @@ -303,7 +303,7 @@ final class ArticlesTable: DatabaseTable { articleIDs.formUnion(parsedItems.articleIDs()) } - let (statusesDictionary, _) = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1 + let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1 assert(statusesDictionary.count == articleIDs.count) let allIncomingArticles = Article.articlesWithWebFeedIDsAndItems(webFeedIDsAndItems, self.accountID, statusesDictionary) //2 @@ -476,17 +476,17 @@ final class ArticlesTable: DatabaseTable { } } - func markAndFetchNew(_ articleIDs: Set, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ completion: @escaping ArticleIDsCompletionBlock) { + func mark(_ articleIDs: Set, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ completion: DatabaseCompletionBlock?) { queue.runInTransaction { databaseResult in switch databaseResult { case .success(let database): - let newStatusIDs = self.statusesTable.markAndFetchNew(articleIDs, statusKey, flag, database) + self.statusesTable.mark(articleIDs, statusKey, flag, database) DispatchQueue.main.async { - completion(.success(newStatusIDs)) + completion?(nil) } case .failure(let databaseError): DispatchQueue.main.async { - completion(.failure(databaseError)) + completion?(databaseError) } } } diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift b/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift index 95d06d788..d170718e4 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/StatusesTable.swift @@ -28,7 +28,7 @@ final class StatusesTable: DatabaseTable { // MARK: - Creating/Updating - func ensureStatusesForArticleIDs(_ articleIDs: Set, _ read: Bool, _ database: FMDatabase) -> ([String: ArticleStatus], Set) { + func ensureStatusesForArticleIDs(_ articleIDs: Set, _ read: Bool, _ database: FMDatabase) -> [String: ArticleStatus] { #if DEBUG // Check for missing statuses — this asserts that all the passed-in articleIDs exist in the statuses table. @@ -44,7 +44,7 @@ final class StatusesTable: DatabaseTable { // Check cache. let articleIDsMissingCachedStatus = articleIDsWithNoCachedStatus(articleIDs) if articleIDsMissingCachedStatus.isEmpty { - return (statusesDictionary(articleIDs), Set()) + return statusesDictionary(articleIDs) } // Check database. @@ -56,7 +56,7 @@ final class StatusesTable: DatabaseTable { self.createAndSaveStatusesForArticleIDs(articleIDsNeedingStatus, read, database) } - return (statusesDictionary(articleIDs), articleIDsNeedingStatus) + return statusesDictionary(articleIDs) } // MARK: - Marking @@ -85,11 +85,10 @@ final class StatusesTable: DatabaseTable { return updatedStatuses } - func markAndFetchNew(_ articleIDs: Set, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) -> Set { - let (statusesDictionary, newStatusIDs) = ensureStatusesForArticleIDs(articleIDs, flag, database) + func mark(_ articleIDs: Set, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) { + let statusesDictionary = ensureStatusesForArticleIDs(articleIDs, flag, database) let statuses = Set(statusesDictionary.values) mark(statuses, statusKey, flag, database) - return newStatusIDs } // MARK: - Fetching diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index 2e6103762..21c22c35a 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -295,4 +295,10 @@ struct AppAssets { } } + static var notificationSoundBlipFileName: String = { + // https://freesound.org/people/cabled_mess/sounds/350862/ + return "notificationSoundBlip.mp3" + }() + + } diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index df9b7c618..685d6fd44 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -42,6 +42,7 @@ final class AppDefaults { static let exportOPMLAccountID = "exportOPMLAccountID" static let defaultBrowserID = "defaultBrowserID" static let currentThemeName = "currentThemeName" + static let hasSeenNotAllArticlesHaveURLsAlert = "hasSeenNotAllArticlesHaveURLsAlert" // Hidden prefs static let showDebugMenu = "ShowDebugMenu" @@ -220,6 +221,15 @@ final class AppDefaults { AppDefaults.setString(for: Key.currentThemeName, newValue) } } + + var hasSeenNotAllArticlesHaveURLsAlert: Bool { + get { + return UserDefaults.standard.bool(forKey: Key.hasSeenNotAllArticlesHaveURLsAlert) + } + set { + UserDefaults.standard.set(newValue, forKey: Key.hasSeenNotAllArticlesHaveURLsAlert) + } + } var showTitleOnMainWindow: Bool { return AppDefaults.bool(for: Key.showTitleOnMainWindow) diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 959c2c213..d155b34a4 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -16,6 +16,7 @@ import RSCore import RSCoreResources import Secrets import CrashReporter +import SwiftUI // If we're not going to import Sparkle, provide dummy protocols to make it easy // for AppDelegate to comply @@ -31,7 +32,7 @@ var appDelegate: AppDelegate! @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, UNUserNotificationCenterDelegate, UnreadCountProvider, SPUStandardUserDriverDelegate, SPUUpdaterDelegate, Logging { - + private struct WindowRestorationIdentifiers { static let mainWindow = "mainWindow" } @@ -43,7 +44,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, var webFeedIconDownloader: WebFeedIconDownloader! var extensionContainersFile: ExtensionContainersFile! var extensionFeedAddRequestFile: ExtensionFeedAddRequestFile! - + var appName: String! var refreshTimer: AccountRefreshTimer? @@ -60,7 +61,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } } } - + var isShutDownSyncDone = false @IBOutlet var shareMenuItem: NSMenuItem! @@ -70,7 +71,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, @IBOutlet var sortByNewestArticleOnTopMenuItem: NSMenuItem! @IBOutlet var groupArticlesByFeedMenuItem: NSMenuItem! @IBOutlet var checkForUpdatesMenuItem: NSMenuItem! - + var unreadCount = 0 { didSet { if unreadCount != oldValue { @@ -79,7 +80,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } } } - + private var mainWindowController: MainWindowController? { var bestController: MainWindowController? for candidateController in mainWindowControllers { @@ -104,43 +105,40 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, private var inspectorWindowController: InspectorWindowController? private var crashReportWindowController: CrashReportWindowController? // For testing only private let appMovementMonitor = RSAppMovementMonitor() - #if !MAC_APP_STORE && !TEST +#if !MAC_APP_STORE && !TEST private var softwareUpdater: SPUUpdater! private var crashReporter: PLCrashReporter! - #endif +#endif - private var themeImportPath: String? - override init() { NSWindow.allowsAutomaticWindowTabbing = false super.init() - - #if !MAC_APP_STORE + +#if !MAC_APP_STORE let crashReporterConfig = PLCrashReporterConfig.defaultConfiguration() crashReporter = PLCrashReporter(configuration: crashReporterConfig) crashReporter.enable() - #endif - +#endif + SecretsManager.provider = Secrets() AccountManager.shared = AccountManager(accountsFolder: Platform.dataSubfolder(forApplication: nil, folderName: "Accounts")!) ArticleThemesManager.shared = ArticleThemesManager(folderPath: Platform.dataSubfolder(forApplication: nil, folderName: "Themes")!) FeedProviderManager.shared.delegate = ExtensionPointManager.shared - + NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(inspectableObjectsDidChange(_:)), name: .InspectableObjectsDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(importDownloadedTheme(_:)), name: .didEndDownloadingTheme, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(themeImportError(_:)), name: .didFailToImportThemeWithError, object: nil) NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(didWakeNotification(_:)), name: NSWorkspace.didWakeNotification, object: nil) - + appDelegate = self } - + // MARK: - API func showAddFolderSheetOnWindow(_ window: NSWindow) { addFolderWindowController = AddFolderWindowController() addFolderWindowController!.runSheetOnWindow(window) } - + func showAddWebFeedSheetOnWindow(_ window: NSWindow, urlString: String?, name: String?, account: Account?, folder: Folder?) { addFeedController = AddFeedController(hostWindow: window) addFeedController?.showAddFeedSheet(.webFeed, urlString, name, account, folder) @@ -152,7 +150,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, installAppleEventHandlers() CacheCleaner.purgeIfNecessary() - + // Try to establish a cache in the Caches folder, but if it fails for some reason fall back to a temporary dir let cacheFolder: String if let userCacheFolder = try? FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false).path { @@ -162,40 +160,40 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, let bundleIdentifier = (Bundle.main.infoDictionary!["CFBundleIdentifier"]! as! String) cacheFolder = (NSTemporaryDirectory() as NSString).appendingPathComponent(bundleIdentifier) } - + let faviconsFolder = (cacheFolder as NSString).appendingPathComponent("Favicons") let faviconsFolderURL = URL(fileURLWithPath: faviconsFolder) try! FileManager.default.createDirectory(at: faviconsFolderURL, withIntermediateDirectories: true, attributes: nil) faviconDownloader = FaviconDownloader(folder: faviconsFolder) - + let imagesFolder = (cacheFolder as NSString).appendingPathComponent("Images") let imagesFolderURL = URL(fileURLWithPath: imagesFolder) try! FileManager.default.createDirectory(at: imagesFolderURL, withIntermediateDirectories: true, attributes: nil) imageDownloader = ImageDownloader(folder: imagesFolder) - + authorAvatarDownloader = AuthorAvatarDownloader(imageDownloader: imageDownloader) webFeedIconDownloader = WebFeedIconDownloader(imageDownloader: imageDownloader, folder: cacheFolder) - + appName = (Bundle.main.infoDictionary!["CFBundleExecutable"]! as! String) } func applicationDidFinishLaunching(_ note: Notification) { - - #if MAC_APP_STORE || TEST - checkForUpdatesMenuItem.isHidden = true - #else - // Initialize Sparkle... - let hostBundle = Bundle.main - let updateDriver = SPUStandardUserDriver(hostBundle: hostBundle, delegate: self) - self.softwareUpdater = SPUUpdater(hostBundle: hostBundle, applicationBundle: hostBundle, userDriver: updateDriver, delegate: self) - - do { - try self.softwareUpdater.start() - } - catch { - logger.error("Failed to start software updater with error: \(error.localizedDescription, privacy: .public)") - } - #endif + +#if MAC_APP_STORE || TEST + checkForUpdatesMenuItem.isHidden = true +#else + // Initialize Sparkle... + let hostBundle = Bundle.main + let updateDriver = SPUStandardUserDriver(hostBundle: hostBundle, delegate: self) + self.softwareUpdater = SPUUpdater(hostBundle: hostBundle, applicationBundle: hostBundle, userDriver: updateDriver, delegate: self) + + do { + try self.softwareUpdater.start() + } + catch { + logger.error("Failed to start software updater with error: \(error.localizedDescription, privacy: .public)") + } +#endif AppDefaults.shared.registerDefaults() let isFirstRun = AppDefaults.shared.isFirstRun @@ -203,14 +201,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, logger.debug("Is first run") } let localAccount = AccountManager.shared.defaultAccount - + if isFirstRun && !AccountManager.shared.anyAccountHasAtLeastOneFeed() { // Import feeds. Either old NNW 3 feeds or the default feeds. if !NNW3ImportController.importSubscriptionsIfFileExists(account: localAccount) { DefaultFeedsImporter.importDefaultFeeds(account: localAccount) } } - + updateSortMenuItems() updateGroupByFeedMenuItem() @@ -225,26 +223,26 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, if isFirstRun { mainWindowController?.window?.center() } - + NotificationCenter.default.addObserver(self, selector: #selector(webFeedSettingDidChange(_:)), name: .WebFeedSettingDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) - + DispatchQueue.main.async { self.unreadCount = AccountManager.shared.unreadCount } - + if InspectorWindowController.shouldOpenAtStartup { self.toggleInspectorWindow(self) } - + extensionContainersFile = ExtensionContainersFile() extensionFeedAddRequestFile = ExtensionFeedAddRequestFile() - + refreshTimer = AccountRefreshTimer() syncTimer = ArticleStatusSyncTimer() - UNUserNotificationCenter.current().requestAuthorization(options:[.badge]) { (granted, error) in } - + UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .alert, .sound]) { (granted, error) in } + UNUserNotificationCenter.current().getNotificationSettings { (settings) in if settings.authorizationStatus == .authorized { DispatchQueue.main.async { @@ -252,14 +250,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } } } - + UNUserNotificationCenter.current().delegate = self userNotificationManager = UserNotificationManager() - - #if DEBUG + +#if DEBUG refreshTimer!.update() syncTimer!.update() - #else +#else if AppDefaults.shared.suppressSyncOnLaunch { refreshTimer!.update() syncTimer!.update() @@ -269,26 +267,26 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, self.syncTimer!.timedRefresh(nil) } } - #endif +#endif if AppDefaults.shared.showDebugMenu { - // The Web Inspector uses SPI and can never appear in a MAC_APP_STORE build. - #if MAC_APP_STORE - let debugMenu = debugMenuItem.submenu! - let toggleWebInspectorItemIndex = debugMenu.indexOfItem(withTarget: self, andAction: #selector(toggleWebInspectorEnabled(_:))) - if toggleWebInspectorItemIndex != -1 { - debugMenu.removeItem(at: toggleWebInspectorItemIndex) - } - #endif - } else { + // The Web Inspector uses SPI and can never appear in a MAC_APP_STORE build. +#if MAC_APP_STORE + let debugMenu = debugMenuItem.submenu! + let toggleWebInspectorItemIndex = debugMenu.indexOfItem(withTarget: self, andAction: #selector(toggleWebInspectorEnabled(_:))) + if toggleWebInspectorItemIndex != -1 { + debugMenu.removeItem(at: toggleWebInspectorItemIndex) + } +#endif + } else { debugMenuItem.menu?.removeItem(debugMenuItem) } - - #if !MAC_APP_STORE + +#if !MAC_APP_STORE DispatchQueue.main.async { CrashReporter.check(crashReporter: self.crashReporter) } - #endif +#endif } @@ -299,7 +297,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, mainWindowController.handle(userActivity) return true } - + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { // https://github.com/brentsimmons/NetNewsWire/issues/522 // I couldn’t reproduce the crashing bug, but it appears to happen on creating a main window @@ -313,7 +311,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, mainWindowController.showWindow(self) return false } - + func applicationDidBecomeActive(_ notification: Notification) { fireOldTimers() } @@ -322,7 +320,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, ArticleStringFormatter.emptyCaches() saveState() } - + func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String : Any]) { AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) } @@ -346,14 +344,58 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, let timeout = Date().addingTimeInterval(2) while !isShutDownSyncDone && RunLoop.current.run(mode: .default, before: timeout) && timeout > Date() { } } - + + func presentThemeImportError(_ error: Error) { + var informativeText: String = "" + + if let decodingError = error as? DecodingError { + switch decodingError { + case .typeMismatch(let type, _): + let localizedError = NSLocalizedString("This theme cannot be used because the the type—“%@”—is mismatched in the Info.plist", comment: "Type mismatch") + informativeText = NSString.localizedStringWithFormat(localizedError as NSString, type as! CVarArg) as String + case .valueNotFound(let value, _): + let localizedError = NSLocalizedString("This theme cannot be used because the the value—“%@”—is not found in the Info.plist.", comment: "Decoding value missing") + informativeText = NSString.localizedStringWithFormat(localizedError as NSString, value as! CVarArg) as String + case .keyNotFound(let codingKey, _): + let localizedError = NSLocalizedString("This theme cannot be used because the the key—“%@”—is not found in the Info.plist.", comment: "Decoding key missing") + informativeText = NSString.localizedStringWithFormat(localizedError as NSString, codingKey.stringValue) as String + case .dataCorrupted(let context): + guard let underlyingError = context.underlyingError as NSError?, + let debugDescription = underlyingError.userInfo["NSDebugDescription"] as? String else { + informativeText = error.localizedDescription + break + } + let localizedError = NSLocalizedString("This theme cannot be used because of data corruption in the Info.plist: %@.", comment: "Decoding key missing") + informativeText = NSString.localizedStringWithFormat(localizedError as NSString, debugDescription) as String + + default: + informativeText = error.localizedDescription + } + } else { + informativeText = error.localizedDescription + } + + DispatchQueue.main.async { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = NSLocalizedString("Theme Error", comment: "Theme error") + alert.informativeText = informativeText + alert.addButton(withTitle: NSLocalizedString("OK", comment: "OK")) + + alert.buttons[0].keyEquivalent = "\r" + + let response = alert.runModal() + } + } + // MARK: Notifications + @objc func unreadCountDidChange(_ note: Notification) { if note.object is AccountManager { unreadCount = AccountManager.shared.unreadCount } } - + @objc func webFeedSettingDidChange(_ note: Notification) { guard let feed = note.object as? WebFeed, let key = note.userInfo?[WebFeed.WebFeedSettingUserInfoKey] as? String else { return @@ -362,14 +404,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, let _ = faviconDownloader.favicon(for: feed) } } - + @objc func inspectableObjectsDidChange(_ note: Notification) { guard let inspectorWindowController = inspectorWindowController, inspectorWindowController.isOpen else { return } inspectorWindowController.objects = objectsForInspector() } - + @objc func userDefaultsDidChange(_ note: Notification) { updateSortMenuItems() updateGroupByFeedMenuItem() @@ -388,14 +430,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, @objc func importDownloadedTheme(_ note: Notification) { guard let userInfo = note.userInfo, - let url = userInfo["url"] as? URL else { + let url = userInfo["url"] as? URL else { return } DispatchQueue.main.async { self.importTheme(filename: url.path) } } - + // MARK: Main Window func createMainWindowController() -> MainWindowController { @@ -408,12 +450,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, mainWindowControllers.append(controller) return controller } - + func windowControllerWithName(_ storyboardName: String) -> NSWindowController { let storyboard = NSStoryboard(name: NSStoryboard.Name(storyboardName), bundle: nil) return storyboard.instantiateInitialController()! as! NSWindowController } - + @discardableResult func createAndShowMainWindow() -> MainWindowController { let controller = createMainWindowController() @@ -426,7 +468,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, return controller } - + func createAndShowMainWindowIfNecessary() { if mainWindowController == nil { createAndShowMainWindow() @@ -434,7 +476,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, mainWindowController?.showWindow(self) } } - + func removeMainWindow(_ windowController: MainWindowController) { guard mainWindowControllers.count > 1 else { return } if let index = mainWindowControllers.firstIndex(of: windowController) { @@ -447,10 +489,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, if shuttingDown { return false } - + let isDisplayingSheet = mainWindowController?.isDisplayingSheet ?? false let isSpecialAccountAvailable = AccountManager.shared.activeAccounts.contains(where: { $0.type == .onMyMac || $0.type == .cloudKit }) - + if item.action == #selector(refreshAll(_:)) { return !AccountManager.shared.refreshInProgress && !AccountManager.shared.activeAccounts.isEmpty } @@ -484,7 +526,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } return ExtensionPointManager.shared.isTwitterEnabled } + + #if !DEBUG + if item.action == #selector(debugDropConditionalGetInfo(_:)) { + return false + } + #endif + if item.action == #selector(debugTestCrashReporterWindow(_:)) || + item.action == #selector(debugTestCrashReportSending(_:)) || + item.action == #selector(forceCrash(_:)) { + let appIDPrefix = Bundle.main.infoDictionary?["AppIdentifierPrefix"] as! String + return appIDPrefix == "M8L2WTLA8W." + } + #if !MAC_APP_STORE if item.action == #selector(toggleWebInspectorEnabled(_:)) { (item as! NSMenuItem).state = AppDefaults.shared.webInspectorEnabled ? .on : .off @@ -719,6 +774,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, self.softwareUpdater.checkForUpdates() #endif } + + @IBAction func showAbout(_ sender: Any?) { + if #available(macOS 12, *) { + for window in NSApplication.shared.windows { + if window.identifier == .aboutNetNewsWire { + window.makeKeyAndOrderFront(nil) + return + } + } + let controller = AboutWindowController() + controller.window?.makeKeyAndOrderFront(nil) + } else { + NSApplication.shared.orderFrontStandardAboutPanel(self) + } + } } @@ -751,31 +821,53 @@ extension AppDelegate { @IBAction func debugDropConditionalGetInfo(_ sender: Any?) { #if DEBUG - AccountManager.shared.activeAccounts.forEach{ $0.debugDropConditionalGetInfo() } + AccountManager.shared.activeAccounts.forEach{ $0.debugDropConditionalGetInfo() } #endif } + @IBAction func debugClearImageCaches(_ sender: Any?) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = NSLocalizedString("Are you sure you want to clear the image caches? This will restart NetNewsWire to begin reloading the remote images.", + comment: "Clear and restart confirmation message.") + alert.addButton(withTitle: NSLocalizedString("Clear & Restart", comment: "Clear & Restart")) + alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Cancel")) + + let userChoice = alert.runModal() + if userChoice == .alertFirstButtonReturn { + CacheCleaner.purge() + + let configuration = NSWorkspace.OpenConfiguration() + configuration.createsNewApplicationInstance = true + NSWorkspace.shared.openApplication(at: Bundle.main.bundleURL, configuration: configuration) + + NSApp.terminate(self) + } + } + @IBAction func debugTestCrashReporterWindow(_ sender: Any?) { #if DEBUG - crashReportWindowController = CrashReportWindowController(crashLogText: "This is a test crash log.") - crashReportWindowController!.testing = true - crashReportWindowController!.showWindow(self) + crashReportWindowController = CrashReportWindowController(crashLogText: "This is a test crash log.") + crashReportWindowController!.testing = true + crashReportWindowController!.showWindow(self) #endif } @IBAction func debugTestCrashReportSending(_ sender: Any?) { + #if DEBUG CrashReporter.sendCrashLogText("This is a test. Hi, Brent.") + #endif } @IBAction func forceCrash(_ sender: Any?) { + #if DEBUG fatalError("This is a deliberate crash.") + #endif } @IBAction func openApplicationSupportFolder(_ sender: Any?) { - #if DEBUG - guard let appSupport = Platform.dataSubfolder(forApplication: nil, folderName: "") else { return } - NSWorkspace.shared.open(URL(fileURLWithPath: appSupport)) - #endif + guard let appSupport = Platform.dataSubfolder(forApplication: nil, folderName: "") else { return } + NSWorkspace.shared.open(URL(fileURLWithPath: appSupport)) } @IBAction func toggleWebInspectorEnabled(_ sender: Any?) { @@ -898,8 +990,7 @@ internal extension AppDelegate { } } } catch { - NotificationCenter.default.post(name: .didFailToImportThemeWithError, object: nil, userInfo: ["error" : error, "path": filename]) - logger.error("Error importing theme: \(error.localizedDescription, privacy: .public)") + presentThemeImportError(error) } } @@ -918,67 +1009,6 @@ internal extension AppDelegate { alert.beginSheetModal(for: window) } - @objc func themeImportError(_ note: Notification) { - guard let userInfo = note.userInfo, - let error = userInfo["error"] as? Error else { - return - } - themeImportPath = userInfo["path"] as? String - var informativeText: String = "" - if let decodingError = error as? DecodingError { - switch decodingError { - case .typeMismatch(let type, _): - let localizedError = NSLocalizedString("This theme cannot be used because the the type—“%@”—is mismatched in the Info.plist", comment: "Type mismatch") - informativeText = NSString.localizedStringWithFormat(localizedError as NSString, type as! CVarArg) as String - case .valueNotFound(let value, _): - let localizedError = NSLocalizedString("This theme cannot be used because the the value—“%@”—is not found in the Info.plist.", comment: "Decoding value missing") - informativeText = NSString.localizedStringWithFormat(localizedError as NSString, value as! CVarArg) as String - case .keyNotFound(let codingKey, _): - let localizedError = NSLocalizedString("This theme cannot be used because the the key—“%@”—is not found in the Info.plist.", comment: "Decoding key missing") - informativeText = NSString.localizedStringWithFormat(localizedError as NSString, codingKey.stringValue) as String - case .dataCorrupted(let context): - guard let underlyingError = context.underlyingError as NSError?, - let debugDescription = underlyingError.userInfo["NSDebugDescription"] as? String else { - informativeText = error.localizedDescription - break - } - let localizedError = NSLocalizedString("This theme cannot be used because of data corruption in the Info.plist: %@.", comment: "Decoding key missing") - informativeText = NSString.localizedStringWithFormat(localizedError as NSString, debugDescription) as String - - default: - informativeText = error.localizedDescription - } - } else { - informativeText = error.localizedDescription - } - - DispatchQueue.main.async { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = NSLocalizedString("Theme Error", comment: "Theme download error") - alert.informativeText = informativeText - alert.addButton(withTitle: NSLocalizedString("Open Theme Folder", comment: "Open Theme Folder")) - alert.addButton(withTitle: NSLocalizedString("OK", comment: "OK")) - - let button = alert.buttons.first - button?.target = self - button?.action = #selector(self.openThemesFolder(_:)) - alert.buttons[0].keyEquivalent = "\033" - alert.buttons[1].keyEquivalent = "\r" - alert.runModal() - } - } - - @objc func openThemesFolder(_ sender: Any) { - if themeImportPath == nil { - let url = URL(fileURLWithPath: ArticleThemesManager.shared.folderPath) - NSWorkspace.shared.open(url) - } else { - let url = URL(fileURLWithPath: themeImportPath!) - NSWorkspace.shared.open(url.deletingLastPathComponent()) - } - } - } /* @@ -1020,41 +1050,31 @@ extension AppDelegate: NSWindowRestoration { private extension AppDelegate { func handleMarkAsRead(userInfo: [AnyHashable: Any]) { + markArticle(userInfo: userInfo, statusKey: .read) + } + + func handleMarkAsStarred(userInfo: [AnyHashable: Any]) { + markArticle(userInfo: userInfo, statusKey: .starred) + } + + func markArticle(userInfo: [AnyHashable: Any], statusKey: ArticleStatus.Key) { guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { return } - let account = AccountManager.shared.existingAccount(with: accountID) - guard account != nil else { + guard let account = AccountManager.shared.existingAccount(with: accountID) else { logger.debug("No account found from notification.") return } - let article = try? account!.fetchArticles(.articleIDs([articleID])) - guard article != nil else { + + guard let articles = try? account.fetchArticles(.articleIDs([articleID])), !articles.isEmpty else { logger.debug("No article found from search using: \(articleID, privacy: .public)") return } - account!.markArticles(article!, statusKey: .read, flag: true) { _ in } + + account.mark(articles: articles, statusKey: statusKey, flag: true) { _ in } } - func handleMarkAsStarred(userInfo: [AnyHashable: Any]) { - guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], - let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, - let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { - return - } - let account = AccountManager.shared.existingAccount(with: accountID) - guard account != nil else { - logger.debug("No account found from notification.") - return - } - let article = try? account!.fetchArticles(.articleIDs([articleID])) - guard article != nil else { - logger.debug("No article found from search using: \(articleID, privacy: .public)") - return - } - account!.markArticles(article!, statusKey: .starred, flag: true) { _ in } - } } diff --git a/Mac/Base.lproj/Main.storyboard b/Mac/Base.lproj/Main.storyboard index 90758a355..06967fdb1 100644 --- a/Mac/Base.lproj/Main.storyboard +++ b/Mac/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -18,7 +18,7 @@ - + @@ -539,6 +539,12 @@ + + + + + + diff --git a/Mac/Base.lproj/MainWindow.storyboard b/Mac/Base.lproj/MainWindow.storyboard index 9d501c240..310a1b4c7 100644 --- a/Mac/Base.lproj/MainWindow.storyboard +++ b/Mac/Base.lproj/MainWindow.storyboard @@ -1,8 +1,9 @@ - + - + + @@ -174,9 +175,9 @@ -