From 90088735b16c9676774a493164dc2582e682a50c Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Fri, 3 Jan 2025 21:30:22 -0800 Subject: [PATCH] Bring in changes from release branches. --- Account/Sources/Account/AccountManager.swift | 51 +-- Account/Sources/Account/AccountMetadata.swift | 8 +- .../CloudKit/CloudKitAccountDelegate.swift | 342 +++++++------- .../CloudKit/CloudKitAccountZone.swift | 1 + .../CloudKitAccountZoneDelegate.swift | 4 +- .../Account/CombinedRefreshProgress.swift | 110 ++++- Account/Sources/Account/DataExtensions.swift | 5 +- Account/Sources/Account/Feed.swift | 12 +- .../Account/FeedFinder/FeedFinder.swift | 6 +- Account/Sources/Account/FeedMetadata.swift | 12 +- .../Feedbin/FeedbinAccountDelegate.swift | 9 +- .../Feedly/FeedlyAccountDelegate.swift | 7 +- .../LocalAccount/InitialFeedDownloader.swift | 2 +- .../LocalAccount/LocalAccountDelegate.swift | 52 +-- .../LocalAccount/LocalAccountRefresher.swift | 223 +++++---- .../NewsBlur/NewsBlurAccountDelegate.swift | 5 +- Account/Sources/Account/OPMLNormalizer.swift | 2 + .../ReaderAPI/ReaderAPIAccountDelegate.swift | 13 +- .../Account/SidebarItemIdentifier.swift | 2 +- .../ArticlesDatabase/ArticlesTable.swift | 72 +-- .../Extensions/ArticleStatus+Database.swift | 2 +- Mac/AppAssets.swift | 3 + Mac/AppDefaults.swift | 37 +- Mac/AppDelegate.swift | 9 +- Mac/CrashReporter/CrashReporter.swift | 4 +- .../Detail/DetailContainerView.swift | 6 + .../Detail/DetailViewController.swift | 68 ++- .../Detail/DetailWebViewController.swift | 37 +- Mac/MainWindow/Detail/page.html | 8 - Mac/MainWindow/MainWindowController.swift | 13 +- .../Sidebar/SidebarStatusBarView.swift | 36 +- .../Timeline/Cell/TimelineCellData.swift | 0 .../Timeline/Cell/UnreadIndicatorView.swift | 0 .../PreferencesControlsBackgroundView.swift | 4 +- .../PreferencesTableViewBackgroundView.swift | 4 +- Mac/Resources/NetNewsWire.sdef | 2 +- Mac/SafariExtension/Info.plist | 2 +- Mac/ShareExtension/Info.plist | 2 +- RSWeb/Sources/RSWeb/DownloadProgress.swift | 6 +- RSWeb/Sources/RSWeb/DownloadSession.swift | 422 ++++++++++++------ RSWeb/Sources/RSWeb/HTTPResponseCode.swift | 16 +- RSWeb/Sources/RSWeb/HTTPResponseHeader.swift | 3 + RSWeb/Sources/RSWeb/URL+RSWeb.swift | 29 +- Shared/Activity/ActivityManager.swift | 2 +- Shared/Article Rendering/main.js | 4 + Shared/Article Rendering/stylesheet.css | 92 +++- Shared/Extensions/RSImage-AppIcons.swift | 2 + Shared/Favicons/FaviconDownloader.swift | 83 ++-- Shared/Favicons/SingleFaviconDownloader.swift | 2 +- Shared/IconImageCache.swift | 4 +- Shared/Images/AuthorAvatarDownloader.swift | 7 +- Shared/Images/FeedIconDownloader.swift | 229 +++------- Shared/Images/ImageDownloader.swift | 13 +- Shared/Images/RSHTMLMetadata+Extension.swift | 0 Shared/Importers/DefaultFeeds.opml | 4 + Shared/Timer/AccountRefreshTimer.swift | 6 +- Shared/UniformTypeIdentifiers+Extras.swift | 2 +- Shared/Widget/WidgetData.swift | 9 +- Shared/Widget/WidgetDataEncoder.swift | 81 ++-- .../scripts/testFeedExists.applescript | 2 +- .../scripts/testFeedOPML.applescript | 2 +- .../testNameAndUrlOfEveryFeed.applescript | 2 +- .../scripts/testNameOfAuthors.applescript | 2 +- .../testTitleOfArticlesWhose.applescript | 2 +- Widget/Resources/Localizable.stringsdict | 4 +- Widget/Resources/en.lproj/Localizable.strings | 12 +- Widget/Shared Views/ArticleItemView.swift | 41 +- Widget/Widget Views/StarredWidget.swift | 57 +-- Widget/Widget Views/TodayWidget.swift | 56 +-- Widget/Widget Views/UnreadWidget.swift | 49 +- Widget/WidgetBundle.swift | 41 +- iOS/Add/Add.storyboard | 4 +- iOS/MainFeed/MainFeedViewController.swift | 4 +- .../MainTimelineViewController.swift | 6 +- iOS/SceneCoordinator.swift | 23 +- iOS/SceneDelegate.swift | 2 +- ...ewsWire_iOSintentextension_target.xcconfig | 1 + ...NewsWire_iOSshareextension_target.xcconfig | 1 + ...ewsWire_iOSwidgetextension_target.xcconfig | 1 + xcconfig/NetNewsWire_project_release.xcconfig | 1 + ...etNewsWire_safariextension_target.xcconfig | 1 + ...NetNewsWire_shareextension_target.xcconfig | 1 + .../NetNewsWire_ios_target_common.xcconfig | 4 +- 83 files changed, 1401 insertions(+), 1109 deletions(-) create mode 100644 Mac/MainWindow/Timeline/Cell/TimelineCellData.swift create mode 100644 Mac/MainWindow/Timeline/Cell/UnreadIndicatorView.swift create mode 100644 Shared/Images/RSHTMLMetadata+Extension.swift diff --git a/Account/Sources/Account/AccountManager.swift b/Account/Sources/Account/AccountManager.swift index 7e9355001..fa7a76673 100644 --- a/Account/Sources/Account/AccountManager.swift +++ b/Account/Sources/Account/AccountManager.swift @@ -88,12 +88,9 @@ public final class AccountManager: UnreadCountProvider { } return false } - - public var combinedRefreshProgress: CombinedRefreshProgress { - let downloadProgressArray = activeAccounts.map { $0.refreshProgress } - return CombinedRefreshProgress(downloadProgressArray: downloadProgressArray) - } - + + public let combinedRefreshProgress = CombinedRefreshProgress() + public init(accountsFolder: String) { self.accountsFolder = accountsFolder @@ -251,8 +248,13 @@ public final class AccountManager: UnreadCountProvider { } } - public func refreshAll(errorHandler: @escaping (Error) -> Void, completion: (() -> Void)? = nil) { - guard let reachability = try? Reachability(hostname: "apple.com"), reachability.connection != .unavailable else { return } + public func refreshAll(errorHandler: ((Error) -> Void)? = nil, completion: (() -> Void)? = nil) { + + guard let reachability = try? Reachability(hostname: "apple.com"), reachability.connection != .unavailable else { + return + } + + combinedRefreshProgress.start() let group = DispatchGroup() @@ -264,43 +266,16 @@ public final class AccountManager: UnreadCountProvider { case .success: break case .failure(let error): - errorHandler(error) + errorHandler?(error) } } } group.notify(queue: DispatchQueue.main) { + self.combinedRefreshProgress.stop() completion?() } } - - public func refreshAll(completion: (() -> Void)? = nil) { - guard let reachability = try? Reachability(hostname: "apple.com"), reachability.connection != .unavailable else { return } - - var syncErrors = [AccountSyncError]() - let group = DispatchGroup() - - for account in activeAccounts { - group.enter() - account.refreshAll() { result in - group.leave() - switch result { - case .success: - break - case .failure(let error): - syncErrors.append(AccountSyncError(account: account, error: error)) - } - } - } - - group.notify(queue: DispatchQueue.main) { - if syncErrors.count > 0 { - NotificationCenter.default.post(Notification(name: .AccountsDidFailToSyncWithErrors, object: self, userInfo: [Account.UserInfoKey.syncErrors: syncErrors])) - } - completion?() - } - - } public func sendArticleStatusAll(completion: (() -> Void)? = nil) { let group = DispatchGroup() @@ -374,7 +349,7 @@ public final class AccountManager: UnreadCountProvider { } return articles } - + public func fetchArticlesAsync(_ fetchType: FetchType, _ completion: @escaping ArticleSetResultBlock) { precondition(Thread.isMainThread) diff --git a/Account/Sources/Account/AccountMetadata.swift b/Account/Sources/Account/AccountMetadata.swift index 4fa08063a..8d9ba4fc1 100644 --- a/Account/Sources/Account/AccountMetadata.swift +++ b/Account/Sources/Account/AccountMetadata.swift @@ -83,13 +83,7 @@ final class AccountMetadata: Codable { } } - var performedApril2020RetentionPolicyChange: Bool? { - didSet { - if performedApril2020RetentionPolicyChange != oldValue { - valueDidChange(.performedApril2020RetentionPolicyChange) - } - } - } + var performedApril2020RetentionPolicyChange: Bool? // No longer used. var externalID: String? { didSet { diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift index 476448bc9..90249dd96 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift @@ -21,7 +21,7 @@ import Secrets enum CloudKitAccountDelegateError: LocalizedError { case invalidParameter case unknown - + var errorDescription: String? { return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.") } @@ -32,42 +32,46 @@ final class CloudKitAccountDelegate: AccountDelegate { private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") private let database: SyncDatabase - + private let container: CKContainer = { let orgID = Bundle.main.object(forInfoDictionaryKey: "OrganizationIdentifier") as! String return CKContainer(identifier: "iCloud.\(orgID).NetNewsWire") }() - + private let accountZone: CloudKitAccountZone private let articlesZone: CloudKitArticlesZone - - private let mainThreadOperationQueue = MainThreadOperationQueue() - private lazy var refresher: LocalAccountRefresher = { - let refresher = LocalAccountRefresher() - refresher.delegate = self - return refresher - }() + private let mainThreadOperationQueue = MainThreadOperationQueue() + private let refresher: LocalAccountRefresher weak var account: Account? - + let behaviors: AccountBehaviors = [] let isOPMLImportInProgress = false - + let server: String? = nil var credentials: Credentials? var accountMetadata: AccountMetadata? - var refreshProgress = DownloadProgress(numberOfTasks: 0) - + /// refreshProgress is combined sync progress and feed download progress. + let refreshProgress = DownloadProgress(numberOfTasks: 0) + private let syncProgress = DownloadProgress(numberOfTasks: 0) + init(dataFolder: String) { - accountZone = CloudKitAccountZone(container: container) - articlesZone = CloudKitArticlesZone(container: container) - + + self.accountZone = CloudKitAccountZone(container: container) + self.articlesZone = CloudKitArticlesZone(container: container) + let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") - database = SyncDatabase(databaseFilePath: databaseFilePath) + self.database = SyncDatabase(databaseFilePath: databaseFilePath) + + self.refresher = LocalAccountRefresher() + self.refresher.delegate = self + + NotificationCenter.default.addObserver(self, selector: #selector(downloadProgressDidChange(_:)), name: .DownloadProgressDidChange, object: refresher.downloadProgress) + NotificationCenter.default.addObserver(self, selector: #selector(syncProgressDidChange(_:)), name: .DownloadProgressDidChange, object: syncProgress) } - + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { let op = CloudKitRemoteNotificationOperation(accountZone: accountZone, articlesZone: articlesZone, userInfo: userInfo) op.completionBlock = { mainThreadOperaion in @@ -75,20 +79,23 @@ final class CloudKitAccountDelegate: AccountDelegate { } mainThreadOperationQueue.add(op) } - + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + guard refreshProgress.isComplete else { completion(.success(())) return } + syncProgress.reset() + 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) } @@ -109,11 +116,11 @@ final class CloudKitAccountDelegate: AccountDelegate { } } } - + 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 @@ -125,27 +132,28 @@ final class CloudKitAccountDelegate: AccountDelegate { } mainThreadOperationQueue.add(op) } - + func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { + guard refreshProgress.isComplete else { completion(.success(())) return } var fileData: Data? - + do { fileData = try Data(contentsOf: opmlFile) } catch { completion(.failure(error)) return } - + guard let opmlData = fileData else { completion(.success(())) return } - + let parserData = ParserData(url: opmlFile.absoluteString, data: opmlData) let opmlDocument = OPMLParser.document(with: parserData) @@ -160,20 +168,19 @@ final class CloudKitAccountDelegate: AccountDelegate { let normalizedItems = OPMLNormalizer.normalize(opmlItems) - refreshProgress.addToNumberOfTasksAndRemaining(1) + syncProgress.addToNumberOfTasksAndRemaining(1) self.accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems) { _ in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() self.standardRefreshAll(for: account, completion: completion) } - } - + func createFeed(for account: Account, url urlString: String, name: String?, container: Container, validateFeed: Bool, completion: @escaping (Result) -> Void) { guard let url = URL(string: urlString) else { completion(.failure(LocalAccountDelegateError.invalidParameter)) return } - + let editedName = name == nil || name!.isEmpty ? nil : name createRSSFeed(for: account, url: url, editedName: editedName, container: container, validateFeed: validateFeed, completion: completion) @@ -181,9 +188,9 @@ final class CloudKitAccountDelegate: AccountDelegate { func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { let editedName = name.isEmpty ? nil : name - refreshProgress.addToNumberOfTasksAndRemaining(1) + syncProgress.addToNumberOfTasksAndRemaining(1) accountZone.renameFeed(feed, editedName: editedName) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success: feed.editedName = name @@ -214,11 +221,11 @@ final class CloudKitAccountDelegate: AccountDelegate { } } } - + func moveFeed(for account: Account, with feed: Feed, from fromContainer: Container, to toContainer: Container, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(1) + syncProgress.addToNumberOfTasksAndRemaining(1) accountZone.moveFeed(feed, from: fromContainer, to: toContainer) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success: fromContainer.removeFeed(feed) @@ -230,11 +237,11 @@ final class CloudKitAccountDelegate: AccountDelegate { } } } - + func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(1) + syncProgress.addToNumberOfTasksAndRemaining(1) accountZone.addFeed(feed, to: container) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success: container.addFeed(feed) @@ -245,7 +252,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } } } - + func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result) -> Void) { createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) { result in switch result { @@ -256,11 +263,11 @@ final class CloudKitAccountDelegate: AccountDelegate { } } } - + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(1) + syncProgress.addToNumberOfTasksAndRemaining(1) accountZone.createFolder(name: name) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success(let externalID): if let folder = account.ensureFolder(with: name) { @@ -275,11 +282,11 @@ final class CloudKitAccountDelegate: AccountDelegate { } } } - + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(1) + syncProgress.addToNumberOfTasksAndRemaining(1) accountZone.renameFolder(folder, to: name) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success: folder.name = name @@ -290,19 +297,19 @@ final class CloudKitAccountDelegate: AccountDelegate { } } } - + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(2) + syncProgress.addToNumberOfTasksAndRemaining(2) accountZone.findFeedExternalIDs(for: folder) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success(let feedExternalIDs): let feeds = feedExternalIDs.compactMap { account.existingFeed(withExternalID: $0) } let group = DispatchGroup() var errorOccurred = false - + for feed in feeds { group.enter() self.removeFeedFromCloud(for: account, with: feed, from: folder) { result in @@ -313,17 +320,17 @@ final class CloudKitAccountDelegate: AccountDelegate { } } } - + group.notify(queue: DispatchQueue.global(qos: .background)) { DispatchQueue.main.async { guard !errorOccurred else { - self.refreshProgress.completeTask() + self.syncProgress.completeTask() completion(.failure(CloudKitAccountDelegateError.unknown)) return } - + self.accountZone.removeFolder(folder) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success: account.removeFolder(folder) @@ -334,41 +341,41 @@ final class CloudKitAccountDelegate: AccountDelegate { } } } - + case .failure(let error): - self.refreshProgress.completeTask() - self.refreshProgress.completeTask() + self.syncProgress.completeTask() + self.syncProgress.completeTask() self.processAccountError(account, error) completion(.failure(error)) } } - + } - + func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { guard let name = folder.name else { completion(.failure(LocalAccountDelegateError.invalidParameter)) return } - + let feedsToRestore = folder.topLevelFeeds - refreshProgress.addToNumberOfTasksAndRemaining(1 + feedsToRestore.count) - + syncProgress.addToNumberOfTasksAndRemaining(1 + feedsToRestore.count) + accountZone.createFolder(name: name) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success(let externalID): folder.externalID = externalID account.addFolder(folder) - + let group = DispatchGroup() for feed in feedsToRestore { - + folder.topLevelFeeds.remove(feed) group.enter() self.restoreFeed(for: account, feed: feed, container: folder) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() group.leave() switch result { case .success: @@ -377,14 +384,14 @@ final class CloudKitAccountDelegate: AccountDelegate { os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) } } - + } - + group.notify(queue: DispatchQueue.main) { account.addFolder(folder) completion(.success(())) } - + case .failure(let error): self.processAccountError(account, error) completion(.failure(error)) @@ -416,12 +423,12 @@ final class CloudKitAccountDelegate: AccountDelegate { func accountDidInitialize(_ account: Account) { self.account = account - - accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress, articlesZone: articlesZone) + + accountZone.delegate = CloudKitAcountZoneDelegate(account: account, articlesZone: articlesZone) articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone) - + database.resetAllSelectedForProcessing() - + // Check to see if this is a new account and initialize anything we need if account.externalID == nil { accountZone.findOrCreateAccount() { result in @@ -436,9 +443,9 @@ final class CloudKitAccountDelegate: AccountDelegate { accountZone.subscribeToZoneChanges() articlesZone.subscribeToZoneChanges() } - + } - + func accountWillBeDeleted(_ account: Account) { accountZone.resetChangeToken() articlesZone.resetChangeToken() @@ -448,7 +455,7 @@ final class CloudKitAccountDelegate: AccountDelegate { return completion(.success(nil)) } - // MARK: Suspend and Resume (for iOS) + // MARK: - Suspend and Resume (for iOS) func suspendNetwork() { refresher.suspend() @@ -457,45 +464,68 @@ final class CloudKitAccountDelegate: AccountDelegate { func suspendDatabase() { database.suspend() } - + func resume() { refresher.resume() database.resume() } } +// MARK: - Refresh Progress + private extension CloudKitAccountDelegate { - + + func updateRefreshProgress() { + + refreshProgress.numberOfTasks = refresher.downloadProgress.numberOfTasks + syncProgress.numberOfTasks + refreshProgress.numberRemaining = refresher.downloadProgress.numberRemaining + syncProgress.numberRemaining + + // Complete? + if refreshProgress.numberOfTasks > 0 && refreshProgress.numberRemaining < 1 { + refresher.downloadProgress.numberOfTasks = 0 + syncProgress.numberOfTasks = 0 + } + } + + @objc func downloadProgressDidChange(_ note: Notification) { + + updateRefreshProgress() + } + + @objc func syncProgressDidChange(_ note: Notification) { + + updateRefreshProgress() + } +} + +// MARK: - Private + +private extension CloudKitAccountDelegate { + func initialRefreshAll(for account: Account, completion: @escaping (Result) -> Void) { - + func fail(_ error: Error) { self.processAccountError(account, error) - self.refreshProgress.clear() + self.syncProgress.reset() completion(.failure(error)) } - - refreshProgress.addToNumberOfTasksAndRemaining(3) + + syncProgress.addToNumberOfTasksAndRemaining(3) accountZone.fetchChangesInZone() { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() let feeds = account.flattenedFeeds() - self.refreshProgress.addToNumberOfTasksAndRemaining(feeds.count) switch result { case .success: self.refreshArticleStatus(for: account) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() 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) - } + + self.combinedRefresh(account, feeds) { + self.syncProgress.reset() + account.metadata.lastArticleFetchEndTime = Date() } case .failure(let error): @@ -506,41 +536,34 @@ private extension CloudKitAccountDelegate { fail(error) } } - } func standardRefreshAll(for account: Account, completion: @escaping (Result) -> Void) { - let intialFeedsCount = account.flattenedFeeds().count - refreshProgress.addToNumberOfTasksAndRemaining(3 + intialFeedsCount) + syncProgress.addToNumberOfTasksAndRemaining(3) func fail(_ error: Error) { self.processAccountError(account, error) - self.refreshProgress.clear() + self.syncProgress.reset() completion(.failure(error)) } - + accountZone.fetchChangesInZone() { result in switch result { case .success: - self.refreshProgress.completeTask() + self.syncProgress.completeTask() let feeds = account.flattenedFeeds() - self.refreshProgress.addToNumberOfTasksAndRemaining(feeds.count - intialFeedsCount) - + self.refreshArticleStatus(for: account) { result in switch result { case .success: - self.refreshProgress.completeTask() - self.combinedRefresh(account, feeds) { result in + self.syncProgress.completeTask() + self.combinedRefresh(account, feeds) { self.sendArticleStatus(for: account, showProgress: true) { _ in - self.refreshProgress.clear() - if case .failure(let error) = result { - fail(error) - } else { - account.metadata.lastArticleFetchEndTime = Date() - completion(.success(())) - } + self.syncProgress.reset() + account.metadata.lastArticleFetchEndTime = Date() + completion(.success(())) } } case .failure(let error): @@ -552,23 +575,13 @@ private extension CloudKitAccountDelegate { fail(error) } } - } - func combinedRefresh(_ account: Account, _ feeds: Set, completion: @escaping (Result) -> Void) { - - let group = DispatchGroup() + func combinedRefresh(_ account: Account, _ feeds: Set, completion: @escaping () -> Void) { - group.enter() - refresher.refreshFeeds(feeds) { - group.leave() - } - - group.notify(queue: DispatchQueue.main) { - completion(.success(())) - } + refresher.refreshFeeds(feeds, completion: completion) } - + func createRSSFeed(for account: Account, url: URL, editedName: String?, container: Container, validateFeed: Bool, completion: @escaping (Result) -> Void) { func addDeadFeed() { @@ -581,7 +594,7 @@ private extension CloudKitAccountDelegate { homePageURL: nil, container: container) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success(let externalID): feed.externalID = externalID @@ -593,48 +606,48 @@ private extension CloudKitAccountDelegate { } } - refreshProgress.addToNumberOfTasksAndRemaining(5) + syncProgress.addToNumberOfTasksAndRemaining(5) FeedFinder.find(url: url) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success(let feedSpecifiers): guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { - self.refreshProgress.completeTasks(3) + self.syncProgress.completeTasks(3) if validateFeed { - self.refreshProgress.completeTask() + self.syncProgress.completeTask() completion(.failure(AccountError.createErrorNotFound)) } else { addDeadFeed() } return } - + if account.hasFeed(withURL: bestFeedSpecifier.urlString) { - self.refreshProgress.completeTasks(4) + self.syncProgress.completeTasks(4) completion(.failure(AccountError.createErrorAlreadySubscribed)) return } - + let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil) feed.editedName = editedName container.addFeed(feed) InitialFeedDownloader.download(url) { parsedFeed in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() if let parsedFeed = parsedFeed { account.update(feed, with: parsedFeed) { result in switch result { case .success: - + self.accountZone.createFeed(url: bestFeedSpecifier.urlString, name: parsedFeed.title, editedName: editedName, homePageURL: parsedFeed.homePageURL, container: container) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success(let externalID): feed.externalID = externalID @@ -642,31 +655,30 @@ private extension CloudKitAccountDelegate { completion(.success(feed)) case .failure(let error): container.removeFeed(feed) - self.refreshProgress.completeTasks(2) + self.syncProgress.completeTasks(2) completion(.failure(error)) } - } case .failure(let error): container.removeFeed(feed) - self.refreshProgress.completeTasks(3) + self.syncProgress.completeTasks(3) completion(.failure(error)) } - + } } else { - self.refreshProgress.completeTasks(3) + self.syncProgress.completeTasks(3) container.removeFeed(feed) completion(.failure(AccountError.createErrorNotFound)) } - + } - + case .failure: - self.refreshProgress.completeTasks(3) + self.syncProgress.completeTasks(3) if validateFeed { - self.refreshProgress.completeTask() + self.syncProgress.completeTask() completion(.failure(AccountError.createErrorNotFound)) return } else { @@ -681,7 +693,7 @@ private extension CloudKitAccountDelegate { switch result { case .success(let articles): self.storeArticleChanges(new: articles, updated: Set
(), deleted: Set
()) { - self.refreshProgress.completeTask() + self.syncProgress.completeTask() self.sendArticleStatus(for: account, showProgress: true) { result in switch result { case .success: @@ -696,7 +708,7 @@ private extension CloudKitAccountDelegate { } } } - + func processAccountError(_ account: Account, _ error: Error) { if case CloudKitZoneError.userDeletedZone = error { account.removeFeeds(account.topLevelFeeds) @@ -705,8 +717,8 @@ private extension CloudKitAccountDelegate { } } } - - func storeArticleChanges(new: Set
?, updated: Set
?, deleted: Set
?, completion: @escaping () -> Void) { + + func storeArticleChanges(new: Set
?, updated: Set
?, deleted: Set
?, completion: (() -> Void)?) { // New records with a read status aren't really new, they just didn't have the read article stored let group = DispatchGroup() if let new = new { @@ -721,19 +733,19 @@ private extension CloudKitAccountDelegate { insertSyncStatuses(articles: updated, statusKey: .new, flag: false) { group.leave() } - + group.enter() insertSyncStatuses(articles: deleted, statusKey: .deleted, flag: true) { group.leave() } - + group.notify(queue: DispatchQueue.global(qos: .userInitiated)) { DispatchQueue.main.async { - completion() + completion?() } } } - + func insertSyncStatuses(articles: Set
?, statusKey: SyncStatus.Key, flag: Bool, completion: @escaping () -> Void) { guard let articles = articles, !articles.isEmpty else { completion() @@ -762,12 +774,12 @@ private extension CloudKitAccountDelegate { } mainThreadOperationQueue.add(op) } - + func removeFeedFromCloud(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(2) + syncProgress.addToNumberOfTasksAndRemaining(2) accountZone.removeFeed(feed, from: container) { result in - self.refreshProgress.completeTask() + self.syncProgress.completeTask() switch result { case .success: guard let feedExternalID = feed.externalID else { @@ -776,31 +788,25 @@ private extension CloudKitAccountDelegate { } self.articlesZone.deleteArticles(feedExternalID) { result in feed.dropConditionalGetInfo() - self.refreshProgress.completeTask() + self.syncProgress.completeTask() completion(result) } case .failure(let error): - self.refreshProgress.completeTask() + self.syncProgress.completeTask() self.processAccountError(account, error) completion(.failure(error)) } } } - + } extension CloudKitAccountDelegate: LocalAccountRefresherDelegate { - - func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: Feed) { - refreshProgress.completeTask() - } - - func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges, completion: @escaping () -> Void) { + + func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges) { self.storeArticleChanges(new: articleChanges.newArticles, updated: articleChanges.updatedArticles, deleted: articleChanges.deletedArticles, - completion: completion) + completion: nil) } - } - diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift index 02deb6d51..1f7d4729e 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift @@ -312,6 +312,7 @@ final class CloudKitAccountZone: CloudKitZone { } } } + } func createFolder(name: String, completion: @escaping (Result) -> Void) { diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift index b5fc40ea8..06deeb7e9 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -22,12 +22,10 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") weak var account: Account? - weak var refreshProgress: DownloadProgress? weak var articlesZone: CloudKitArticlesZone? - init(account: Account, refreshProgress: DownloadProgress, articlesZone: CloudKitArticlesZone) { + init(account: Account, articlesZone: CloudKitArticlesZone) { self.account = account - self.refreshProgress = refreshProgress self.articlesZone = articlesZone } diff --git a/Account/Sources/Account/CombinedRefreshProgress.swift b/Account/Sources/Account/CombinedRefreshProgress.swift index 803fb9e1c..91eaed1c5 100644 --- a/Account/Sources/Account/CombinedRefreshProgress.swift +++ b/Account/Sources/Account/CombinedRefreshProgress.swift @@ -9,34 +9,102 @@ import Foundation import RSWeb -// Combines the refresh progress of multiple accounts into one struct, -// for use by refresh status view and so on. +extension Notification.Name { + public static let combinedRefreshProgressDidChange = Notification.Name("combinedRefreshProgressDidChange") +} -public struct CombinedRefreshProgress { +/// Combine the refresh progress of multiple accounts into one place, +/// for use by refresh status view and so on. +public final class CombinedRefreshProgress { - public let numberOfTasks: Int - public let numberRemaining: Int - public let numberCompleted: Int - public let isComplete: Bool + public private(set) var numberOfTasks = 0 + public private(set) var numberRemaining = 0 + public private(set) var numberCompleted = 0 - init(numberOfTasks: Int, numberRemaining: Int, numberCompleted: Int) { - self.numberOfTasks = max(numberOfTasks, 0) - self.numberRemaining = max(numberRemaining, 0) - self.numberCompleted = max(numberCompleted, 0) - self.isComplete = numberRemaining < 1 + public var isComplete: Bool { + !isStarted || numberRemaining < 1 } - public init(downloadProgressArray: [DownloadProgress]) { - var numberOfTasks = 0 - var numberRemaining = 0 - var numberCompleted = 0 + var isStarted = false - for downloadProgress in downloadProgressArray { - numberOfTasks += downloadProgress.numberOfTasks - numberRemaining += downloadProgress.numberRemaining - numberCompleted += downloadProgress.numberCompleted + init() { + + NotificationCenter.default.addObserver(self, selector: #selector(refreshProgressDidChange(_:)), name: .DownloadProgressDidChange, object: nil) + } + + func start() { + reset() + isStarted = true + } + + func stop() { + reset() + isStarted = false + } + + @objc func refreshProgressDidChange(_ notification: Notification) { + + guard isStarted else { + return + } + + var updatedNumberOfTasks = 0 + var updatedNumberRemaining = 0 + var updatedNumberCompleted = 0 + + var didMakeChange = false + + let downloadProgresses = AccountManager.shared.activeAccounts.map { $0.refreshProgress } + for downloadProgress in downloadProgresses { + updatedNumberOfTasks += downloadProgress.numberOfTasks + updatedNumberRemaining += downloadProgress.numberRemaining + updatedNumberCompleted += downloadProgress.numberCompleted } - self.init(numberOfTasks: numberOfTasks, numberRemaining: numberRemaining, numberCompleted: numberCompleted) + if updatedNumberOfTasks > numberOfTasks { + numberOfTasks = updatedNumberOfTasks + didMakeChange = true + } + + assert(updatedNumberRemaining <= numberOfTasks) + updatedNumberRemaining = max(updatedNumberRemaining, numberRemaining) + updatedNumberRemaining = min(updatedNumberRemaining, numberOfTasks) + if updatedNumberRemaining != numberRemaining { + numberRemaining = updatedNumberRemaining + didMakeChange = true + } + + assert(updatedNumberCompleted <= numberOfTasks) + updatedNumberCompleted = max(updatedNumberCompleted, numberCompleted) + updatedNumberCompleted = min(updatedNumberCompleted, numberOfTasks) + if updatedNumberCompleted != numberCompleted { + numberCompleted = updatedNumberCompleted + didMakeChange = true + } + + if didMakeChange { + postDidChangeNotification() + } + } +} + +private extension CombinedRefreshProgress { + + func reset() { + + let didMakeChange = numberOfTasks != 0 || numberRemaining != 0 || numberCompleted != 0 + + numberOfTasks = 0 + numberRemaining = 0 + numberCompleted = 0 + + if didMakeChange { + postDidChangeNotification() + } + } + + func postDidChangeNotification() { + + NotificationCenter.default.post(name: .combinedRefreshProgressDidChange, object: self) } } diff --git a/Account/Sources/Account/DataExtensions.swift b/Account/Sources/Account/DataExtensions.swift index 0c505268b..f99dd5a03 100644 --- a/Account/Sources/Account/DataExtensions.swift +++ b/Account/Sources/Account/DataExtensions.swift @@ -11,7 +11,7 @@ import Articles import Parser public extension Notification.Name { - static let FeedSettingDidChange = Notification.Name(rawValue: "FeedSettingDidChangeNotification") + static let feedSettingDidChange = Notification.Name(rawValue: "FeedSettingDidChangeNotification") } public extension Feed { @@ -27,6 +27,7 @@ public extension Feed { public static let authors = "authors" public static let contentHash = "contentHash" public static let conditionalGetInfo = "conditionalGetInfo" + public static let cacheControlInfo = "cacheControlInfo" } } @@ -42,7 +43,7 @@ extension Feed { func postFeedSettingDidChangeNotification(_ codingKey: FeedMetadata.CodingKeys) { let userInfo = [Feed.FeedSettingUserInfoKey: codingKey.stringValue] - NotificationCenter.default.post(name: .FeedSettingDidChange, object: self, userInfo: userInfo) + NotificationCenter.default.post(name: .feedSettingDidChange, object: self, userInfo: userInfo) } } diff --git a/Account/Sources/Account/Feed.swift b/Account/Sources/Account/Feed.swift index dbdc5f734..6ee693931 100644 --- a/Account/Sources/Account/Feed.swift +++ b/Account/Sources/Account/Feed.swift @@ -132,6 +132,15 @@ public final class Feed: SidebarItem, Renamable, Hashable { } } + public var cacheControlInfo: CacheControlInfo? { + get { + metadata.cacheControlInfo + } + set { + metadata.cacheControlInfo = newValue + } + } + public var contentHash: String? { get { return metadata.contentHash @@ -254,11 +263,10 @@ public final class Feed: SidebarItem, Renamable, Hashable { } // MARK: - API - + public func dropConditionalGetInfo() { conditionalGetInfo = nil contentHash = nil - sinceToken = nil } // MARK: - Hashable diff --git a/Account/Sources/Account/FeedFinder/FeedFinder.swift b/Account/Sources/Account/FeedFinder/FeedFinder.swift index 9975e2f94..2d864ec32 100644 --- a/Account/Sources/Account/FeedFinder/FeedFinder.swift +++ b/Account/Sources/Account/FeedFinder/FeedFinder.swift @@ -14,8 +14,8 @@ import RSCore class FeedFinder { static func find(url: URL, completion: @escaping (Result, Error>) -> Void) { - downloadAddingToCache(url) { (data, response, error) in - + Downloader.shared.download(url) { (data, response, error) in + if response?.forcedStatusCode == 404 { if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), urlComponents.host == "micro.blog" { urlComponents.path = "\(urlComponents.path).json" @@ -147,7 +147,7 @@ private extension FeedFinder { } group.enter() - downloadUsingCache(url) { (data, response, error) in + Downloader.shared.download(url) { (data, response, error) in if let data = data, let response = response, response.statusIsOK, error == nil { if self.isFeed(data) { addFeedSpecifier(downloadFeedSpecifier, feedSpecifiers: &resultFeedSpecifiers) diff --git a/Account/Sources/Account/FeedMetadata.swift b/Account/Sources/Account/FeedMetadata.swift index cae197b69..cfd3a7034 100644 --- a/Account/Sources/Account/FeedMetadata.swift +++ b/Account/Sources/Account/FeedMetadata.swift @@ -27,7 +27,7 @@ final class FeedMetadata: Codable { case isNotifyAboutNewArticles case isArticleExtractorAlwaysOn case conditionalGetInfo - case sinceToken + case cacheControlInfo case externalID = "subscriptionID" case folderRelationship } @@ -111,15 +111,15 @@ final class FeedMetadata: Codable { } } } - - var sinceToken: String? { + + var cacheControlInfo: CacheControlInfo? { didSet { - if externalID != oldValue { - valueDidChange(.externalID) + if cacheControlInfo != oldValue { + valueDidChange(.cacheControlInfo) } } } - + var externalID: String? { didSet { if externalID != oldValue { diff --git a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift index 0a867aa0c..bc6fdea56 100644 --- a/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift @@ -81,7 +81,8 @@ final class FeedbinAccountDelegate: AccountDelegate { } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { - + + refreshProgress.reset() refreshProgress.addToNumberOfTasksAndRemaining(5) refreshAccount(account) { result in @@ -94,7 +95,7 @@ final class FeedbinAccountDelegate: AccountDelegate { completion(.success(())) case .failure(let error): DispatchQueue.main.async { - self.refreshProgress.clear() + self.refreshProgress.reset() let wrappedError = AccountError.wrappedError(error: error, account: account) completion(.failure(wrappedError)) } @@ -103,7 +104,7 @@ final class FeedbinAccountDelegate: AccountDelegate { case .failure(let error): DispatchQueue.main.async { - self.refreshProgress.clear() + self.refreshProgress.reset() let wrappedError = AccountError.wrappedError(error: error, account: account) completion(.failure(wrappedError)) } @@ -720,7 +721,7 @@ private extension FeedbinAccountDelegate { case .success: DispatchQueue.main.async { - self.refreshProgress.clear() + self.refreshProgress.reset() completion(.success(())) } diff --git a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift index 0d1e601c5..a46176d1a 100644 --- a/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift @@ -124,10 +124,12 @@ final class FeedlyAccountDelegate: AccountDelegate { return } + refreshProgress.reset() + let log = self.log - + let syncAllOperation = FeedlySyncAllOperation(account: account, feedlyUserId: credentials.username, caller: caller, database: database, lastSuccessfulFetchStartDate: accountMetadata?.lastArticleFetchStartTime, downloadProgress: refreshProgress, log: log) - + syncAllOperation.downloadProgress = refreshProgress let date = Date() @@ -139,6 +141,7 @@ final class FeedlyAccountDelegate: AccountDelegate { os_log(.debug, log: log, "Sync took %{public}.3f seconds", -date.timeIntervalSinceNow) completion(result) + self?.refreshProgress.reset() } currentSyncAllOperation = syncAllOperation diff --git a/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift b/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift index 8850ca8ff..6cbc5de9a 100644 --- a/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift +++ b/Account/Sources/Account/LocalAccount/InitialFeedDownloader.swift @@ -14,7 +14,7 @@ struct InitialFeedDownloader { static func download(_ url: URL,_ completion: @escaping (_ parsedFeed: ParsedFeed?) -> Void) { - downloadUsingCache(url) { (data, response, error) in + Downloader.shared.download(url) { (data, response, error) in guard let data = data else { completion(nil) return diff --git a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift index aebf6dd5f..523706f4d 100644 --- a/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Account/Sources/Account/LocalAccount/LocalAccountDelegate.swift @@ -25,12 +25,10 @@ final class LocalAccountDelegate: AccountDelegate { weak var account: Account? - private lazy var refresher: LocalAccountRefresher? = { - let refresher = LocalAccountRefresher() - refresher.delegate = self - return refresher + lazy var refreshProgress: DownloadProgress = { + refresher.downloadProgress }() - + let behaviors: AccountBehaviors = [] let isOPMLImportInProgress = false @@ -38,8 +36,12 @@ final class LocalAccountDelegate: AccountDelegate { var credentials: Credentials? var accountMetadata: AccountMetadata? - let refreshProgress = DownloadProgress(numberOfTasks: 0) - + private lazy var refresher: LocalAccountRefresher = { + let refresher = LocalAccountRefresher() + refresher.delegate = self + return refresher + }() + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { completion() } @@ -51,21 +53,18 @@ final class LocalAccountDelegate: AccountDelegate { } let feeds = account.flattenedFeeds() - refreshProgress.addToNumberOfTasksAndRemaining(feeds.count) let group = DispatchGroup() group.enter() - refresher?.refreshFeeds(feeds) { + refresher.refreshFeeds(feeds) { group.leave() } group.notify(queue: DispatchQueue.main) { - self.refreshProgress.clear() account.metadata.lastArticleFetchEndTime = Date() completion(.success(())) } - } func syncArticleStatus(for account: Account, completion: ((Result) -> Void)? = nil) { @@ -79,17 +78,17 @@ final class LocalAccountDelegate: AccountDelegate { func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { completion(.success(())) } - + func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { var fileData: Data? - + do { fileData = try Data(contentsOf: opmlFile) } catch { completion(.failure(error)) return } - + guard let opmlData = fileData else { completion(.success(())) return @@ -111,7 +110,6 @@ final class LocalAccountDelegate: AccountDelegate { } completion(.success(())) - } func createFeed(for account: Account, url urlString: String, name: String?, container: Container, validateFeed: Bool, completion: @escaping (Result) -> Void) { @@ -196,7 +194,7 @@ final class LocalAccountDelegate: AccountDelegate { // MARK: Suspend and Resume (for iOS) func suspendNetwork() { - refresher?.suspend() + refresher.suspend() } func suspendDatabase() { @@ -204,21 +202,14 @@ final class LocalAccountDelegate: AccountDelegate { } func resume() { - refresher?.resume() + refresher.resume() } } extension LocalAccountDelegate: LocalAccountRefresherDelegate { - - - func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: Feed) { - refreshProgress.completeTask() + + func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges) { } - - func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges, completion: @escaping () -> Void) { - completion() - } - } private extension LocalAccountDelegate { @@ -229,28 +220,24 @@ private extension LocalAccountDelegate { // container before the name has been downloaded. This will put it in the sidebar // with an Untitled name if we don't delay it being added to the sidebar. BatchUpdate.shared.start() - refreshProgress.addToNumberOfTasksAndRemaining(1) FeedFinder.find(url: url) { result in switch result { case .success(let feedSpecifiers): guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { - self.refreshProgress.completeTask() BatchUpdate.shared.end() completion(.failure(AccountError.createErrorNotFound)) return } if account.hasFeed(withURL: bestFeedSpecifier.urlString) { - self.refreshProgress.completeTask() BatchUpdate.shared.end() completion(.failure(AccountError.createErrorAlreadySubscribed)) return } InitialFeedDownloader.download(url) { parsedFeed in - self.refreshProgress.completeTask() if let parsedFeed = parsedFeed { let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil) @@ -265,17 +252,12 @@ private extension LocalAccountDelegate { BatchUpdate.shared.end() completion(.failure(AccountError.createErrorNotFound)) } - } case .failure: BatchUpdate.shared.end() - self.refreshProgress.completeTask() completion(.failure(AccountError.createErrorNotFound)) } - } - } - } diff --git a/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift b/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift index c8ca9dbdf..fa51932a6 100644 --- a/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift +++ b/Account/Sources/Account/LocalAccount/LocalAccountRefresher.swift @@ -12,152 +12,217 @@ import Parser import RSWeb import Articles import ArticlesDatabase +import os protocol LocalAccountRefresherDelegate { - func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: Feed) - func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges, completion: @escaping () -> Void) + func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges) } final class LocalAccountRefresher { - + + var delegate: LocalAccountRefresherDelegate? + var downloadProgress: DownloadProgress { + downloadSession.downloadProgress + } + private var completion: (() -> Void)? = nil private var isSuspended = false - var delegate: LocalAccountRefresherDelegate? - + private lazy var downloadSession: DownloadSession = { return DownloadSession(delegate: self) }() + private var urlToFeedDictionary = [String: Feed]() + public func refreshFeeds(_ feeds: Set, completion: (() -> Void)? = nil) { - guard !feeds.isEmpty else { - completion?() + + let filteredFeeds = feeds.filter { !Self.feedShouldBeSkipped($0) } + + guard !filteredFeeds.isEmpty else { + Task { @MainActor in + completion?() + } return } + + urlToFeedDictionary.removeAll() + for feed in filteredFeeds { + urlToFeedDictionary[feed.url] = feed + } + + let urls = filteredFeeds.compactMap { Self.url(for: $0) } + self.completion = completion - downloadSession.downloadObjects(feeds as NSSet) + downloadSession.download(Set(urls)) } - + public func suspend() { downloadSession.cancelAll() isSuspended = true } - + public func resume() { isSuspended = false } - } // MARK: - DownloadSessionDelegate extension LocalAccountRefresher: DownloadSessionDelegate { - func downloadSession(_ downloadSession: DownloadSession, requestForRepresentedObject representedObject: AnyObject) -> URLRequest? { - guard let feed = representedObject as? Feed else { - return nil - } - guard let url = URL(string: feed.url) else { - return nil - } - - var request = URLRequest(url: url) - if let conditionalGetInfo = feed.conditionalGetInfo { - conditionalGetInfo.addRequestHeadersToURLRequest(&request) - } + func downloadSession(_ downloadSession: DownloadSession, conditionalGetInfoFor url: URL) -> HTTPConditionalGetInfo? { - return request + guard let feed = urlToFeedDictionary[url.absoluteString] else { + return nil + } + return feed.conditionalGetInfo } - - func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject representedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?, completion: @escaping () -> Void) { - let feed = representedObject as! Feed - - guard !data.isEmpty, !isSuspended else { - completion() - delegate?.localAccountRefresher(self, requestCompletedFor: feed) + + func downloadSession(_ downloadSession: DownloadSession, downloadDidComplete url: URL, response: URLResponse?, data: Data, error: NSError?) { + + guard !isSuspended else { + return + } + guard let feed = urlToFeedDictionary[url.absoluteString] else { return } - if let error = error { - print("Error downloading \(feed.url) - \(error)") - completion() - delegate?.localAccountRefresher(self, requestCompletedFor: feed) + if error != nil { return } + let conditionalGetInfo: HTTPConditionalGetInfo? = { + if let httpResponse = response as? HTTPURLResponse { + return HTTPConditionalGetInfo(urlResponse: httpResponse) + } + return nil + }() + + if url.isOpenRSSOrgURL { + // Supported only for openrss.org. Cache-Control headers are + // otherwise not intentional for feeds, unfortunately. + if let httpURLResponse = response as? HTTPURLResponse, let cacheControlInfo = CacheControlInfo(urlResponse: httpURLResponse) { + feed.cacheControlInfo = cacheControlInfo + } + } + let dataHash = data.md5String if dataHash == feed.contentHash { - completion() - delegate?.localAccountRefresher(self, requestCompletedFor: feed) + // It’s possible that the conditional get info has changed even if the + // content hasn’t changed. + // https://inessential.com/2024/08/03/netnewswire_and_conditional_get_issues.html + feed.conditionalGetInfo = conditionalGetInfo return } let parserData = ParserData(url: feed.url, data: data) FeedParser.parse(parserData) { (parsedFeed, error) in - guard let account = feed.account, let parsedFeed = parsedFeed, error == nil else { - completion() - self.delegate?.localAccountRefresher(self, requestCompletedFor: feed) + guard let account = feed.account, let parsedFeed, error == nil else { return } account.update(feed, with: parsedFeed) { result in if case .success(let articleChanges) = result { - if let httpResponse = response as? HTTPURLResponse { - feed.conditionalGetInfo = HTTPConditionalGetInfo(urlResponse: httpResponse) - } feed.contentHash = dataHash - self.delegate?.localAccountRefresher(self, requestCompletedFor: feed) - self.delegate?.localAccountRefresher(self, articleChanges: articleChanges) { - completion() - } - } else { - completion() - self.delegate?.localAccountRefresher(self, requestCompletedFor: feed) + feed.conditionalGetInfo = conditionalGetInfo + self.delegate?.localAccountRefresher(self, articleChanges: articleChanges) } } - } } - func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData data: Data, representedObject: AnyObject) -> Bool { - let feed = representedObject as! Feed - guard !isSuspended else { - delegate?.localAccountRefresher(self, requestCompletedFor: feed) + func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData data: Data, url: URL) -> Bool { + + guard !data.isDefinitelyNotFeed(), !isSuspended else { return false } - - if data.isEmpty { + return true + } + + func downloadSessionDidComplete(_ downloadSession: DownloadSession) { + + Task { @MainActor in + completion?() + completion = nil + } + } +} + + +// MARK: - Private + +private extension LocalAccountRefresher { + + /// These hosts will never return a feed. + /// + /// People may still have feeds pointing to Twitter due to our prior + /// use of the Twitter API. (Which Twitter took away.) + static let badHosts = ["twitter.com", "www.twitter.com", "x.com", "www.x.com"] + + /// Return true if we won’t download that feed. + static func feedIsDisallowed(_ feed: Feed) -> Bool { + + guard let url = url(for: feed) else { return true } - - if data.isDefinitelyNotFeed() { - delegate?.localAccountRefresher(self, requestCompletedFor: feed) + guard let lowercaseHost = url.host()?.lowercased() else { + return true + } + + for badHost in badHosts { + if lowercaseHost == badHost { + os_log(.debug, "Dropping request because it‘s X/Twitter, which doesn’t provide feeds: \(feed.url)") + return true + } + } + + return false + } + + static func feedShouldBeSkipped(_ feed: Feed) -> Bool { + feedShouldBeSkippedForCacheControlReasons(feed) || feedIsDisallowed(feed) + } + + static func feedShouldBeSkippedForCacheControlReasons(_ feed: Feed) -> Bool { + // We support Cache-Control only for openrss.org. The rest of the feed-providing + // universe hasn’t dealt with Cache-Control, and we routinely see days-long + // max-ages for even fast-moving feeds. + // + // However, openrss.org does make sure their Cache-Control headers are + // intentional, and we should honor those. + guard let url = url(for: feed) else { return false } - - return true + + if url.isOpenRSSOrgURL { + if let cacheControlInfo = feed.cacheControlInfo, !cacheControlInfo.canResume { + os_log(.debug, "Dropping request for Cache-Control reasons: \(feed.url)") + return true + } + } + + return false } - func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse response: URLResponse, representedObject: AnyObject) { - let feed = representedObject as! Feed - delegate?.localAccountRefresher(self, requestCompletedFor: feed) - } + static var urlCache = [String: URL]() - func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) { - let feed = representedObject as! Feed - delegate?.localAccountRefresher(self, requestCompletedFor: feed) - } - - func downloadSession(_ downloadSession: DownloadSession, didDiscardDuplicateRepresentedObject representedObject: AnyObject) { - let feed = representedObject as! Feed - delegate?.localAccountRefresher(self, requestCompletedFor: feed) - } + static func url(for feed: Feed) -> URL? { - func downloadSessionDidCompleteDownloadObjects(_ downloadSession: DownloadSession) { - completion?() - completion = nil - } + assert(Thread.isMainThread) + let urlString = feed.url + + if let url = urlCache[urlString] { + return url + } + if let url = URL(unicodeString: urlString) { + urlCache[urlString] = url + return url + } + + return nil + } } // MARK: - Utility diff --git a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift index ae3698b80..d24764327 100644 --- a/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Account/Sources/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -64,6 +64,9 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func refreshAll(for account: Account, completion: @escaping (Result) -> ()) { + + refreshProgress.reset() + self.refreshProgress.addToNumberOfTasksAndRemaining(4) refreshFeeds(for: account) { result in @@ -92,7 +95,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { case .failure(let error): DispatchQueue.main.async { - self.refreshProgress.clear() + self.refreshProgress.reset() let wrappedError = AccountError.wrappedError(error: error, account: account) completion(.failure(wrappedError)) } diff --git a/Account/Sources/Account/OPMLNormalizer.swift b/Account/Sources/Account/OPMLNormalizer.swift index 850cf1ad4..2d5de573f 100644 --- a/Account/Sources/Account/OPMLNormalizer.swift +++ b/Account/Sources/Account/OPMLNormalizer.swift @@ -60,5 +60,7 @@ final class OPMLNormalizer { normalizedOPMLItems.append(feed) } } + } + } diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index 248cfb9bb..0ef263c70 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -106,6 +106,8 @@ final class ReaderAPIAccountDelegate: AccountDelegate { } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + + refreshProgress.reset() refreshProgress.addToNumberOfTasksAndRemaining(6) refreshAccount(account) { result in @@ -121,14 +123,15 @@ final class ReaderAPIAccountDelegate: AccountDelegate { self.refreshArticleStatus(for: account) { _ in self.refreshProgress.completeTask() self.refreshMissingArticles(account) { - self.refreshProgress.clear() DispatchQueue.main.async { + self.refreshProgress.reset() completion(.success(())) } } } } case .failure(let error): + self.refreshProgress.reset() completion(.failure(error)) } } @@ -136,7 +139,7 @@ final class ReaderAPIAccountDelegate: AccountDelegate { case .failure(let error): DispatchQueue.main.async { - self.refreshProgress.clear() + self.refreshProgress.reset() let wrappedError = AccountError.wrappedError(error: error, account: account) if wrappedError.isCredentialsError, let basicCredentials = try? account.retrieveCredentials(type: .readerBasic), let endpoint = account.endpointURL { @@ -406,7 +409,7 @@ final class ReaderAPIAccountDelegate: AccountDelegate { case .success(let feedSpecifiers): let feedSpecifiers = feedSpecifiers.filter { !$0.urlString.contains("json") } guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers) else { - self.refreshProgress.clear() + self.refreshProgress.reset() completion(.failure(AccountError.createErrorNotFound)) return } @@ -432,7 +435,7 @@ final class ReaderAPIAccountDelegate: AccountDelegate { } case .failure: - self.refreshProgress.clear() + self.refreshProgress.reset() completion(.failure(AccountError.createErrorNotFound)) } @@ -962,7 +965,7 @@ private extension ReaderAPIAccountDelegate { self.refreshArticleStatus(for: account) { _ in self.refreshProgress.completeTask() self.refreshMissingArticles(account) { - self.refreshProgress.clear() + self.refreshProgress.reset() DispatchQueue.main.async { completion(.success(feed)) } diff --git a/Account/Sources/Account/SidebarItemIdentifier.swift b/Account/Sources/Account/SidebarItemIdentifier.swift index 579d322c8..04bbd410a 100644 --- a/Account/Sources/Account/SidebarItemIdentifier.swift +++ b/Account/Sources/Account/SidebarItemIdentifier.swift @@ -1,5 +1,5 @@ // -// ArticleFetcherType.swift +// SidebarItemIdentifier.swift // Account // // Created by Maurice Parker on 11/13/19. diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift index 7ba0f274c..e50f49ccc 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift @@ -32,8 +32,8 @@ final class ArticlesTable: DatabaseTable { let articleCutoffDate = Date().bySubtracting(days: 90) private typealias ArticlesFetchMethod = (FMDatabase) -> Set
- private typealias ArticlesCountFetchMethod = (FMDatabase) -> Int - + private typealias ArticlesCountFetchMethod = (FMDatabase) -> Int + init(name: String, accountID: String, queue: DatabaseQueue, retentionStyle: ArticlesDatabase.RetentionStyle) { self.name = name @@ -104,10 +104,10 @@ final class ArticlesTable: DatabaseTable { fetchArticlesAsync({ self.fetchStarredArticles(feedIDs, limit, $0) }, completion) } - func fetchStarredArticlesCount(_ feedIDs: Set) throws -> Int { - return try fetchArticlesCount{ self.fetchStarredArticlesCount(feedIDs, $0) } - } - + func fetchStarredArticlesCount(_ feedIDs: Set) throws -> Int { + return try fetchArticlesCount{ self.fetchStarredArticlesCount(feedIDs, $0) } + } + // MARK: - Fetching Search Articles func fetchArticlesMatching(_ searchString: String) throws -> Set
{ @@ -660,23 +660,23 @@ private extension ArticlesTable { return articles } - private func fetchArticlesCount(_ fetchMethod: @escaping ArticlesCountFetchMethod) throws -> Int { - var articlesCount = 0 - var error: DatabaseError? = nil - queue.runInDatabaseSync { databaseResult in - switch databaseResult { - case .success(let database): - articlesCount = fetchMethod(database) - case .failure(let databaseError): - error = databaseError - } - } - if let error = error { - throw(error) - } - return articlesCount - } - + private func fetchArticlesCount(_ fetchMethod: @escaping ArticlesCountFetchMethod) throws -> Int { + var articlesCount = 0 + var error: DatabaseError? = nil + queue.runInDatabaseSync { databaseResult in + switch databaseResult { + case .success(let database): + articlesCount = fetchMethod(database) + case .failure(let databaseError): + error = databaseError + } + } + if let error = error { + throw(error) + } + return articlesCount + } + private func fetchArticlesAsync(_ fetchMethod: @escaping ArticlesFetchMethod, _ completion: @escaping ArticleSetResultBlock) { queue.runInDatabase { databaseResult in @@ -751,19 +751,19 @@ private extension ArticlesTable { return articlesWithSQL(sql, parameters, database) } - func fetchArticleCountsWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject]) -> Int { - let sql = "select count(*) from articles natural join statuses where \(whereClause);" - guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else { - return 0 - } - var articlesCount = 0 - if resultSet.next() { - articlesCount = resultSet.long(forColumnIndex: 0) - } - resultSet.close() - return articlesCount - } - + func fetchArticleCountsWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject]) -> Int { + let sql = "select count(*) from articles natural join statuses where \(whereClause);" + guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else { + return 0 + } + var articlesCount = 0 + if resultSet.next() { + articlesCount = resultSet.long(forColumnIndex: 0) + } + resultSet.close() + return articlesCount + } + func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set
{ let sql = "select rowid from search where search match ?;" let sqlSearchString = sqliteSearchString(with: searchString) diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/Extensions/ArticleStatus+Database.swift b/ArticlesDatabase/Sources/ArticlesDatabase/Extensions/ArticleStatus+Database.swift index ff2d2fe29..4dc1e0c39 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/Extensions/ArticleStatus+Database.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/Extensions/ArticleStatus+Database.swift @@ -23,7 +23,7 @@ extension ArticleStatus { } extension ArticleStatus: @retroactive DatabaseObject { - + public var databaseID: String { return articleID } diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index 65abe43f5..d6387e64a 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -48,6 +48,9 @@ struct AppAssets { return RSImage(named: "accountTheOldReader") }() + static let nnwFeedIcon = RSImage(named: "nnwFeedIcon")! + + @available(macOS 11.0, *) static var addNewSidebarItemImage: RSImage = { return NSImage(systemSymbolName: "plus", accessibilityDescription: nil)! }() diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index 7132b158e..d88ebbfae 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -5,6 +5,7 @@ // Created by Brent Simmons on 9/22/17. // Copyright © 2017 Ranchero Software. All rights reserved. // + import AppKit enum FontSize: Int { @@ -17,9 +18,8 @@ enum FontSize: Int { final class AppDefaults { static let defaultThemeName = "Default" - - static var shared = AppDefaults() - private init() {} + + static let shared = AppDefaults() struct Key { static let firstRunDate = "firstRunDate" @@ -41,6 +41,7 @@ final class AppDefaults { static let exportOPMLAccountID = "exportOPMLAccountID" static let defaultBrowserID = "defaultBrowserID" static let currentThemeName = "currentThemeName" + static let articleContentJavascriptEnabled = "articleContentJavascriptEnabled" // Hidden prefs static let showDebugMenu = "ShowDebugMenu" @@ -299,6 +300,15 @@ final class AppDefaults { } } + var isArticleContentJavascriptEnabled: Bool { + get { + UserDefaults.standard.bool(forKey: Key.articleContentJavascriptEnabled) + } + set { + UserDefaults.standard.set(newValue, forKey: Key.articleContentJavascriptEnabled) + } + } + func registerDefaults() { #if DEBUG let showDebugMenu = true @@ -306,15 +316,18 @@ final class AppDefaults { let showDebugMenu = false #endif - let defaults: [String : Any] = [Key.sidebarFontSize: FontSize.medium.rawValue, - Key.timelineFontSize: FontSize.medium.rawValue, - Key.detailFontSize: FontSize.medium.rawValue, - Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue, - Key.timelineGroupByFeed: false, - "NSScrollViewShouldScrollUnderTitlebar": false, - Key.refreshInterval: RefreshInterval.everyHour.rawValue, - Key.showDebugMenu: showDebugMenu, - Key.currentThemeName: Self.defaultThemeName] + let defaults: [String : Any] = [ + Key.sidebarFontSize: FontSize.medium.rawValue, + Key.timelineFontSize: FontSize.medium.rawValue, + Key.detailFontSize: FontSize.medium.rawValue, + Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue, + Key.timelineGroupByFeed: false, + "NSScrollViewShouldScrollUnderTitlebar": false, + Key.refreshInterval: RefreshInterval.everyHour.rawValue, + Key.showDebugMenu: showDebugMenu, + Key.currentThemeName: Self.defaultThemeName, + Key.articleContentJavascriptEnabled: true + ] UserDefaults.standard.register(defaults: defaults) diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index d1d9bcf36..c925b3199 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -39,9 +39,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, var userNotificationManager: UserNotificationManager! var faviconDownloader: FaviconDownloader! - var imageDownloader: ImageDownloader! - var authorAvatarDownloader: AuthorAvatarDownloader! - var feedIconDownloader: FeedIconDownloader! var extensionContainersFile: ExtensionContainersFile! var extensionFeedAddRequestFile: ExtensionFeedAddRequestFile! @@ -169,10 +166,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, 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) - feedIconDownloader = FeedIconDownloader(imageDownloader: imageDownloader, folder: cacheFolder) appName = (Bundle.main.infoDictionary!["CFBundleExecutable"]! as! String) } @@ -221,7 +214,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, mainWindowController?.window?.center() } - NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .FeedSettingDidChange, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .feedSettingDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) DispatchQueue.main.async { diff --git a/Mac/CrashReporter/CrashReporter.swift b/Mac/CrashReporter/CrashReporter.swift index b37810a9d..95423994d 100644 --- a/Mac/CrashReporter/CrashReporter.swift +++ b/Mac/CrashReporter/CrashReporter.swift @@ -54,9 +54,7 @@ struct CrashReporter { let formData = formString.data(using: .utf8, allowLossyConversion: true) request.httpBody = formData - download(request) { (_, _, _) in - // Don’t care about the result. - } + Downloader.shared.download(request) // Don’t care about the result. } static func runCrashReporterWindow(_ crashLogText: String) { diff --git a/Mac/MainWindow/Detail/DetailContainerView.swift b/Mac/MainWindow/Detail/DetailContainerView.swift index ffc330b37..f51ec96ef 100644 --- a/Mac/MainWindow/Detail/DetailContainerView.swift +++ b/Mac/MainWindow/Detail/DetailContainerView.swift @@ -35,4 +35,10 @@ final class DetailContainerView: NSView { } } } + + override func draw(_ dirtyRect: NSRect) { + NSColor.controlBackgroundColor.set() + let r = NSIntersectionRect(dirtyRect, bounds) + r.fill() + } } diff --git a/Mac/MainWindow/Detail/DetailViewController.swift b/Mac/MainWindow/Detail/DetailViewController.swift index f8758a852..4a84a3f48 100644 --- a/Mac/MainWindow/Detail/DetailViewController.swift +++ b/Mac/MainWindow/Detail/DetailViewController.swift @@ -25,15 +25,10 @@ final class DetailViewController: NSViewController, WKUIDelegate { @IBOutlet var containerView: DetailContainerView! @IBOutlet var statusBarView: DetailStatusBarView! - lazy var regularWebViewController = { - return createWebViewController() - }() + private lazy var regularWebViewController = createWebViewController() + private var searchWebViewController: DetailWebViewController? - lazy var searchWebViewController = { - return createWebViewController() - }() - - var currentWebViewController: DetailWebViewController! { + private var currentWebViewController: DetailWebViewController! { didSet { let webview = currentWebViewController.view if containerView.contentView === webview { @@ -44,18 +39,44 @@ final class DetailViewController: NSViewController, WKUIDelegate { } } + private var currentSourceMode: TimelineSourceMode = .regular { + didSet { + currentWebViewController = webViewController(for: currentSourceMode) + } + } + + private var detailStateForRegular: DetailState = .noSelection { + didSet { + webViewController(for: .regular).state = detailStateForRegular + } + } + + private var detailStateForSearch: DetailState = .noSelection { + didSet { + webViewController(for: .search).state = detailStateForSearch + } + } + + private var isArticleContentJavascriptEnabled = AppDefaults.shared.isArticleContentJavascriptEnabled + override func viewDidLoad() { currentWebViewController = regularWebViewController + NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) } // MARK: - API func setState(_ state: DetailState, mode: TimelineSourceMode) { - webViewController(for: mode).state = state + switch mode { + case .regular: + detailStateForRegular = state + case .search: + detailStateForSearch = state + } } func showDetail(for mode: TimelineSourceMode) { - currentWebViewController = webViewController(for: mode) + currentSourceMode = mode } func stopMediaPlayback() { @@ -130,7 +151,32 @@ private extension DetailViewController { case .regular: return regularWebViewController case .search: - return searchWebViewController + if searchWebViewController == nil { + searchWebViewController = createWebViewController() + } + return searchWebViewController! + } + } + + @objc func userDefaultsDidChange(_ : Notification) { + if AppDefaults.shared.isArticleContentJavascriptEnabled != isArticleContentJavascriptEnabled { + isArticleContentJavascriptEnabled = AppDefaults.shared.isArticleContentJavascriptEnabled + createNewWebViewsAndRestoreState() + } + } + + func createNewWebViewsAndRestoreState() { + + regularWebViewController = createWebViewController() + currentWebViewController = regularWebViewController + regularWebViewController.state = detailStateForRegular + + searchWebViewController = nil + + if currentSourceMode == .search { + searchWebViewController = createWebViewController() + currentWebViewController = searchWebViewController + searchWebViewController!.state = detailStateForSearch } } } diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift index c0771170e..c7c935ebb 100644 --- a/Mac/MainWindow/Detail/DetailWebViewController.swift +++ b/Mac/MainWindow/Detail/DetailWebViewController.swift @@ -73,6 +73,16 @@ final class DetailWebViewController: NSViewController { } } + static let userScripts: [WKUserScript] = { + let filenames = ["main", "main_mac", "newsfoot"] + let scripts = filenames.map { filename in + let scriptURL = Bundle.main.url(forResource: filename, withExtension: ".js")! + let scriptSource = try! String(contentsOf: scriptURL) + return WKUserScript(source: scriptSource, injectionTime: .atDocumentStart, forMainFrameOnly: true) + } + return scripts + }() + private struct MessageName { static let mouseDidEnter = "mouseDidEnter" static let mouseDidExit = "mouseDidExit" @@ -86,13 +96,16 @@ final class DetailWebViewController: NSViewController { let configuration = WKWebViewConfiguration() configuration.preferences = preferences - configuration.defaultWebpagePreferences.allowsContentJavaScript = true + configuration.defaultWebpagePreferences.allowsContentJavaScript = AppDefaults.shared.isArticleContentJavascriptEnabled configuration.setURLSchemeHandler(detailIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme) let userContentController = WKUserContentController() userContentController.add(self, name: MessageName.windowDidScroll) userContentController.add(self, name: MessageName.mouseDidEnter) userContentController.add(self, name: MessageName.mouseDidExit) + for script in Self.userScripts { + userContentController.addUserScript(script) + } configuration.userContentController = userContentController webView = DetailWebView(frame: NSRect.zero, configuration: configuration) @@ -106,6 +119,19 @@ final class DetailWebViewController: NSViewController { view = webView + // Use the safe area layout guides if they are available. + if #available(OSX 11.0, *) { + // These constraints have been removed as they were unsatisfiable after removing NSBox. + } else { + let constraints = [ + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ] + NSLayoutConstraint.activate(constraints) + } + // Hide the web view until the first reload (navigation) is complete (plus some delay) to avoid the awful white flash that happens on the initial display in dark mode. // See bug #901. webView.isHidden = true @@ -116,7 +142,7 @@ final class DetailWebViewController: NSViewController { NotificationCenter.default.addObserver(self, selector: #selector(webInspectorEnabledDidChange(_:)), name: .WebInspectorEnabledDidChange, object: nil) #endif - NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .FeedIconDidBecomeAvailable, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) @@ -313,11 +339,14 @@ 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 fetchScrollInfo(_ completion: @escaping (ScrollInfo?) -> Void) { - let javascriptString = "var x = {contentHeight: document.body.scrollHeight, offsetY: window.pageYOffset}; x" + var javascriptString = "var x = {contentHeight: document.body.scrollHeight, offsetY: document.body.scrollTop}; x" + if #available(macOS 10.15, *) { + javascriptString = "var x = {contentHeight: document.body.scrollHeight, offsetY: window.pageYOffset}; x" + } webView.evaluateJavaScript(javascriptString) { (info, error) in guard let info = info as? [String: Any] else { 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/MainWindowController.swift b/Mac/MainWindow/MainWindowController.swift index 33c93176a..2e3ee6e60 100644 --- a/Mac/MainWindow/MainWindowController.swift +++ b/Mac/MainWindow/MainWindowController.swift @@ -64,7 +64,7 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations { super.windowDidLoad() sharingServicePickerDelegate = SharingServicePickerDelegate(self.window) - + updateArticleThemeMenu() let toolbar = NSToolbar(identifier: "MainWindowToolbar") @@ -914,11 +914,11 @@ private extension MainWindowController { return viewController.children.first as? NSSplitViewController } - var currentTimelineViewController: MainTimelineViewController? { + var currentTimelineViewController: TimelineViewController? { return timelineContainerViewController?.currentTimelineViewController } - var regularTimelineViewController: MainTimelineViewController? { + var regularTimelineViewController: TimelineViewController? { return timelineContainerViewController?.regularTimelineViewController } @@ -1041,7 +1041,6 @@ private extension MainWindowController { return false } - guard let toolbarItem = item as? NSToolbarItem, let toolbarButton = toolbarItem.view as? ArticleExtractorButton else { if let menuItem = item as? NSMenuItem { menuItem.state = isShowingExtractedArticle ? .on : .off @@ -1067,7 +1066,6 @@ private extension MainWindowController { toolbarButton.buttonState = isShowingExtractedArticle ? .on : .off } - return true } @@ -1200,13 +1198,13 @@ private extension MainWindowController { let formattedLabel = NSString.localizedStringWithFormat(localizedLabel as NSString, count) window?.subtitle = formattedLabel as String } - + guard let selectedObjects = selectedObjectsInSidebar(), selectedObjects.count > 0 else { window?.title = appDelegate.appName! setSubtitle(appDelegate.unreadCount) return } - + guard selectedObjects.count == 1 else { window?.title = NSLocalizedString("Multiple", comment: "Multiple") let unreadCount = selectedObjects.reduce(0, { result, selectedObject in @@ -1217,6 +1215,7 @@ private extension MainWindowController { } }) setSubtitle(unreadCount) + return } diff --git a/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift b/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift index d65733d51..d1e9de7dc 100644 --- a/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift +++ b/Mac/MainWindow/Sidebar/SidebarStatusBarView.swift @@ -21,11 +21,6 @@ final class SidebarStatusBarView: NSView { private var isAnimatingProgress = false - private var progress: CombinedRefreshProgress? = nil { - didSet { - CoalescingQueue.standard.add(self, #selector(updateUI)) - } - } override var isFlipped: Bool { return true } @@ -39,25 +34,20 @@ final class SidebarStatusBarView: NSView { progressLabel.font = NSFont.monospacedDigitSystemFont(ofSize: progressLabelFontSize, weight: NSFont.Weight.regular) progressLabel.stringValue = "" - NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_:)), name: .AccountRefreshProgressDidChange, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_:)), name: .combinedRefreshProgressDidChange, object: nil) } @objc func updateUI() { - guard let progress = progress else { - stopProgressIfNeeded() - return - } - - updateProgressIndicator(progress) - updateProgressLabel(progress) + updateProgressIndicator() + updateProgressLabel() } // MARK: Notifications @objc dynamic func progressDidChange(_ notification: Notification) { - progress = AccountManager.shared.combinedRefreshProgress + CoalescingQueue.standard.add(self, #selector(updateUI)) } } @@ -73,13 +63,13 @@ private extension SidebarStatusBarView { return } isAnimatingProgress = false - self.progressIndicator.stopAnimation(self) + progressIndicator.stopAnimation(self) progressIndicator.isHidden = true progressLabel.isHidden = true superview?.layoutSubtreeIfNeeded() - NSAnimationContext.runAnimationGroup{ (context) in + NSAnimationContext.runAnimationGroup { context in context.duration = SidebarStatusBarView.animationDuration context.allowsImplicitAnimation = true bottomConstraint.constant = -(heightConstraint.constant) @@ -99,7 +89,7 @@ private extension SidebarStatusBarView { superview?.layoutSubtreeIfNeeded() - NSAnimationContext.runAnimationGroup{ (context) in + NSAnimationContext.runAnimationGroup { context in context.duration = SidebarStatusBarView.animationDuration context.allowsImplicitAnimation = true bottomConstraint.constant = 0 @@ -107,7 +97,9 @@ private extension SidebarStatusBarView { } } - func updateProgressIndicator(_ progress: CombinedRefreshProgress) { + func updateProgressIndicator() { + + let progress = AccountManager.shared.combinedRefreshProgress if progress.isComplete { stopProgressIfNeeded() @@ -127,7 +119,9 @@ private extension SidebarStatusBarView { } } - func updateProgressLabel(_ progress: CombinedRefreshProgress) { + func updateProgressLabel() { + + let progress = AccountManager.shared.combinedRefreshProgress if progress.isComplete { progressLabel.stringValue = "" @@ -135,8 +129,8 @@ private extension SidebarStatusBarView { } let formatString = NSLocalizedString("%@ of %@", comment: "Status bar progress") - let s = NSString(format: formatString as NSString, NSNumber(value: progress.numberCompleted), NSNumber(value: progress.numberOfTasks)) + let s = String(format: formatString, NSNumber(value: progress.numberCompleted), NSNumber(value: progress.numberOfTasks)) - progressLabel.stringValue = s as String + progressLabel.stringValue = s } } diff --git a/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift b/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift new file mode 100644 index 000000000..e69de29bb diff --git a/Mac/MainWindow/Timeline/Cell/UnreadIndicatorView.swift b/Mac/MainWindow/Timeline/Cell/UnreadIndicatorView.swift new file mode 100644 index 000000000..e69de29bb diff --git a/Mac/Preferences/PreferencesControlsBackgroundView.swift b/Mac/Preferences/PreferencesControlsBackgroundView.swift index 36353dd6b..5625f3d3d 100644 --- a/Mac/Preferences/PreferencesControlsBackgroundView.swift +++ b/Mac/Preferences/PreferencesControlsBackgroundView.swift @@ -26,9 +26,11 @@ final class PreferencesControlsBackgroundView: NSView { } override func draw(_ dirtyRect: NSRect) { + let fillColor = self.effectiveAppearance.isDarkMode ? darkModeFillColor : lightModeFillColor fillColor.setFill() - dirtyRect.fill() + let r = NSIntersectionRect(dirtyRect, bounds) + r.fill() let borderColor = self.effectiveAppearance.isDarkMode ? darkModeBorderColor : lightModeBorderColor borderColor.set() diff --git a/Mac/Preferences/PreferencesTableViewBackgroundView.swift b/Mac/Preferences/PreferencesTableViewBackgroundView.swift index 34548e429..21839a895 100644 --- a/Mac/Preferences/PreferencesTableViewBackgroundView.swift +++ b/Mac/Preferences/PreferencesTableViewBackgroundView.swift @@ -16,6 +16,8 @@ final class PreferencesTableViewBackgroundView: NSView { override func draw(_ dirtyRect: NSRect) { let color = self.effectiveAppearance.isDarkMode ? darkBorderColor : lightBorderColor color.setFill() - dirtyRect.fill() + + let r = NSIntersectionRect(dirtyRect, bounds) + r.fill() } } diff --git a/Mac/Resources/NetNewsWire.sdef b/Mac/Resources/NetNewsWire.sdef index 5419b35c7..9af570f2e 100644 --- a/Mac/Resources/NetNewsWire.sdef +++ b/Mac/Resources/NetNewsWire.sdef @@ -4,7 +4,7 @@ - + diff --git a/Mac/SafariExtension/Info.plist b/Mac/SafariExtension/Info.plist index 9cf563d89..0c09a833e 100644 --- a/Mac/SafariExtension/Info.plist +++ b/Mac/SafariExtension/Info.plist @@ -59,7 +59,7 @@ NSHumanReadableCopyright - Copyright © 2019-2022 Brent Simmons. All rights reserved. + Copyright © 2019-2024 Brent Simmons. All rights reserved. NSHumanReadableDescription This extension adds a Safari toolbar button for easily subscribing to the syndication feed for the current page. diff --git a/Mac/ShareExtension/Info.plist b/Mac/ShareExtension/Info.plist index 9c54461fa..8b3017b1a 100644 --- a/Mac/ShareExtension/Info.plist +++ b/Mac/ShareExtension/Info.plist @@ -48,6 +48,6 @@ $(PRODUCT_MODULE_NAME).ShareViewController NSHumanReadableCopyright - Copyright © 2020-2022 Ranchero Software. All rights reserved. + Copyright © 2020-2024 Ranchero Software. All rights reserved. diff --git a/RSWeb/Sources/RSWeb/DownloadProgress.swift b/RSWeb/Sources/RSWeb/DownloadProgress.swift index 84bfffedf..1a3444e76 100755 --- a/RSWeb/Sources/RSWeb/DownloadProgress.swift +++ b/RSWeb/Sources/RSWeb/DownloadProgress.swift @@ -30,9 +30,6 @@ public final class DownloadProgress { public var numberRemaining = 0 { didSet { - if numberRemaining == 0 && numberOfTasks != 0 { - numberOfTasks = 0 - } if numberRemaining != oldValue { postDidChangeNotification() } @@ -85,8 +82,9 @@ public final class DownloadProgress { } } - public func clear() { + public func reset() { assert(Thread.isMainThread) + numberRemaining = 0 numberOfTasks = 0 } } diff --git a/RSWeb/Sources/RSWeb/DownloadSession.swift b/RSWeb/Sources/RSWeb/DownloadSession.swift index 9d95b269e..b3ff27ac1 100755 --- a/RSWeb/Sources/RSWeb/DownloadSession.swift +++ b/RSWeb/Sources/RSWeb/DownloadSession.swift @@ -7,45 +7,52 @@ // import Foundation +import os // Create a DownloadSessionDelegate, then create a DownloadSession. -// To download things: call downloadObjects, with a set of represented objects, to download things. DownloadSession will call the various delegate methods. +// To download things: call download with a set of URLs. DownloadSession will call the various delegate methods. public protocol DownloadSessionDelegate { - func downloadSession(_ downloadSession: DownloadSession, requestForRepresentedObject: AnyObject) -> URLRequest? - func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?, completion: @escaping () -> Void) - func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData: Data, representedObject: AnyObject) -> Bool - func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse: URLResponse, representedObject: AnyObject) - func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) - func downloadSession(_ downloadSession: DownloadSession, didDiscardDuplicateRepresentedObject: AnyObject) - func downloadSessionDidCompleteDownloadObjects(_ downloadSession: DownloadSession) - + func downloadSession(_ downloadSession: DownloadSession, conditionalGetInfoFor: URL) -> HTTPConditionalGetInfo? + func downloadSession(_ downloadSession: DownloadSession, downloadDidComplete: URL, response: URLResponse?, data: Data, error: NSError?) + func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData: Data, url: URL) -> Bool + func downloadSessionDidComplete(_ downloadSession: DownloadSession) } @objc public final class DownloadSession: NSObject { - + + public let downloadProgress = DownloadProgress(numberOfTasks: 0) + private var urlSession: URLSession! private var tasksInProgress = Set() private var tasksPending = Set() private var taskIdentifierToInfoDictionary = [Int: DownloadInfo]() - private let representedObjects = NSMutableSet() + private var urlsInSession = Set() private let delegate: DownloadSessionDelegate - private var redirectCache = [String: String]() - private var queue = [AnyObject]() - + private var redirectCache = [URL: URL]() + private var queue = [URL]() + + // 429 Too Many Requests responses + private var retryAfterMessages = [String: HTTPResponse429]() + + /// URLs with 400-499 responses (except for 429). + /// These URLs are skipped for the rest of the session. + private var urlsWith400s = Set() + + public init(delegate: DownloadSessionDelegate) { - + self.delegate = delegate super.init() - let sessionConfiguration = URLSessionConfiguration.default + let sessionConfiguration = URLSessionConfiguration.ephemeral sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData sessionConfiguration.timeoutIntervalForRequest = 15.0 sessionConfiguration.httpShouldSetCookies = false sessionConfiguration.httpCookieAcceptPolicy = .never - sessionConfiguration.httpMaximumConnectionsPerHost = 2 + sessionConfiguration.httpMaximumConnectionsPerHost = 1 sessionConfiguration.httpCookieStorage = nil sessionConfiguration.urlCache = nil @@ -64,27 +71,28 @@ public protocol DownloadSessionDelegate { public func cancelAll() { urlSession.getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in - for dataTask in dataTasks { - dataTask.cancel() + for task in dataTasks { + task.cancel() } - for uploadTask in uploadTasks { - uploadTask.cancel() + for task in uploadTasks { + task.cancel() } - for downloadTask in downloadTasks { - downloadTask.cancel() + for task in downloadTasks { + task.cancel() } } } - public func downloadObjects(_ objects: NSSet) { - for oneObject in objects { - if !representedObjects.contains(oneObject) { - representedObjects.add(oneObject) - addDataTask(oneObject as AnyObject) - } else { - delegate.downloadSession(self, didDiscardDuplicateRepresentedObject: oneObject as AnyObject) - } + public func download(_ urls: Set) { + + let filteredURLs = Self.filteredURLs(urls) + + for url in filteredURLs { + addDataTask(url) } + + urlsInSession = filteredURLs + updateDownloadProgress() } } @@ -93,28 +101,35 @@ public protocol DownloadSessionDelegate { extension DownloadSession: URLSessionTaskDelegate { public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - tasksInProgress.remove(task) - + + defer { + removeTask(task) + } + guard let info = infoForTask(task) else { return } - - info.error = error - delegate.downloadSession(self, downloadDidCompleteForRepresentedObject: info.representedObject, response: info.urlResponse, data: info.data as Data, error: error as NSError?) { - self.removeTask(task) - } + delegate.downloadSession(self, downloadDidComplete: info.url, response: info.urlResponse, data: info.data as Data, error: error as NSError?) } - + + private static let redirectStatusCodes = Set([HTTPResponseCode.redirectPermanent, HTTPResponseCode.redirectTemporary, HTTPResponseCode.redirectVeryTemporary, HTTPResponseCode.redirectPermanentPreservingMethod]) + public func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { - - if response.statusCode == 301 || response.statusCode == 308 { - if let oldURLString = task.originalRequest?.url?.absoluteString, let newURLString = request.url?.absoluteString { - cacheRedirect(oldURLString, newURLString) + + if Self.redirectStatusCodes.contains(response.statusCode) { + if let oldURL = task.originalRequest?.url, let newURL = request.url { + cacheRedirect(oldURL, newURL) } } - - completionHandler(request) + + var modifiedRequest = request + + if let url = request.url, url.isOpenRSSOrgURL { + modifiedRequest.setValue(UserAgent.openRSSOrgUserAgent, forHTTPHeaderField: HTTPRequestHeader.userAgent) + } + + completionHandler(modifiedRequest) } } @@ -123,40 +138,36 @@ extension DownloadSession: URLSessionTaskDelegate { extension DownloadSession: URLSessionDataDelegate { public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - - tasksInProgress.insert(dataTask) - tasksPending.remove(dataTask) - - if let info = infoForTask(dataTask) { - info.urlResponse = response + + defer { + updateDownloadProgress() } - if response.forcedStatusCode == 304 { + tasksInProgress.insert(dataTask) + tasksPending.remove(dataTask) - if let representedObject = infoForTask(dataTask)?.representedObject { - delegate.downloadSession(self, didReceiveNotModifiedResponse: response, representedObject: representedObject) - } - - completionHandler(.cancel) - removeTask(dataTask) - - return + let taskInfo = infoForTask(dataTask) + if let taskInfo { + taskInfo.urlResponse = response } if !response.statusIsOK { - if let representedObject = infoForTask(dataTask)?.representedObject { - delegate.downloadSession(self, didReceiveUnexpectedResponse: response, representedObject: representedObject) - } - completionHandler(.cancel) removeTask(dataTask) + let statusCode = response.forcedStatusCode + + if statusCode == HTTPResponseCode.tooManyRequests { + handle429Response(dataTask, response) + } else if (400...499).contains(statusCode), let url = response.url { + urlsWith400s.insert(url) + } + return } addDataTaskFromQueueIfNecessary() - completionHandler(.allow) } @@ -167,52 +178,59 @@ extension DownloadSession: URLSessionDataDelegate { } info.addData(data) - if !delegate.downloadSession(self, shouldContinueAfterReceivingData: info.data as Data, representedObject: info.representedObject) { - - info.canceled = true + if !delegate.downloadSession(self, shouldContinueAfterReceivingData: info.data as Data, url: info.url) { dataTask.cancel() removeTask(dataTask) } } - } // MARK: - Private private extension DownloadSession { - func addDataTask(_ representedObject: AnyObject) { + func addDataTask(_ url: URL) { + guard tasksPending.count < 500 else { - queue.insert(representedObject, at: 0) - return - } - - guard let request = delegate.downloadSession(self, requestForRepresentedObject: representedObject) else { + queue.insert(url, at: 0) return } - var requestToUse = request - // If received permanent redirect earlier, use that URL. - - if let urlString = request.url?.absoluteString, let redirectedURLString = cachedRedirectForURLString(urlString) { - if let redirectedURL = URL(string: redirectedURLString) { - requestToUse.url = redirectedURL - } - } - - let task = urlSession.dataTask(with: requestToUse) + let urlToUse = cachedRedirect(for: url) ?? url - let info = DownloadInfo(representedObject, urlRequest: requestToUse) + if requestShouldBeDroppedDueToActive429(urlToUse) { + os_log(.debug, "Dropping request for previous 429: \(urlToUse)") + return + } + if requestShouldBeDroppedDueToPrevious400(urlToUse) { + os_log(.debug, "Dropping request for previous 400-499: \(urlToUse)") + return + } + + let urlRequest: URLRequest = { + var request = URLRequest(url: urlToUse) + if let conditionalGetInfo = delegate.downloadSession(self, conditionalGetInfoFor: url) { + conditionalGetInfo.addRequestHeadersToURLRequest(&request) + } + if url.isOpenRSSOrgURL { + request.setValue(UserAgent.openRSSOrgUserAgent, forHTTPHeaderField: HTTPRequestHeader.userAgent) + } + return request + }() + + let task = urlSession.dataTask(with: urlRequest) + + let info = DownloadInfo(url) taskIdentifierToInfoDictionary[task.taskIdentifier] = info tasksPending.insert(task) task.resume() } - + func addDataTaskFromQueueIfNecessary() { - guard tasksPending.count < 500, let representedObject = queue.popLast() else { return } - addDataTask(representedObject) + guard tasksPending.count < 500, let url = queue.popLast() else { return } + addDataTask(url) } func infoForTask(_ task: URLSessionTask) -> DownloadInfo? { @@ -225,63 +243,206 @@ private extension DownloadSession { taskIdentifierToInfoDictionary[task.taskIdentifier] = nil addDataTaskFromQueueIfNecessary() - - if tasksInProgress.count + tasksPending.count < 1 { - representedObjects.removeAllObjects() - delegate.downloadSessionDidCompleteDownloadObjects(self) - } + + updateDownloadProgress() } - + func urlStringIsBlackListedRedirect(_ urlString: String) -> Bool { - + // Hotels and similar often do permanent redirects. We can catch some of those. - + let s = urlString.lowercased() let badStrings = ["solutionip", "lodgenet", "monzoon", "landingpage", "btopenzone", "register", "login", "authentic"] - + for oneBadString in badStrings { if s.contains(oneBadString) { return true } } - + return false } - - func cacheRedirect(_ oldURLString: String, _ newURLString: String) { - if urlStringIsBlackListedRedirect(newURLString) { + + func cacheRedirect(_ oldURL: URL, _ newURL: URL) { + if urlStringIsBlackListedRedirect(newURL.absoluteString) { return } - redirectCache[oldURLString] = newURLString + redirectCache[oldURL] = newURL } - - func cachedRedirectForURLString(_ urlString: String) -> String? { - + + func cachedRedirect(for url: URL) -> URL? { + // Follow chains of redirects, but avoid loops. - - var urlStrings = Set() - urlStrings.insert(urlString) - - var currentString = urlString - + + var urls = Set() + urls.insert(url) + + var currentURL = url + while(true) { - - if let oneRedirectString = redirectCache[currentString] { - - if urlStrings.contains(oneRedirectString) { + + if let oneRedirectURL = redirectCache[currentURL] { + + if urls.contains(oneRedirectURL) { // Cycle. Bail. return nil } - urlStrings.insert(oneRedirectString) - currentString = oneRedirectString + urls.insert(oneRedirectURL) + currentURL = oneRedirectURL } - + else { break } } - - return currentString == urlString ? nil : currentString + + if currentURL == url { + return nil + } + return currentURL + } + + // MARK: - Download Progress + + func updateDownloadProgress() { + + downloadProgress.numberOfTasks = urlsInSession.count + + let numberRemaining = tasksPending.count + tasksInProgress.count + queue.count + downloadProgress.numberRemaining = min(numberRemaining, downloadProgress.numberOfTasks) + + // Complete? + if downloadProgress.numberOfTasks > 0 && downloadProgress.numberRemaining < 1 { + delegate.downloadSessionDidComplete(self) + urlsInSession.removeAll() + } + } + + // MARK: - 429 Too Many Requests + + func handle429Response(_ dataTask: URLSessionDataTask, _ response: URLResponse) { + + guard let message = createHTTPResponse429(dataTask, response) else { + return + } + + retryAfterMessages[message.host] = message + cancelAndRemoveTasksWithHost(message.host) + } + + func createHTTPResponse429(_ dataTask: URLSessionDataTask, _ response: URLResponse) -> HTTPResponse429? { + + guard let url = dataTask.currentRequest?.url ?? dataTask.originalRequest?.url else { + return nil + } + guard let httpResponse = response as? HTTPURLResponse else { + return nil + } + guard let retryAfterValue = httpResponse.value(forHTTPHeaderField: HTTPResponseHeader.retryAfter) else { + return nil + } + guard let retryAfter = TimeInterval(retryAfterValue), retryAfter > 0 else { + return nil + } + + return HTTPResponse429(url: url, retryAfter: retryAfter) + } + + func cancelAndRemoveTasksWithHost(_ host: String) { + + cancelAndRemoveTasksWithHost(host, in: tasksInProgress) + cancelAndRemoveTasksWithHost(host, in: tasksPending) + } + + func cancelAndRemoveTasksWithHost(_ host: String, in tasks: Set) { + + let lowercaseHost = host.lowercased() + + let tasksToRemove = tasks.filter { task in + if let taskHost = task.lowercaseHost, taskHost.contains(lowercaseHost) { + return false + } + return true + } + + for task in tasksToRemove { + task.cancel() + } + for task in tasksToRemove { + removeTask(task) + } + } + + func requestShouldBeDroppedDueToActive429(_ url: URL) -> Bool { + + guard let host = url.host() else { + return false + } + guard let retryAfterMessage = retryAfterMessages[host] else { + return false + } + + if retryAfterMessage.resumeDate < Date() { + retryAfterMessages[host] = nil + return false + } + + return true + } + + // MARK: - 400-499 responses + + func requestShouldBeDroppedDueToPrevious400(_ url: URL) -> Bool { + + if urlsWith400s.contains(url) { + return true + } + if let redirectedURL = cachedRedirect(for: url), urlsWith400s.contains(redirectedURL) { + return true + } + + return false + } + + // MARK: - Filtering URLs + + static private let lastOpenRSSOrgFeedRefreshKey = "lastOpenRSSOrgFeedRefresh" + static private var lastOpenRSSOrgFeedRefresh: Date { + get { + UserDefaults.standard.value(forKey: lastOpenRSSOrgFeedRefreshKey) as? Date ?? Date.distantPast + } + set { + UserDefaults.standard.setValue(newValue, forKey: lastOpenRSSOrgFeedRefreshKey) + } + } + + static private var canDownloadFromOpenRSSOrg: Bool { + let okayToDownloadDate = lastOpenRSSOrgFeedRefresh + TimeInterval(60 * 60 * 10) // 10 minutes (arbitrary) + return Date() > okayToDownloadDate + } + + static func filteredURLs(_ urls: Set) -> Set { + + // Possibly remove some openrss.org URLs. + // Can be extended later if necessary. + + if canDownloadFromOpenRSSOrg { + // Allow only one feed from openrss.org per refresh session + lastOpenRSSOrgFeedRefresh = Date() + return urls.byRemovingAllButOneRandomOpenRSSOrgURL() + } + + return urls.byRemovingOpenRSSOrgURLs() + } +} + +extension URLSessionTask { + + var lowercaseHost: String? { + guard let request = currentRequest ?? originalRequest else { + return nil + } + return request.url?.host()?.lowercased() } } @@ -289,21 +450,13 @@ private extension DownloadSession { private final class DownloadInfo { - let representedObject: AnyObject - let urlRequest: URLRequest + let url: URL let data = NSMutableData() - var error: Error? var urlResponse: URLResponse? - var canceled = false - - var statusCode: Int { - return urlResponse?.forcedStatusCode ?? 0 - } - - init(_ representedObject: AnyObject, urlRequest: URLRequest) { - - self.representedObject = representedObject - self.urlRequest = urlRequest + + init(_ url: URL) { + + self.url = url } func addData(_ d: Data) { @@ -311,4 +464,3 @@ private final class DownloadInfo { data.append(d) } } - diff --git a/RSWeb/Sources/RSWeb/HTTPResponseCode.swift b/RSWeb/Sources/RSWeb/HTTPResponseCode.swift index 1ece77008..e983ced2f 100755 --- a/RSWeb/Sources/RSWeb/HTTPResponseCode.swift +++ b/RSWeb/Sources/RSWeb/HTTPResponseCode.swift @@ -32,7 +32,8 @@ public struct HTTPResponseCode { public static let useProxy = 305 public static let unused = 306 public static let redirectVeryTemporary = 307 - + public static let redirectPermanentPreservingMethod = 308 + public static let badRequest = 400 public static let unauthorized = 401 public static let paymentRequired = 402 @@ -51,7 +52,18 @@ public struct HTTPResponseCode { public static let unsupportedMediaType = 415 public static let requestedRangeNotSatisfiable = 416 public static let expectationFailed = 417 - + public static let imATeapot = 418 + public static let misdirectedRequest = 421 + public static let unprocessableContentWebDAV = 422 + public static let lockedWebDAV = 423 + public static let failedDependencyWebDAV = 424 + public static let tooEarly = 425 + public static let upgradeRequired = 426 + public static let preconditionRequired = 428 + public static let tooManyRequests = 429 + public static let requestHeaderFieldsTooLarge = 431 + public static let unavailableForLegalReasons = 451 + public static let internalServerError = 500 public static let notImplemented = 501 public static let badGateway = 502 diff --git a/RSWeb/Sources/RSWeb/HTTPResponseHeader.swift b/RSWeb/Sources/RSWeb/HTTPResponseHeader.swift index 15e8c53e1..cfdd588ce 100755 --- a/RSWeb/Sources/RSWeb/HTTPResponseHeader.swift +++ b/RSWeb/Sources/RSWeb/HTTPResponseHeader.swift @@ -22,4 +22,7 @@ public struct HTTPResponseHeader { // Changed to the canonical case for lookups against a case sensitive dictionary // https://developer.apple.com/documentation/foundation/httpurlresponse/1417930-allheaderfields public static let etag = "Etag" + + public static let cacheControl = "Cache-Control" + public static let retryAfter = "Retry-After" } diff --git a/RSWeb/Sources/RSWeb/URL+RSWeb.swift b/RSWeb/Sources/RSWeb/URL+RSWeb.swift index d9bc8790f..1225a13e6 100755 --- a/RSWeb/Sources/RSWeb/URL+RSWeb.swift +++ b/RSWeb/Sources/RSWeb/URL+RSWeb.swift @@ -16,32 +16,32 @@ private struct URLConstants { } public extension URL { - + func isHTTPSURL() -> Bool { return self.scheme?.lowercased() == URLConstants.schemeHTTPS } - + func isHTTPURL() -> Bool { return self.scheme?.lowercased() == URLConstants.schemeHTTP } - + func isHTTPOrHTTPSURL() -> Bool { return self.isHTTPSURL() || self.isHTTPURL() } - + func absoluteStringWithHTTPOrHTTPSPrefixRemoved() -> String? { // Case-inensitive. Turns http://example.com/foo into example.com/foo - + if isHTTPSURL() { return absoluteString.stringByRemovingCaseInsensitivePrefix(URLConstants.prefixHTTPS) } else if isHTTPURL() { return absoluteString.stringByRemovingCaseInsensitivePrefix(URLConstants.prefixHTTP) } - + return nil } - + func appendingQueryItem(_ queryItem: URLQueryItem) -> URL? { appendingQueryItems([queryItem]) } @@ -50,40 +50,39 @@ public extension URL { guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return nil } - + var newQueryItems = components.queryItems ?? [] newQueryItems.append(contentsOf: queryItems) components.queryItems = newQueryItems - + return components.url } - + func preparedForOpeningInBrowser() -> URL? { var urlString = absoluteString.replacingOccurrences(of: " ", with: "%20") urlString = urlString.replacingOccurrences(of: "^", with: "%5E") urlString = urlString.replacingOccurrences(of: "&", with: "&") urlString = urlString.replacingOccurrences(of: "&", with: "&") - + return URL(string: urlString) } - } private extension String { func stringByRemovingCaseInsensitivePrefix(_ prefix: String) -> String { // Returns self if it doesn’t have the given prefix. - + let lowerPrefix = prefix.lowercased() let lowerSelf = self.lowercased() - + if (lowerSelf == lowerPrefix) { return "" } if !lowerSelf.hasPrefix(lowerPrefix) { return self } - + let index = self.index(self.startIndex, offsetBy: prefix.count) return String(self[.. .systemMessage { .articleDateline { margin-bottom: 5px; font-weight: bold; + font-variant-caps: all-small-caps; + letter-spacing: 0.025em; } .articleDateline a:link, .articleDateline a:visited { @@ -115,6 +120,7 @@ body > .systemMessage { .articleDatelineTitle { margin-bottom: 5px; font-weight: bold; + font-variant-caps: all-small-caps; } .articleDatelineTitle a:link, .articleDatelineTitle a:visited { @@ -122,19 +128,33 @@ body > .systemMessage { } .externalLink { - margin-bottom: 5px; + margin-top: 15px; + margin-bottom: 15px; + font-size: 0.875em; font-style: italic; + color: var(--article-date-color); width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.externalLink a { + font-family: "SF Mono", Menlo, Courier, monospace; + font-size: 0.85em; + font-variant-caps: normal; + letter-spacing: 0em; +} + .articleBody { margin-top: 20px; line-height: 1.6em; } +.articleBody a { + padding: 0px 1px; +} + h1 { line-height: 1.15em; font-weight: bold; @@ -149,6 +169,7 @@ pre { overflow-y: hidden; word-wrap: normal; word-break: normal; + border-radius: 3px; } pre { @@ -156,9 +177,15 @@ pre { } code, pre { - font-family: "SF Mono", Menlo, "Courier New", Courier, monospace; + font-family: "SF Mono Regular", Menlo, Courier, monospace; font-size: 1em; -webkit-hyphens: none; + background: var(--code-background-color); +} + +code { + padding: 1px 2px; + border-radius: 2px; } pre code { @@ -365,7 +392,8 @@ a.footnote:hover, font-size: [[font-size]]px; --primary-accent-color: #086AEE; --secondary-accent-color: #086AEE; - --block-quote-border-color: rgba(8, 106, 238, 0.75); + --block-quote-border-color: rgba(0, 0, 0, 0.25); + --ios-hover-color: lightgray; /* placeholder */ } @media(prefers-color-scheme: dark) { @@ -374,6 +402,7 @@ a.footnote:hover, --secondary-accent-color: #5E9EF4; --block-quote-border-color: rgba(94, 158, 244, 0.75); --header-table-border-color: rgba(255, 255, 255, 0.2); + --ios-hover-color: #444444; /* placeholder */ } } @@ -381,17 +410,37 @@ a.footnote:hover, color: var(--secondary-accent-color); } + .externalLink a { + font-size: inherit; + } + + .articleBody a:link, .articleBody a:visited { + text-decoration: underline; + text-decoration-color: var(--primary-accent-color); + text-decoration-thickness: 1px; + text-underline-offset: 2px; + color: var(--secondary-accent-color); + } + + .articleBody sup a:link, .articleBody sup a:visited { + text-decoration: none; + color: var(--sup-link-color); + } + body .header { font: -apple-system-body; font-size: [[font-size]]px; } body .header a:link, body .header a:visited { - color: var(--primary-accent-color); + color: var(--secondary-accent-color); + } + + .articleBody a:hover { + background: var(--ios-hover-color); } pre { - border: 1px solid var(--secondary-accent-color); padding: 5px; } @@ -434,15 +483,19 @@ a.footnote:hover, :root { color-scheme: light dark; - --accent-color: rgba(8, 106, 238, 1); - --block-quote-border-color: rgba(8, 106, 238, .50); + --accent-color: rgba( 8, 106, 238, 1); + --block-quote-border-color: rgba( 0, 0, 0, 0.25); + --hover-gradient-color-start: rgba(60, 146, 251, 1); + --hover-gradient-color-end: rgba(67, 149, 251, 1); } @media(prefers-color-scheme: dark) { :root { - --accent-color: rgba(94, 158, 244, 1); - --block-quote-border-color: rgba(94, 158, 244, .50); + --accent-color: rgba( 94, 158, 244, 1); + --block-quote-border-color: rgba( 94, 158, 244, 0.50); --header-table-border-color: rgba(255, 255, 255, 0.1); + --hover-gradient-color-start: rgba( 41, 121, 213, 1); + --hover-gradient-color-end: rgba( 42, 120, 212, 1); } } @@ -450,13 +503,22 @@ a.footnote:hover, color: var(--accent-color); } + .articleBody a:link, .articleBody a:visited { + border-bottom: 1px solid var(--accent-color); + } + .articleBody a:hover { + border-radius: 2px; + background: linear-gradient(0deg, var(--hover-gradient-color-start) 0%, var(--hover-gradient-color-end) 100%); + border-bottom: 1px solid var(--hover-gradient-color-end); + color: white; + text-decoration: none; + } + pre { - border: 1px solid var(--accent-color); padding: 10px; } .nnw-overflow table { border: 1px solid var(--accent-color); } - } diff --git a/Shared/Extensions/RSImage-AppIcons.swift b/Shared/Extensions/RSImage-AppIcons.swift index df5670732..f464eef33 100644 --- a/Shared/Extensions/RSImage-AppIcons.swift +++ b/Shared/Extensions/RSImage-AppIcons.swift @@ -33,4 +33,6 @@ extension IconImage { } return nil }() + + static let nnwFeedIcon = IconImage(AppAssets.nnwFeedIcon) } diff --git a/Shared/Favicons/FaviconDownloader.swift b/Shared/Favicons/FaviconDownloader.swift index 5cde5b555..aafdf5ed5 100644 --- a/Shared/Favicons/FaviconDownloader.swift +++ b/Shared/Favicons/FaviconDownloader.swift @@ -11,6 +11,8 @@ import CoreServices import Articles import Account import RSCore +import RSWeb +import RSParser import UniformTypeIdentifiers extension Notification.Name { @@ -121,7 +123,7 @@ final class FaviconDownloader { if let url = URL(string: homePageURL) { if url.host == "nnw.ranchero.com" || url.host == "netnewswire.blog" { - return IconImage.appIcon + return IconImage.nnwFeedIcon } } @@ -133,15 +135,13 @@ final class FaviconDownloader { return favicon(with: faviconURL, homePageURL: url) } - findFaviconURLs(with: url) { (faviconURLs) in - if let faviconURLs = faviconURLs { - // If the site explicitly specifies favicon.ico, it will appear twice. - self.currentHomePageHasOnlyFaviconICO = faviconURLs.count == 1 + if let faviconURLs = findFaviconURLs(with: url) { + // If the site explicitly specifies favicon.ico, it will appear twice. + self.currentHomePageHasOnlyFaviconICO = faviconURLs.count == 1 - if let firstIconURL = faviconURLs.first { - let _ = self.favicon(with: firstIconURL, homePageURL: url) - self.remainingFaviconURLs[url] = faviconURLs.dropFirst() - } + if let firstIconURL = faviconURLs.first { + let _ = self.favicon(with: firstIconURL, homePageURL: url) + self.remainingFaviconURLs[url] = faviconURLs.dropFirst() } } @@ -197,31 +197,22 @@ private extension FaviconDownloader { static let localeForLowercasing = Locale(identifier: "en_US") - func findFaviconURLs(with homePageURL: String, _ completion: @escaping ([String]?) -> Void) { + func findFaviconURLs(with homePageURL: String) -> [String]? { guard let url = URL(string: homePageURL) else { - completion(nil) - return + return nil + } + guard let htmlMetadata = HTMLMetadataDownloader.shared.cachedMetadata(for: homePageURL) else { + return nil + } + let faviconURLs = htmlMetadata.usableFaviconURLs() ?? [String]() + + guard let scheme = url.scheme, let host = url.host else { + return faviconURLs.isEmpty ? nil : faviconURLs } - FaviconURLFinder.findFaviconURLs(with: homePageURL) { (faviconURLs) in - guard var faviconURLs = faviconURLs else { - completion(nil) - return - } - - var defaultFaviconURL: String? = nil - - if let scheme = url.scheme, let host = url.host { - defaultFaviconURL = "\(scheme)://\(host)/favicon.ico".lowercased(with: FaviconDownloader.localeForLowercasing) - } - - if let defaultFaviconURL = defaultFaviconURL { - faviconURLs.append(defaultFaviconURL) - } - - completion(faviconURLs) - } + let defaultFaviconURL = "\(scheme)://\(host)/favicon.ico".lowercased(with: FaviconDownloader.localeForLowercasing) + return faviconURLs + [defaultFaviconURL] } func faviconDownloader(withURL faviconURL: String, homePageURL: String?) -> SingleFaviconDownloader { @@ -312,5 +303,35 @@ private extension FaviconDownloader { assertionFailure(error.localizedDescription) } } - +} + +private extension RSHTMLMetadata { + + func usableFaviconURLs() -> [String]? { + + favicons.compactMap { favicon in + shouldAllowFavicon(favicon) ? favicon.urlString : nil + } + } + + static let ignoredTypes = [UTType.svg] + + private func shouldAllowFavicon(_ favicon: RSHTMLMetadataFavicon) -> Bool { + + // Check mime type. + if let mimeType = favicon.type, let utType = UTType(mimeType: mimeType) { + if Self.ignoredTypes.contains(utType) { + return false + } + } + + // Check file extension. + if let urlString = favicon.urlString, let url = URL(string: urlString), let utType = UTType(filenameExtension: url.pathExtension) { + if Self.ignoredTypes.contains(utType) { + return false + } + } + + return true + } } diff --git a/Shared/Favicons/SingleFaviconDownloader.swift b/Shared/Favicons/SingleFaviconDownloader.swift index 1b82802f6..67e30dc19 100644 --- a/Shared/Favicons/SingleFaviconDownloader.swift +++ b/Shared/Favicons/SingleFaviconDownloader.swift @@ -139,7 +139,7 @@ private extension SingleFaviconDownloader { return } - downloadUsingCache(url) { (data, response, error) in + Downloader.shared.download(url) { (data, response, error) in if let data = data, !data.isEmpty, let response = response, response.statusIsOK, error == nil { self.saveToDisk(data) diff --git a/Shared/IconImageCache.swift b/Shared/IconImageCache.swift index c9bf2b008..f0f62beaa 100644 --- a/Shared/IconImageCache.swift +++ b/Shared/IconImageCache.swift @@ -84,7 +84,7 @@ private extension IconImageCache { if let iconImage = feedIconImageCache[feedID] { return iconImage } - if let iconImage = appDelegate.feedIconDownloader.icon(for: feed) { + if let iconImage = FeedIconDownloader.shared.icon(for: feed) { feedIconImageCache[feedID] = iconImage return iconImage } @@ -120,7 +120,7 @@ private extension IconImageCache { if let iconImage = authorIconImageCache[author] { return iconImage } - if let iconImage = appDelegate.authorAvatarDownloader.image(for: author) { + if let iconImage = AuthorAvatarDownloader.shared.image(for: author) { authorIconImageCache[author] = iconImage return iconImage } diff --git a/Shared/Images/AuthorAvatarDownloader.swift b/Shared/Images/AuthorAvatarDownloader.swift index c69077c0d..471325d05 100644 --- a/Shared/Images/AuthorAvatarDownloader.swift +++ b/Shared/Images/AuthorAvatarDownloader.swift @@ -17,13 +17,14 @@ extension Notification.Name { final class AuthorAvatarDownloader { - private let imageDownloader: ImageDownloader + public static let shared = AuthorAvatarDownloader() + + private let imageDownloader = ImageDownloader.shared private var cache = [String: IconImage]() // avatarURL: RSImage private var waitingForAvatarURLs = Set() - init(imageDownloader: ImageDownloader) { + init() { - self.imageDownloader = imageDownloader NotificationCenter.default.addObserver(self, selector: #selector(imageDidBecomeAvailable(_:)), name: .ImageDidBecomeAvailable, object: imageDownloader) } diff --git a/Shared/Images/FeedIconDownloader.swift b/Shared/Images/FeedIconDownloader.swift index 637332e78..80aa65f7b 100644 --- a/Shared/Images/FeedIconDownloader.swift +++ b/Shared/Images/FeedIconDownloader.swift @@ -15,60 +15,33 @@ import Parser extension Notification.Name { - static let FeedIconDidBecomeAvailable = Notification.Name("FeedIconDidBecomeAvailableNotification") // UserInfoKey.feed + static let feedIconDidBecomeAvailable = Notification.Name("FeedIconDidBecomeAvailable") // UserInfoKey.feed } public final class FeedIconDownloader { + public static let shared = FeedIconDownloader() + + private let imageDownloader = ImageDownloader.shared private static let saveQueue = CoalescingQueue(name: "Cache Save Queue", interval: 1.0) private let imageDownloader: ImageDownloader private var feedURLToIconURLCache = [String: String]() - private var feedURLToIconURLCachePath: String + private var feedURLToIconURLCachePath: URL private var feedURLToIconURLCacheDirty = false { didSet { queueSaveFeedURLToIconURLCacheIfNeeded() } } - - private var homePageToIconURLCache = [String: String]() - private var homePageToIconURLCachePath: String - private var homePageToIconURLCacheDirty = false { - didSet { - queueSaveHomePageToIconURLCacheIfNeeded() - } - } - - private var homePagesWithNoIconURLCache = Set() - private var homePagesWithNoIconURLCachePath: String - private var homePagesWithNoIconURLCacheDirty = false { - didSet { - queueHomePagesWithNoIconURLCacheIfNeeded() - } - } - private var homePagesWithUglyIcons: Set = { - return Set(["https://www.macsparky.com/", "https://xkcd.com/"]) - }() - - private var urlsInProgress = Set() - private var cache = [Feed: IconImage]() - private var waitingForFeedURLs = [String: Feed]() - - init(imageDownloader: ImageDownloader, folder: String) { - self.imageDownloader = imageDownloader - self.feedURLToIconURLCachePath = (folder as NSString).appendingPathComponent("FeedURLToIconURLCache.plist") - self.homePageToIconURLCachePath = (folder as NSString).appendingPathComponent("HomePageToIconURLCache.plist") - self.homePagesWithNoIconURLCachePath = (folder as NSString).appendingPathComponent("HomePagesWithNoIconURLCache.plist") + init() { + + let folder = AppConfig.cacheSubfolder(named: "FeedIcons") + self.feedURLToIconURLCachePath = folder.appendingPathComponent("FeedURLToIconURLCache.plist") loadFeedURLToIconURLCache() - loadHomePageToIconURLCache() - loadHomePagesWithNoIconURLCache() - NotificationCenter.default.addObserver(self, selector: #selector(imageDidBecomeAvailable(_:)), name: .ImageDidBecomeAvailable, object: imageDownloader) - } - func resetCache() { - cache = [Feed: IconImage]() + NotificationCenter.default.addObserver(self, selector: #selector(imageDidBecomeAvailable(_:)), name: .ImageDidBecomeAvailable, object: imageDownloader) } func icon(for feed: Feed) -> IconImage? { @@ -77,18 +50,22 @@ public final class FeedIconDownloader { return cachedImage } - if let hpURLString = feed.homePageURL, let hpURL = URL(string: hpURLString), (hpURL.host == "nnw.ranchero.com" || hpURL.host == "netnewswire.blog") { - return IconImage.appIcon + if let homePageURLString = feed.homePageURL, let homePageURL = URL(string: homePageURLString), (homePageURL.host == "nnw.ranchero.com" || homePageURL.host == "netnewswire.blog") { + return IconImage.nnwFeedIcon } func checkHomePageURL() { guard let homePageURL = feed.homePageURL else { return } - icon(forHomePageURL: homePageURL, feed: feed) { (image) in - if let image = image { - self.postFeedIconDidBecomeAvailableNotification(feed) + if homePagesWithNoIconURL.contains(homePageURL) { + return + } + icon(forHomePageURL: homePageURL, feed: feed) { image, iconURL in + if let image, let iconURL { self.cache[feed] = IconImage(image) + self.cacheIconURLForFeedURL(iconURL: iconURL, feedURL: feed.url) + self.postFeedIconDidBecomeAvailableNotification(feed) } } } @@ -97,8 +74,9 @@ public final class FeedIconDownloader { if let iconURL = feed.iconURL { icon(forURL: iconURL, feed: feed) { (image) in if let image = image { - self.postFeedIconDidBecomeAvailableNotification(feed) self.cache[feed] = IconImage(image) + self.cacheIconURLForFeedURL(iconURL: iconURL, feedURL: feed.url) + self.postFeedIconDidBecomeAvailableNotification(feed) } else { checkHomePageURL() } @@ -107,68 +85,62 @@ public final class FeedIconDownloader { checkHomePageURL() } } - - if let feedProviderURL = feedURLToIconURLCache[feed.url] { - self.icon(forURL: feedProviderURL, feed: feed) { (image) in - if let image = image { + + if let previouslyFoundIconURL = feedURLToIconURLCache[feed.url] { + icon(forURL: previouslyFoundIconURL, feed: feed) { image in + if let image { self.postFeedIconDidBecomeAvailableNotification(feed) self.cache[feed] = IconImage(image) } } + return nil } - + checkFeedIconURL() return nil } @objc func imageDidBecomeAvailable(_ note: Notification) { - guard let url = note.userInfo?[UserInfoKey.url] as? String, let feed = waitingForFeedURLs[url] else { + guard let url = note.userInfo?[UserInfoKey.url] as? String, let feed = waitingForFeedURLs[url] else { return } waitingForFeedURLs[url] = nil _ = icon(for: feed) } - - @objc func saveFeedURLToIconURLCacheIfNeeded() { - if feedURLToIconURLCacheDirty { - saveFeedURLToIconURLCache() - } - } - - @objc func saveHomePageToIconURLCacheIfNeeded() { - if homePageToIconURLCacheDirty { - saveHomePageToIconURLCache() - } - } - - @objc func saveHomePagesWithNoIconURLCacheIfNeeded() { - if homePagesWithNoIconURLCacheDirty { - saveHomePagesWithNoIconURLCache() - } - } - } private extension FeedIconDownloader { - func icon(forHomePageURL homePageURL: String, feed: Feed, _ imageResultBlock: @escaping (RSImage?) -> Void) { + static let homePagesWithUglyIcons: Set = Set(["https://www.macsparky.com/", "https://xkcd.com/"]) - if homePagesWithNoIconURLCache.contains(homePageURL) || homePagesWithUglyIcons.contains(homePageURL) { - imageResultBlock(nil) + func icon(forHomePageURL homePageURL: String, feed: Feed, _ resultBlock: @escaping (RSImage?, String?) -> Void) { + + if homePagesWithNoIconURL.contains(homePageURL) || Self.homePagesWithUglyIcons.contains(homePageURL) { + resultBlock(nil, nil) return } - if let iconURL = cachedIconURL(for: homePageURL) { - icon(forURL: iconURL, feed: feed, imageResultBlock) + guard let metadata = HTMLMetadataDownloader.shared.cachedMetadata(for: homePageURL) else { + resultBlock(nil, nil) return } - findIconURLForHomePageURL(homePageURL, feed: feed) + if let url = metadata.bestWebsiteIconURL() { + homePagesWithNoIconURL.remove(homePageURL) + icon(forURL: url, feed: feed) { image in + resultBlock(image, url) + } + return + } + + homePagesWithNoIconURL.insert(homePageURL) + resultBlock(nil, nil) } func icon(forURL url: String, feed: Feed, _ imageResultBlock: @escaping (RSImage?) -> Void) { + waitingForFeedURLs[url] = feed guard let imageData = imageDownloader.image(for: url) else { imageResultBlock(nil) @@ -181,132 +153,49 @@ private extension FeedIconDownloader { DispatchQueue.main.async { let userInfo: [AnyHashable: Any] = [UserInfoKey.feed: feed] - NotificationCenter.default.post(name: .FeedIconDidBecomeAvailable, object: self, userInfo: userInfo) + NotificationCenter.default.post(name: .feedIconDidBecomeAvailable, object: self, userInfo: userInfo) } } - func cachedIconURL(for homePageURL: String) -> String? { + func cacheIconURLForFeedURL(iconURL: String, feedURL: String) { - return homePageToIconURLCache[homePageURL] + feedURLToIconURLCache[feedURL] = iconURL + feedURLToIconURLCacheDirty = true } - func cacheIconURL(for homePageURL: String, _ iconURL: String) { - homePagesWithNoIconURLCache.remove(homePageURL) - homePagesWithNoIconURLCacheDirty = true - homePageToIconURLCache[homePageURL] = iconURL - homePageToIconURLCacheDirty = true - } - - func findIconURLForHomePageURL(_ homePageURL: String, feed: Feed) { - - guard !urlsInProgress.contains(homePageURL) else { - return - } - urlsInProgress.insert(homePageURL) - - HTMLMetadataDownloader.downloadMetadata(for: homePageURL) { (metadata) in - - self.urlsInProgress.remove(homePageURL) - guard let metadata = metadata else { - return - } - self.pullIconURL(from: metadata, homePageURL: homePageURL, feed: feed) - } - } - - func pullIconURL(from metadata: HTMLMetadata, homePageURL: String, feed: Feed) { - - if let url = metadata.bestWebsiteIconURL() { - cacheIconURL(for: homePageURL, url) - icon(forURL: url, feed: feed) { (image) in - } - return - } - - homePagesWithNoIconURLCache.insert(homePageURL) - homePagesWithNoIconURLCacheDirty = true - } - func loadFeedURLToIconURLCache() { - let url = URL(fileURLWithPath: feedURLToIconURLCachePath) - guard let data = try? Data(contentsOf: url) else { + + guard let data = try? Data(contentsOf: feedURLToIconURLCachePath) else { return } let decoder = PropertyListDecoder() feedURLToIconURLCache = (try? decoder.decode([String: String].self, from: data)) ?? [String: String]() } - func loadHomePageToIconURLCache() { - let url = URL(fileURLWithPath: homePageToIconURLCachePath) - guard let data = try? Data(contentsOf: url) else { - return - } - let decoder = PropertyListDecoder() - homePageToIconURLCache = (try? decoder.decode([String: String].self, from: data)) ?? [String: String]() - } + @objc func saveFeedURLToIconURLCacheIfNeeded() { - func loadHomePagesWithNoIconURLCache() { - let url = URL(fileURLWithPath: homePagesWithNoIconURLCachePath) - guard let data = try? Data(contentsOf: url) else { - return + assert(Thread.isMainThread) + if feedURLToIconURLCacheDirty { + saveFeedURLToIconURLCache() } - let decoder = PropertyListDecoder() - let decoded = (try? decoder.decode([String].self, from: data)) ?? [String]() - homePagesWithNoIconURLCache = Set(decoded) } func queueSaveFeedURLToIconURLCacheIfNeeded() { + + assert(Thread.isMainThread) FeedIconDownloader.saveQueue.add(self, #selector(saveFeedURLToIconURLCacheIfNeeded)) } - func queueSaveHomePageToIconURLCacheIfNeeded() { - FeedIconDownloader.saveQueue.add(self, #selector(saveHomePageToIconURLCacheIfNeeded)) - } - - func queueHomePagesWithNoIconURLCacheIfNeeded() { - FeedIconDownloader.saveQueue.add(self, #selector(saveHomePagesWithNoIconURLCacheIfNeeded)) - } - func saveFeedURLToIconURLCache() { feedURLToIconURLCacheDirty = false let encoder = PropertyListEncoder() encoder.outputFormat = .binary - let url = URL(fileURLWithPath: feedURLToIconURLCachePath) do { let data = try encoder.encode(feedURLToIconURLCache) - try data.write(to: url) + try data.write(to: feedURLToIconURLCachePath) } catch { assertionFailure(error.localizedDescription) } } - - func saveHomePageToIconURLCache() { - homePageToIconURLCacheDirty = false - - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - let url = URL(fileURLWithPath: homePageToIconURLCachePath) - do { - let data = try encoder.encode(homePageToIconURLCache) - try data.write(to: url) - } catch { - assertionFailure(error.localizedDescription) - } - } - - func saveHomePagesWithNoIconURLCache() { - homePagesWithNoIconURLCacheDirty = false - - let encoder = PropertyListEncoder() - encoder.outputFormat = .binary - let url = URL(fileURLWithPath: homePagesWithNoIconURLCachePath) - do { - let data = try encoder.encode(Array(homePagesWithNoIconURLCache)) - try data.write(to: url) - } catch { - assertionFailure(error.localizedDescription) - } - } - } diff --git a/Shared/Images/ImageDownloader.swift b/Shared/Images/ImageDownloader.swift index 9a549d3ef..3800a4113 100644 --- a/Shared/Images/ImageDownloader.swift +++ b/Shared/Images/ImageDownloader.swift @@ -18,20 +18,21 @@ extension Notification.Name { final class ImageDownloader { + public static let shared = ImageDownloader() + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "ImageDownloader") - private let folder: String private var diskCache: BinaryDiskCache private let queue: DispatchQueue private var imageCache = [String: Data]() // url: image private var urlsInProgress = Set() private var badURLs = Set() // That return a 404 or whatever. Just skip them in the future. - init(folder: String) { + init() { - self.folder = folder - self.diskCache = BinaryDiskCache(folder: folder) - self.queue = DispatchQueue(label: "ImageDownloader serial queue - \(folder)") + let folder = AppConfig.cacheSubfolder(named: "Images") + self.diskCache = BinaryDiskCache(folder: folder.path) + self.queue = DispatchQueue(label: "ImageDownloader serial queue - \(folder.path)") } @discardableResult @@ -103,7 +104,7 @@ private extension ImageDownloader { return } - downloadUsingCache(imageURL) { (data, response, error) in + Downloader.shared.download(imageURL) { (data, response, error) in if let data = data, !data.isEmpty, let response = response, response.statusIsOK, error == nil { self.saveToDisk(url, data) diff --git a/Shared/Images/RSHTMLMetadata+Extension.swift b/Shared/Images/RSHTMLMetadata+Extension.swift new file mode 100644 index 000000000..e69de29bb diff --git a/Shared/Importers/DefaultFeeds.opml b/Shared/Importers/DefaultFeeds.opml index 6b2591ba4..18d9be502 100644 --- a/Shared/Importers/DefaultFeeds.opml +++ b/Shared/Importers/DefaultFeeds.opml @@ -4,6 +4,7 @@ Default Feeds + @@ -11,8 +12,11 @@ + + + diff --git a/Shared/Timer/AccountRefreshTimer.swift b/Shared/Timer/AccountRefreshTimer.swift index 04c944a5a..bcf102ac0 100644 --- a/Shared/Timer/AccountRefreshTimer.swift +++ b/Shared/Timer/AccountRefreshTimer.swift @@ -9,7 +9,7 @@ import Foundation import Account -class AccountRefreshTimer { +final class AccountRefreshTimer { var shuttingDown = false @@ -73,8 +73,6 @@ class AccountRefreshTimer { lastTimedRefresh = Date() update() - //AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) - AccountManager.shared.refreshAll(completion: nil) + AccountManager.shared.refreshAll() } - } diff --git a/Shared/UniformTypeIdentifiers+Extras.swift b/Shared/UniformTypeIdentifiers+Extras.swift index 4efadc760..ff1f91092 100644 --- a/Shared/UniformTypeIdentifiers+Extras.swift +++ b/Shared/UniformTypeIdentifiers+Extras.swift @@ -10,5 +10,5 @@ import UniformTypeIdentifiers extension UTType { - static let opml = UTType("public.opml")! + static let opml = UTType(filenameExtension: "opml", conformingTo: UTType.xml)! } diff --git a/Shared/Widget/WidgetData.swift b/Shared/Widget/WidgetData.swift index 5b6cf9655..ac71969f6 100644 --- a/Shared/Widget/WidgetData.swift +++ b/Shared/Widget/WidgetData.swift @@ -9,7 +9,7 @@ import Foundation struct WidgetData: Codable { - + let currentUnreadCount: Int let currentTodayCount: Int let currentStarredCount: Int @@ -17,16 +17,17 @@ struct WidgetData: Codable { let starredArticles: [LatestArticle] let todayArticles: [LatestArticle] let lastUpdateTime: Date - + } struct LatestArticle: Codable, Identifiable { - + var id: String let feedTitle: String let articleTitle: String? let articleSummary: String? let feedIconPath: String? // Path to image data in shared container. let pubDate: String - + } + diff --git a/Shared/Widget/WidgetDataEncoder.swift b/Shared/Widget/WidgetDataEncoder.swift index 3f36dde57..4d7c6ebb5 100644 --- a/Shared/Widget/WidgetDataEncoder.swift +++ b/Shared/Widget/WidgetDataEncoder.swift @@ -16,51 +16,53 @@ import Account public final class WidgetDataEncoder { - + private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Application") private let fetchLimit = 7 - + private lazy var appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String private lazy var containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) private lazy var imageContainer = containerURL?.appendingPathComponent("widgetImages", isDirectory: true) private lazy var dataURL = containerURL?.appendingPathComponent("widget-data.json") - + public var isRunning = false - + init () { if imageContainer != nil { try? FileManager.default.createDirectory(at: imageContainer!, withIntermediateDirectories: true, attributes: nil) } } - + func encode() { - isRunning = true - - flushSharedContainer() - os_log(.debug, log: log, "Starting encoding widget data.") - - DispatchQueue.main.async { - self.encodeWidgetData() { latestData in - guard let latestData = latestData else { + if #available(iOS 14, *) { + isRunning = true + + flushSharedContainer() + os_log(.debug, log: log, "Starting encoding widget data.") + + DispatchQueue.main.async { + self.encodeWidgetData() { latestData in + guard let latestData = latestData else { + self.isRunning = false + return + } + + let encodedData = try? JSONEncoder().encode(latestData) + + os_log(.debug, log: self.log, "Finished encoding widget data.") + + if self.fileExists() { + try? FileManager.default.removeItem(at: self.dataURL!) + os_log(.debug, log: self.log, "Removed widget data from container.") + } + + if FileManager.default.createFile(atPath: self.dataURL!.path, contents: encodedData, attributes: nil) { + os_log(.debug, log: self.log, "Wrote widget data to container.") + WidgetCenter.shared.reloadAllTimelines() + } + self.isRunning = false - return } - - let encodedData = try? JSONEncoder().encode(latestData) - - os_log(.debug, log: self.log, "Finished encoding widget data.") - - if self.fileExists() { - try? FileManager.default.removeItem(at: self.dataURL!) - os_log(.debug, log: self.log, "Removed widget data from container.") - } - - if FileManager.default.createFile(atPath: self.dataURL!.path, contents: encodedData, attributes: nil) { - os_log(.debug, log: self.log, "Wrote widget data to container.") - WidgetCenter.shared.reloadAllTimelines() - } - - self.isRunning = false } } } @@ -68,7 +70,7 @@ public final class WidgetDataEncoder { private func encodeWidgetData(completion: @escaping (WidgetData?) -> Void) { let dispatchGroup = DispatchGroup() var groupError: Error? = nil - + var unread = [LatestArticle]() dispatchGroup.enter() @@ -89,9 +91,9 @@ public final class WidgetDataEncoder { } dispatchGroup.leave() } - + var starred = [LatestArticle]() - + dispatchGroup.enter() AccountManager.shared.fetchArticlesAsync(.starred(fetchLimit)) { (articleSetResult) in switch articleSetResult { @@ -147,13 +149,13 @@ public final class WidgetDataEncoder { completion(latestData) } } - + } - + private func fileExists() -> Bool { FileManager.default.fileExists(atPath: dataURL!.path) } - + private func writeImageDataToSharedContainer(_ imageData: Data?) -> String? { if imageData == nil { return nil } // Each image gets a UUID @@ -166,16 +168,17 @@ public final class WidgetDataEncoder { return nil } } - + return nil } - + private func flushSharedContainer() { if let imageContainer = imageContainer { try? FileManager.default.removeItem(atPath: imageContainer.path) try? FileManager.default.createDirectory(at: imageContainer, withIntermediateDirectories: true, attributes: nil) } } - + } + diff --git a/Tests/NetNewsWireTests/ScriptingTests/scripts/testFeedExists.applescript b/Tests/NetNewsWireTests/ScriptingTests/scripts/testFeedExists.applescript index cc505a06f..098c1ff61 100644 --- a/Tests/NetNewsWireTests/ScriptingTests/scripts/testFeedExists.applescript +++ b/Tests/NetNewsWireTests/ScriptingTests/scripts/testFeedExists.applescript @@ -1,7 +1,7 @@ -- this script just tests that no error was generated from the script try tell application "NetNewsWire" - exists webfeed 1 of account 1 + exists feed 1 of account 1 end tell on error message return {test_result:false, script_result:message} diff --git a/Tests/NetNewsWireTests/ScriptingTests/scripts/testFeedOPML.applescript b/Tests/NetNewsWireTests/ScriptingTests/scripts/testFeedOPML.applescript index 9adc7709c..5d28a8604 100644 --- a/Tests/NetNewsWireTests/ScriptingTests/scripts/testFeedOPML.applescript +++ b/Tests/NetNewsWireTests/ScriptingTests/scripts/testFeedOPML.applescript @@ -1,7 +1,7 @@ -- this script just tests that no error was generated from the script try tell application "NetNewsWire" - opml representation of webfeed 1 of account 1 + opml representation of feed 1 of account 1 end tell on error message return {test_result:false, script_result:message} diff --git a/Tests/NetNewsWireTests/ScriptingTests/scripts/testNameAndUrlOfEveryFeed.applescript b/Tests/NetNewsWireTests/ScriptingTests/scripts/testNameAndUrlOfEveryFeed.applescript index 38c135450..86268914b 100644 --- a/Tests/NetNewsWireTests/ScriptingTests/scripts/testNameAndUrlOfEveryFeed.applescript +++ b/Tests/NetNewsWireTests/ScriptingTests/scripts/testNameAndUrlOfEveryFeed.applescript @@ -1,7 +1,7 @@ -- this script just tests that no error was generated from the script try tell application "NetNewsWire" - {name, url} of every webfeed of every account + {name, url} of every feed of every account end tell on error message return {test_result:false, script_result:message} diff --git a/Tests/NetNewsWireTests/ScriptingTests/scripts/testNameOfAuthors.applescript b/Tests/NetNewsWireTests/ScriptingTests/scripts/testNameOfAuthors.applescript index c3948e55e..db15bab30 100644 --- a/Tests/NetNewsWireTests/ScriptingTests/scripts/testNameOfAuthors.applescript +++ b/Tests/NetNewsWireTests/ScriptingTests/scripts/testNameOfAuthors.applescript @@ -2,7 +2,7 @@ -- and that the returned list is greater than 0 try tell application "NetNewsWire" - set namesResult to name of every author of every webfeed of every account + set namesResult to name of every author of every feed of every account end tell set test_result to ((count items of namesResult) > 0) on error message diff --git a/Tests/NetNewsWireTests/ScriptingTests/scripts/testTitleOfArticlesWhose.applescript b/Tests/NetNewsWireTests/ScriptingTests/scripts/testTitleOfArticlesWhose.applescript index 04818ca2b..256e20c08 100644 --- a/Tests/NetNewsWireTests/ScriptingTests/scripts/testTitleOfArticlesWhose.applescript +++ b/Tests/NetNewsWireTests/ScriptingTests/scripts/testTitleOfArticlesWhose.applescript @@ -1,7 +1,7 @@ -- this script just tests that no error was generated from the script try tell application "NetNewsWire" - title of every article of webfeed "Six Colors" where read is true + title of every article of feed "Six Colors" where read is true end tell on error message return {test_result:false, script_result:message} diff --git a/Widget/Resources/Localizable.stringsdict b/Widget/Resources/Localizable.stringsdict index 59c3ad26e..9fcbb129d 100644 --- a/Widget/Resources/Localizable.stringsdict +++ b/Widget/Resources/Localizable.stringsdict @@ -51,9 +51,9 @@ zero No more recent articles one - + 1 more recent unread article + + 1 more recent article other - + %u more recent unread articles + + %u more recent articles LocalizedCount diff --git a/Widget/Resources/en.lproj/Localizable.strings b/Widget/Resources/en.lproj/Localizable.strings index fed487392..4c0e2ce01 100644 --- a/Widget/Resources/en.lproj/Localizable.strings +++ b/Widget/Resources/en.lproj/Localizable.strings @@ -8,28 +8,28 @@ /* Bundle */ "Unread_Widget_Title" = "Your Unread Articles"; -"Unread_Widget_Description" = "A sneak peek at your unread articles."; +"Unread_Widget_Description" = "See unread articles."; "Today_Widget_Title" = "Your Today Articles"; -"Today_Widget_Description" = "A sneak peek at recently published unread articles."; +"Today_Widget_Description" = "See the latest articles."; "Starred_Widget_Title" = "Your Starred Articles"; -"Starred_Widget_Description" = "A sneak peek at your starred articles."; +"Starred_Widget_Description" = "See your starred articles."; "SmartFeedSummary_Widget_Title" = "Your Smart Feed Summary"; "SmartFeedSummary_Widget_Description" = "Your smart feeds, summarized."; /* Unread Widget */ "Unread_Widget_NoItemsTitle" = "Unread"; -"Unread_Widget_NoItems" = "There are no unread articles left to read."; +"Unread_Widget_NoItems" = "There are no unread articles."; /* Today Widget */ "Today_Widget_NoItemsTitle" = "Today"; -"Today_Widget_NoItems" = "There are no recent unread articles left to read."; +"Today_Widget_NoItems" = "There are no recent articles."; /* Starred Widget */ "Starred_Widget_NoItemsTitle" = "Starred"; -"Starred_Widget_NoItems" = "When you mark articles as Starred, they'll appear here."; +"Starred_Widget_NoItems" = "There are no starred articles."; /* Smart Feed Summary Widget */ "Unread" = "Unread"; diff --git a/Widget/Shared Views/ArticleItemView.swift b/Widget/Shared Views/ArticleItemView.swift index d88a56932..e65528863 100644 --- a/Widget/Shared Views/ArticleItemView.swift +++ b/Widget/Shared Views/ArticleItemView.swift @@ -10,11 +10,11 @@ import SwiftUI import RSWeb struct ArticleItemView: View { - + var article: LatestArticle var deepLink: URL @State private var iconImage: Image? - + var body: some View { Link(destination: deepLink, label: { HStack(alignment: .top, spacing: nil, content: { @@ -22,10 +22,10 @@ struct ArticleItemView: View { if iconImage != nil { iconImage! .resizable() - .frame(width: 30, height: 30) + .frame(width: WidgetLayout.feedIconSize, height: WidgetLayout.feedIconSize) .cornerRadius(4) } - + // Title and Feed Name VStack(alignment: .leading) { Text(article.articleTitle ?? "Untitled") @@ -34,7 +34,7 @@ struct ArticleItemView: View { .lineLimit(1) .foregroundColor(.primary) .padding(.top, -3) - + HStack { Text(article.feedTitle) .font(.caption) @@ -52,33 +52,34 @@ struct ArticleItemView: View { iconImage = thumbnail(from: article.feedIconPath) } } - + func thumbnail(from path: String?) -> Image? { guard let imagePath = path else { - return Image(uiImage: UIImage(systemName: "globe")!) - } - - let url = URL(fileURLWithPath: imagePath) - - guard let data = try? Data(contentsOf: url), - let uiImage = UIImage(data: data) else { - return Image(uiImage: UIImage(systemName: "globe")!) - } - - return Image(uiImage: uiImage) + return Image(uiImage: UIImage(systemName: "globe")!) + } + + let url = URL(fileURLWithPath: imagePath) + + guard let data = try? Data(contentsOf: url), + let uiImage = UIImage(data: data) else { + return Image(uiImage: UIImage(systemName: "globe")!) + } + + return Image(uiImage: uiImage) } - + func pubDate(_ dateString: String) -> String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" guard let date = dateFormatter.date(from: dateString) else { return "" } - + let displayFormatter = DateFormatter() displayFormatter.dateStyle = .medium displayFormatter.timeStyle = .none - + return displayFormatter.string(from: date) } } + diff --git a/Widget/Widget Views/StarredWidget.swift b/Widget/Widget Views/StarredWidget.swift index cd3b3e72d..602c11307 100644 --- a/Widget/Widget Views/StarredWidget.swift +++ b/Widget/Widget Views/StarredWidget.swift @@ -10,12 +10,12 @@ import WidgetKit import SwiftUI struct StarredWidgetView : View { - + @Environment(\.widgetFamily) var family: WidgetFamily @Environment(\.sizeCategory) var sizeCategory: ContentSizeCategory - + var entry: Provider.Entry - + var body: some View { if entry.widgetData.starredArticles.count == 0 { inboxZero @@ -23,40 +23,28 @@ struct StarredWidgetView : View { } else { GeometryReader { metrics in - HStack { - VStack { - starredImage - .padding(.vertical, 12) - .padding(.leading, 8) - Spacer() - - } - } - .frame(width: metrics.size.width * 0.15) - - Spacer() - + starredImage + .frame(width: WidgetLayout.titleImageSize, alignment: .leading) VStack(alignment:.leading, spacing: 0) { ForEach(0.. Int { var reduceAccessibilityCount: Int = 0 if SizeCategories().isSizeCategoryLarge(category: sizeCategory) { reduceAccessibilityCount = 1 } - + if family == .systemLarge { return entry.widgetData.currentStarredCount >= 7 ? (7 - reduceAccessibilityCount) : entry.widgetData.currentStarredCount } return entry.widgetData.currentStarredCount >= 3 ? (3 - reduceAccessibilityCount) : entry.widgetData.currentStarredCount } - + var inboxZero: some View { VStack(alignment: .center) { Spacer() @@ -105,12 +92,12 @@ struct StarredWidgetView : View { .aspectRatio(contentMode: .fit) .frame(width: 30) .foregroundColor(.yellow) - + Text(L10n.starredWidgetNoItemsTitle) .font(.headline) .foregroundColor(.primary) - + Text(L10n.starredWidgetNoItems) .font(.caption) .foregroundColor(.gray) @@ -119,5 +106,5 @@ struct StarredWidgetView : View { .multilineTextAlignment(.center) .padding() } - + } diff --git a/Widget/Widget Views/TodayWidget.swift b/Widget/Widget Views/TodayWidget.swift index 5fa91c089..c5c1bc0eb 100644 --- a/Widget/Widget Views/TodayWidget.swift +++ b/Widget/Widget Views/TodayWidget.swift @@ -10,12 +10,12 @@ import WidgetKit import SwiftUI struct TodayWidgetView : View { - + @Environment(\.widgetFamily) var family: WidgetFamily @Environment(\.sizeCategory) var sizeCategory: ContentSizeCategory - + var entry: Provider.Entry - + var body: some View { if entry.widgetData.todayArticles.count == 0 { inboxZero @@ -23,40 +23,28 @@ struct TodayWidgetView : View { } else { GeometryReader { metrics in - HStack { - VStack { - todayImage - .padding(.vertical, 12) - .padding(.leading, 8) - Spacer() - - } - } - .frame(width: metrics.size.width * 0.15) - - Spacer() - + todayImage + .frame(width: WidgetLayout.titleImageSize, alignment: .leading) VStack(alignment:.leading, spacing: 0) { ForEach(0.. Int { var reduceAccessibilityCount: Int = 0 if SizeCategories().isSizeCategoryLarge(category: sizeCategory) { reduceAccessibilityCount = 1 } - + if family == .systemLarge { return entry.widgetData.todayArticles.count >= 7 ? (7 - reduceAccessibilityCount) : entry.widgetData.todayArticles.count } return entry.widgetData.todayArticles.count >= 3 ? (3 - reduceAccessibilityCount) : entry.widgetData.todayArticles.count } - + var inboxZero: some View { VStack(alignment: .center) { Spacer() @@ -104,12 +92,12 @@ struct TodayWidgetView : View { .aspectRatio(contentMode: .fit) .frame(width: 30) .foregroundColor(.orange) - + Text(L10n.todayWidgetNoItemsTitle) .font(.headline) .foregroundColor(.primary) - + Text(L10n.todayWidgetNoItems) .font(.caption) .foregroundColor(.gray) @@ -118,5 +106,5 @@ struct TodayWidgetView : View { .multilineTextAlignment(.center) .padding() } - + } diff --git a/Widget/Widget Views/UnreadWidget.swift b/Widget/Widget Views/UnreadWidget.swift index 626044b6b..84e0271ad 100644 --- a/Widget/Widget Views/UnreadWidget.swift +++ b/Widget/Widget Views/UnreadWidget.swift @@ -10,12 +10,12 @@ import WidgetKit import SwiftUI struct UnreadWidgetView : View { - + @Environment(\.widgetFamily) var family: WidgetFamily @Environment(\.sizeCategory) var sizeCategory: ContentSizeCategory - + var entry: Provider.Entry - + var body: some View { if entry.widgetData.currentUnreadCount == 0 { inboxZero @@ -23,40 +23,28 @@ struct UnreadWidgetView : View { } else { GeometryReader { metrics in - HStack { - VStack { - unreadImage - .padding(.vertical, 12) - .padding(.leading, 8) - Spacer() - - } - } - .frame(width: metrics.size.width * 0.15) - - Spacer() - + unreadImage + .frame(width: WidgetLayout.titleImageSize, alignment: .leading) VStack(alignment:.leading, spacing: 0) { ForEach(0.. Int { var reduceAccessibilityCount: Int = 0 if SizeCategories().isSizeCategoryLarge(category: sizeCategory) { reduceAccessibilityCount = 1 } - + if family == .systemLarge { return entry.widgetData.unreadArticles.count >= 7 ? (7 - reduceAccessibilityCount) : entry.widgetData.unreadArticles.count } return entry.widgetData.unreadArticles.count >= 3 ? (3 - reduceAccessibilityCount) : entry.widgetData.unreadArticles.count } - + var inboxZero: some View { VStack(alignment: .center) { Spacer() @@ -107,7 +94,7 @@ struct UnreadWidgetView : View { Text(L10n.unreadWidgetNoItemsTitle) .font(.headline) .foregroundColor(.primary) - + Text(L10n.unreadWidgetNoItems) .font(.caption) .foregroundColor(.gray) @@ -116,6 +103,6 @@ struct UnreadWidgetView : View { .multilineTextAlignment(.center) .padding() } - + } diff --git a/Widget/WidgetBundle.swift b/Widget/WidgetBundle.swift index f5060ed7e..4e17b9cd7 100644 --- a/Widget/WidgetBundle.swift +++ b/Widget/WidgetBundle.swift @@ -13,19 +13,19 @@ import SwiftUI struct UnreadWidget: Widget { let kind: String = "com.ranchero.NetNewsWire.UnreadWidget" - + var body: some WidgetConfiguration { - + return StaticConfiguration(kind: kind, provider: Provider(), content: { entry in UnreadWidgetView(entry: entry) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color("WidgetBackground")) - + .containerBackground(for: .widget) { + Color.clear + } }) .configurationDisplayName(L10n.unreadWidgetTitle) .description(L10n.unreadWidgetDescription) .supportedFamilies([.systemMedium, .systemLarge]) - } } @@ -37,13 +37,13 @@ struct TodayWidget: Widget { return StaticConfiguration(kind: kind, provider: Provider(), content: { entry in TodayWidgetView(entry: entry) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color("WidgetBackground")) - + .containerBackground(for: .widget) { + Color.clear + } }) .configurationDisplayName(L10n.todayWidgetTitle) .description(L10n.todayWidgetDescription) .supportedFamilies([.systemMedium, .systemLarge]) - } } @@ -55,35 +55,16 @@ struct StarredWidget: Widget { return StaticConfiguration(kind: kind, provider: Provider(), content: { entry in StarredWidgetView(entry: entry) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color("WidgetBackground")) - + .containerBackground(for: .widget) { + Color.clear + } }) .configurationDisplayName(L10n.starredWidgetTitle) .description(L10n.starredWidgetDescription) .supportedFamilies([.systemMedium, .systemLarge]) - } } -struct SmartFeedSummaryWidget: Widget { - let kind: String = "com.ranchero.NetNewsWire.SmartFeedSummaryWidget" - - var body: some WidgetConfiguration { - - return StaticConfiguration(kind: kind, provider: Provider(), content: { entry in - SmartFeedSummaryWidgetView(entry: entry) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color("AccentColor")) - - }) - .configurationDisplayName(L10n.smartFeedSummaryWidgetTitle) - .description(L10n.smartFeedSummaryWidgetDescription) - .supportedFamilies([.systemSmall]) - - } -} - - // MARK: - WidgetBundle @main struct NetNewsWireWidgets: WidgetBundle { diff --git a/iOS/Add/Add.storyboard b/iOS/Add/Add.storyboard index 6426ac674..3bb2051aa 100644 --- a/iOS/Add/Add.storyboard +++ b/iOS/Add/Add.storyboard @@ -11,7 +11,7 @@ - + @@ -158,7 +158,7 @@ - + diff --git a/iOS/MainFeed/MainFeedViewController.swift b/iOS/MainFeed/MainFeedViewController.swift index 95e49604d..a67fa074d 100644 --- a/iOS/MainFeed/MainFeedViewController.swift +++ b/iOS/MainFeed/MainFeedViewController.swift @@ -64,8 +64,8 @@ class MainFeedViewController: UITableViewController, UndoableCommandRunner { NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .FeedIconDidBecomeAvailable, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .FeedSettingDidChange, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .feedSettingDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil) diff --git a/iOS/MainTimeline/MainTimelineViewController.swift b/iOS/MainTimeline/MainTimelineViewController.swift index 99589a4b9..a606ee5aa 100644 --- a/iOS/MainTimeline/MainTimelineViewController.swift +++ b/iOS/MainTimeline/MainTimelineViewController.swift @@ -52,7 +52,7 @@ class MainTimelineViewController: UITableViewController, UndoableCommandRunner { NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .FeedIconDidBecomeAvailable, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) @@ -730,8 +730,8 @@ private extension MainTimelineViewController { let showFeedNames = coordinator.showFeedNames let showIcon = coordinator.showIcons && iconImage != nil - cell.cellData = MainTimelineCellData(article: article, showFeedName: showFeedNames, feedName: article.feed?.nameForDisplay, byline: article.byline(), iconImage: iconImage, showIcon: showIcon, featuredImage: featuredImage, numberOfLines: numberOfTextLines, iconSize: iconSize) - + cell.cellData = MainTimelineCellData(article: article, showFeedName: showFeedNames, feedName: article.feed?.nameForDisplay, byline: article.byline(), iconImage: iconImage, showIcon: showIcon, numberOfLines: numberOfTextLines, iconSize: iconSize) + } func iconImageFor(_ article: Article) -> IconImage? { diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 05aef192e..fe31e511c 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -69,10 +69,10 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { // Which Feeds have the Read Articles Filter enabled private var readFilterEnabledTable = [SidebarItemIdentifier: Bool]() - + // Flattened tree structure for the Sidebar private var shadowTable = [(sectionID: String, feedNodes: [FeedNode])]() - + private(set) var preSearchTimelineFeed: SidebarItem? private var lastSearchString = "" private var lastSearchScope: SearchScope? = nil @@ -924,6 +924,11 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { return } + isNavigationDisabled = true + defer { + isNavigationDisabled = false + } + if selectPrevUnreadArticleInTimeline() { return } @@ -1064,14 +1069,14 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { if isSearching { mainTimelineViewController?.hideSearch() } - + guard let account = feed.account else { completion?() return } - + let parentFolder = account.sortedFolders?.first(where: { $0.objectIsChild(feed) }) - + markExpanded(account) if let parentFolder = parentFolder { markExpanded(parentFolder) @@ -1089,10 +1094,10 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { self.selectFeed(nil) { if self.rootSplitViewController.traitCollection.horizontalSizeClass == .compact { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.selectFeed(webFeed, animations: animations, completion: completion) + self.selectFeed(feed, animations: animations, completion: completion) } } else { - self.selectFeed(webFeed, animations: animations, completion: completion) + self.selectFeed(feed, animations: animations, completion: completion) } } }) @@ -1549,7 +1554,7 @@ private extension SceneCoordinator { return ShadowTableChanges(deletes: deletes, inserts: inserts, moves: moves, rowChanges: changes) } - + func shadowTableContains(_ feed: SidebarItem) -> Bool { for section in shadowTable { for feedNode in section.feedNodes { @@ -2086,7 +2091,7 @@ private extension SceneCoordinator { } self.discloseFeed(feed, initialLoad: true) { - self.feedViewController.focus() + self.mainFeedViewController.focus() } } } diff --git a/iOS/SceneDelegate.swift b/iOS/SceneDelegate.swift index 916bd9b5e..fc757625a 100644 --- a/iOS/SceneDelegate.swift +++ b/iOS/SceneDelegate.swift @@ -13,7 +13,7 @@ import Account class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - var coordinator = SceneCoordinator() + var coordinator: SceneCoordinator! // UIWindowScene delegate diff --git a/xcconfig/NetNewsWire_iOSintentextension_target.xcconfig b/xcconfig/NetNewsWire_iOSintentextension_target.xcconfig index 52b19615c..a7e24f050 100644 --- a/xcconfig/NetNewsWire_iOSintentextension_target.xcconfig +++ b/xcconfig/NetNewsWire_iOSintentextension_target.xcconfig @@ -34,6 +34,7 @@ PROVISIONING_PROFILE_SPECIFIER = #include? "../../SharedXcodeSettings/DeveloperSettings.xcconfig" #include "./common/NetNewsWire_ios_target_common.xcconfig" +ENABLE_HARDENED_RUNTIME = YES LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../../Frameworks CODE_SIGN_ENTITLEMENTS = iOS/ShareExtension/NetNewsWire_iOS_ShareExtension.entitlements INFOPLIST_FILE = iOS/IntentsExtension/Info.plist diff --git a/xcconfig/NetNewsWire_iOSshareextension_target.xcconfig b/xcconfig/NetNewsWire_iOSshareextension_target.xcconfig index 8e17d8de9..e635e3a0e 100644 --- a/xcconfig/NetNewsWire_iOSshareextension_target.xcconfig +++ b/xcconfig/NetNewsWire_iOSshareextension_target.xcconfig @@ -34,6 +34,7 @@ PROVISIONING_PROFILE_SPECIFIER = #include? "../../SharedXcodeSettings/DeveloperSettings.xcconfig" #include "./common/NetNewsWire_ios_target_common.xcconfig" +ENABLE_HARDENED_RUNTIME = YES LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../../Frameworks CODE_SIGN_ENTITLEMENTS = iOS/ShareExtension/NetNewsWire_iOS_ShareExtension.entitlements INFOPLIST_FILE = iOS/ShareExtension/Info.plist diff --git a/xcconfig/NetNewsWire_iOSwidgetextension_target.xcconfig b/xcconfig/NetNewsWire_iOSwidgetextension_target.xcconfig index 0f0117c9f..a399e26f7 100644 --- a/xcconfig/NetNewsWire_iOSwidgetextension_target.xcconfig +++ b/xcconfig/NetNewsWire_iOSwidgetextension_target.xcconfig @@ -34,6 +34,7 @@ PROVISIONING_PROFILE_SPECIFIER = #include? "../../SharedXcodeSettings/DeveloperSettings.xcconfig" #include "./common/NetNewsWire_ios_target_common.xcconfig" +ENABLE_HARDENED_RUNTIME = YES LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../../Frameworks CODE_SIGN_ENTITLEMENTS = Widget/NetNewsWire_iOS_WidgetExtension.entitlements INFOPLIST_FILE = Widget/Info.plist diff --git a/xcconfig/NetNewsWire_project_release.xcconfig b/xcconfig/NetNewsWire_project_release.xcconfig index f4d9e6289..1aa1a5439 100644 --- a/xcconfig/NetNewsWire_project_release.xcconfig +++ b/xcconfig/NetNewsWire_project_release.xcconfig @@ -8,3 +8,4 @@ MTL_ENABLE_DEBUG_INFO = NO OTHER_SWIFT_FLAGS = -DRELEASE SWIFT_OPTIMIZATION_LEVEL = -Owholemodule VALIDATE_PRODUCT = YES +DEAD_CODE_STRIPPING = YES diff --git a/xcconfig/NetNewsWire_safariextension_target.xcconfig b/xcconfig/NetNewsWire_safariextension_target.xcconfig index e41f01315..ffc988440 100644 --- a/xcconfig/NetNewsWire_safariextension_target.xcconfig +++ b/xcconfig/NetNewsWire_safariextension_target.xcconfig @@ -34,6 +34,7 @@ PROVISIONING_PROFILE_SPECIFIER = #include? "../../SharedXcodeSettings/DeveloperSettings.xcconfig" #include "./common/NetNewsWire_mac_target_common.xcconfig" +ENABLE_HARDENED_RUNTIME = YES CODE_SIGN_ENTITLEMENTS = Mac/SafariExtension/Subscribe_to_Feed.entitlements INFOPLIST_FILE = Mac/SafariExtension/Info.plist LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks diff --git a/xcconfig/NetNewsWire_shareextension_target.xcconfig b/xcconfig/NetNewsWire_shareextension_target.xcconfig index d5515df46..c95899e6f 100644 --- a/xcconfig/NetNewsWire_shareextension_target.xcconfig +++ b/xcconfig/NetNewsWire_shareextension_target.xcconfig @@ -34,6 +34,7 @@ DEVELOPER_ENTITLEMENTS = #include? "../../SharedXcodeSettings/DeveloperSettings.xcconfig" #include "./common/NetNewsWire_mac_target_common.xcconfig" +ENABLE_HARDENED_RUNTIME = YES CODE_SIGN_ENTITLEMENTS = Mac/ShareExtension/ShareExtension.entitlements INFOPLIST_FILE = Mac/ShareExtension/Info.plist LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks diff --git a/xcconfig/common/NetNewsWire_ios_target_common.xcconfig b/xcconfig/common/NetNewsWire_ios_target_common.xcconfig index aef5050e6..6fa1ae435 100644 --- a/xcconfig/common/NetNewsWire_ios_target_common.xcconfig +++ b/xcconfig/common/NetNewsWire_ios_target_common.xcconfig @@ -1,7 +1,7 @@ // High Level Settings common to both the iOS application and any extensions we bundle with it -MARKETING_VERSION = 6.1.5 -CURRENT_PROJECT_VERSION = 6124 +MARKETING_VERSION = 6.1.6 +CURRENT_PROJECT_VERSION = 6142 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";