diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index fc020e5ad..5bbc1f6b2 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -31,10 +31,9 @@ public extension Notification.Name { static let AccountDidDownloadArticles = Notification.Name(rawValue: "AccountDidDownloadArticles") static let AccountStateDidChange = Notification.Name(rawValue: "AccountStateDidChange") static let StatusesDidChange = Notification.Name(rawValue: "StatusesDidChange") - static let WebFeedMetadataDidChange = Notification.Name(rawValue: "WebFeedMetadataDidChange") } -public enum AccountType: Int { +public enum AccountType: Int, Codable { // Raw values should not change since they’re stored on disk. case onMyMac = 1 case feedly = 16 @@ -199,8 +198,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, typealias WebFeedMetadataDictionary = [String: WebFeedMetadata] var webFeedMetadata = WebFeedMetadataDictionary() - var startingUp = true - public var unreadCount = 0 { didSet { if unreadCount != oldValue { @@ -287,7 +284,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } self.delegate.accountDidInitialize(self) - startingUp = false } // MARK: - API @@ -416,9 +412,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, public func suspendDatabase() { database.cancelAndSuspend() save() - metadataFile.suspend() - webFeedMetadataFile.suspend() - opmlFile.suspend() } /// Re-open the SQLite database and allow database calls. @@ -430,12 +423,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, /// Reload OPML, etc. public func resume() { - metadataFile.resume() - webFeedMetadataFile.resume() - opmlFile.resume() - metadataFile.load() - webFeedMetadataFile.load() - opmlFile.load() fetchAllUnreadCounts() } @@ -488,14 +475,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } - public func resetWebFeedMetadataAndUnreadCounts() { - for feed in flattenedWebFeeds() { - feed.metadata = webFeedMetadata(feedURL: feed.url, webFeedID: feed.webFeedID) - } - fetchAllUnreadCounts() - NotificationCenter.default.post(name: .WebFeedMetadataDidChange, object: self, userInfo: nil) - } - public func markArticles(_ articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { return delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag) } @@ -691,9 +670,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, public func structureDidChange() { // Feeds were added or deleted. Or folders added or deleted. // Or feeds inside folders were added or deleted. - if !startingUp { - opmlFile.markAsDirty() - } + opmlFile.markAsDirty() flattenedWebFeedsNeedUpdate = true webFeedDictionaryNeedsUpdate = true } diff --git a/Frameworks/Account/AccountManager.swift b/Frameworks/Account/AccountManager.swift index 1a273deb8..fd357b8cc 100644 --- a/Frameworks/Account/AccountManager.swift +++ b/Frameworks/Account/AccountManager.swift @@ -90,14 +90,6 @@ public final class AccountManager: UnreadCountProvider { return CombinedRefreshProgress(downloadProgressArray: downloadProgressArray) } - public convenience init() { - let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String - let accountsURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) - let accountsFolder = accountsURL!.appendingPathComponent("Accounts").absoluteString - let accountsFolderPath = accountsFolder.suffix(from: accountsFolder.index(accountsFolder.startIndex, offsetBy: 7)) - self.init(accountsFolder: String(accountsFolderPath)) - } - public init(accountsFolder: String) { self.accountsFolder = accountsFolder diff --git a/Frameworks/Account/AccountMetadataFile.swift b/Frameworks/Account/AccountMetadataFile.swift index 0ec91eee8..6e17ac734 100644 --- a/Frameworks/Account/AccountMetadataFile.swift +++ b/Frameworks/Account/AccountMetadataFile.swift @@ -16,41 +16,26 @@ final class AccountMetadataFile { private let fileURL: URL private let account: Account - private lazy var managedFile = ManagedResourceFile(fileURL: fileURL, load: loadCallback, save: saveCallback) + private var isDirty = false { + didSet { + queueSaveToDiskIfNeeded() + } + } + private let saveQueue = CoalescingQueue(name: "Save Queue", interval: 0.5) + init(filename: String, account: Account) { self.fileURL = URL(fileURLWithPath: filename) self.account = account } func markAsDirty() { - managedFile.markAsDirty() + isDirty = true } func load() { - managedFile.load() - } - - func save() { - managedFile.saveIfNecessary() - } - - func suspend() { - managedFile.suspend() - } - - func resume() { - managedFile.resume() - } - -} - -private extension AccountMetadataFile { - - func loadCallback() { - let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(readingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { readURL in if let fileData = try? Data(contentsOf: readURL) { @@ -63,17 +48,16 @@ private extension AccountMetadataFile { if let error = errorPointer?.pointee { os_log(.error, log: log, "Read from disk coordination failed: %@.", error.localizedDescription) } - } - func saveCallback() { + func save() { guard !account.isDeleted else { return } let encoder = PropertyListEncoder() encoder.outputFormat = .binary let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(writingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { writeURL in do { @@ -90,3 +74,18 @@ private extension AccountMetadataFile { } } + +private extension AccountMetadataFile { + + func queueSaveToDiskIfNeeded() { + saveQueue.add(self, #selector(saveToDiskIfNeeded)) + } + + @objc func saveToDiskIfNeeded() { + if isDirty { + isDirty = false + save() + } + } + +} diff --git a/Frameworks/Account/ContainerIdentifier.swift b/Frameworks/Account/ContainerIdentifier.swift index 1797787c1..0db60e6d4 100644 --- a/Frameworks/Account/ContainerIdentifier.swift +++ b/Frameworks/Account/ContainerIdentifier.swift @@ -12,7 +12,7 @@ public protocol ContainerIdentifiable { var containerID: ContainerIdentifier? { get } } -public enum ContainerIdentifier: Hashable { +public enum ContainerIdentifier: Hashable, Equatable { case smartFeedController case account(String) // accountID case folder(String, String) // accountID, folderName @@ -55,3 +55,47 @@ public enum ContainerIdentifier: Hashable { } } + +extension ContainerIdentifier: Encodable { + enum CodingKeys: CodingKey { + case type + case accountID + case folderName + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .smartFeedController: + try container.encode("smartFeedController", forKey: .type) + case .account(let accountID): + try container.encode("account", forKey: .type) + try container.encode(accountID, forKey: .accountID) + case .folder(let accountID, let folderName): + try container.encode("folder", forKey: .type) + try container.encode(accountID, forKey: .accountID) + try container.encode(folderName, forKey: .folderName) + } + } +} + +extension ContainerIdentifier: Decodable { + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "smartFeedController": + self = .smartFeedController + case "account": + let accountID = try container.decode(String.self, forKey: .accountID) + self = .account(accountID) + default: + let accountID = try container.decode(String.self, forKey: .accountID) + let folderName = try container.decode(String.self, forKey: .folderName) + self = .folder(accountID, folderName) + } + } + +} diff --git a/Frameworks/Account/OPMLFile.swift b/Frameworks/Account/OPMLFile.swift index a2cac7182..034efe4e1 100644 --- a/Frameworks/Account/OPMLFile.swift +++ b/Frameworks/Account/OPMLFile.swift @@ -17,62 +17,40 @@ final class OPMLFile { private let fileURL: URL private let account: Account - private lazy var managedFile = ManagedResourceFile(fileURL: fileURL, load: loadCallback, save: saveCallback) - + + private var isDirty = false { + didSet { + queueSaveToDiskIfNeeded() + } + } + private let saveQueue = CoalescingQueue(name: "Save Queue", interval: 0.5) + init(filename: String, account: Account) { self.fileURL = URL(fileURLWithPath: filename) self.account = account } func markAsDirty() { - managedFile.markAsDirty() + isDirty = true } func load() { - managedFile.load() - } - - func save() { - managedFile.saveIfNecessary() - } - - func suspend() { - managedFile.suspend() - } - - func resume() { - managedFile.resume() - } - -} - -private extension OPMLFile { - - func loadCallback() { - guard let fileData = opmlFileData() else { + guard let fileData = opmlFileData(), let opmlItems = parsedOPMLItems(fileData: fileData) else { return } - // Don't rebuild the account if the OPML hasn't changed since the last save - guard let opml = String(data: fileData, encoding: .utf8), opml != opmlDocument() else { - return - } - - guard let opmlItems = parsedOPMLItems(fileData: fileData) else { return } - BatchUpdate.shared.perform { - account.topLevelWebFeeds.removeAll() account.loadOPMLItems(opmlItems, parentFolder: nil) } } - func saveCallback() { + func save() { guard !account.isDeleted else { return } let opmlDocumentString = opmlDocument() let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(writingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { writeURL in do { @@ -85,12 +63,28 @@ private extension OPMLFile { if let error = errorPointer?.pointee { os_log(.error, log: log, "OPML save to disk coordination failed: %@.", error.localizedDescription) } + } +} + +private extension OPMLFile { + + func queueSaveToDiskIfNeeded() { + saveQueue.add(self, #selector(saveToDiskIfNeeded)) + } + + @objc func saveToDiskIfNeeded() { + if isDirty { + isDirty = false + save() + } + } + func opmlFileData() -> Data? { var fileData: Data? = nil let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(readingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { readURL in do { diff --git a/Frameworks/Account/WebFeedMetadataFile.swift b/Frameworks/Account/WebFeedMetadataFile.swift index e47c54582..8b0b3c029 100644 --- a/Frameworks/Account/WebFeedMetadataFile.swift +++ b/Frameworks/Account/WebFeedMetadataFile.swift @@ -16,41 +16,26 @@ final class WebFeedMetadataFile { private let fileURL: URL private let account: Account - private lazy var managedFile = ManagedResourceFile(fileURL: fileURL, load: loadCallback, save: saveCallback) - + + private var isDirty = false { + didSet { + queueSaveToDiskIfNeeded() + } + } + private let saveQueue = CoalescingQueue(name: "Save Queue", interval: 0.5) + init(filename: String, account: Account) { self.fileURL = URL(fileURLWithPath: filename) self.account = account } func markAsDirty() { - managedFile.markAsDirty() + isDirty = true } func load() { - managedFile.load() - } - - func save() { - managedFile.saveIfNecessary() - } - - func suspend() { - managedFile.suspend() - } - - func resume() { - managedFile.resume() - } - -} - -private extension WebFeedMetadataFile { - - func loadCallback() { - let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(readingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { readURL in if let fileData = try? Data(contentsOf: readURL) { @@ -58,19 +43,14 @@ private extension WebFeedMetadataFile { account.webFeedMetadata = (try? decoder.decode(Account.WebFeedMetadataDictionary.self, from: fileData)) ?? Account.WebFeedMetadataDictionary() } account.webFeedMetadata.values.forEach { $0.delegate = account } - if !account.startingUp { - account.resetWebFeedMetadataAndUnreadCounts() - } }) if let error = errorPointer?.pointee { os_log(.error, log: log, "Read from disk coordination failed: %@.", error.localizedDescription) } - - } - func saveCallback() { + func save() { guard !account.isDeleted else { return } let feedMetadata = metadataForOnlySubscribedToFeeds() @@ -79,7 +59,7 @@ private extension WebFeedMetadataFile { encoder.outputFormat = .binary let errorPointer: NSErrorPointer = nil - let fileCoordinator = NSFileCoordinator(filePresenter: managedFile) + let fileCoordinator = NSFileCoordinator() fileCoordinator.coordinate(writingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { writeURL in do { @@ -94,7 +74,22 @@ private extension WebFeedMetadataFile { os_log(.error, log: log, "Save to disk coordination failed: %@.", error.localizedDescription) } } - + +} + +private extension WebFeedMetadataFile { + + func queueSaveToDiskIfNeeded() { + saveQueue.add(self, #selector(saveToDiskIfNeeded)) + } + + @objc func saveToDiskIfNeeded() { + if isDirty { + isDirty = false + save() + } + } + private func metadataForOnlySubscribedToFeeds() -> Account.WebFeedMetadataDictionary { let webFeedIDs = account.idToWebFeedDictionary.keys return account.webFeedMetadata.filter { (feedID: String, metadata: WebFeedMetadata) -> Bool in diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift index 942f4c0ff..6fd4a2de1 100644 --- a/Mac/MainWindow/Detail/DetailWebViewController.swift +++ b/Mac/MainWindow/Detail/DetailWebViewController.swift @@ -207,7 +207,14 @@ private extension DetailWebViewController { func reloadArticleImage() { guard let article = article else { return } - webView?.evaluateJavaScript("reloadArticleImage(\"\(article.articleID)\")") + + var components = URLComponents() + components.scheme = ArticleRenderer.imageIconScheme + components.path = article.articleID + + if let imageSrc = components.string { + webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")") + } } func reloadHTML() { diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index c69250af7..51a138d08 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -137,7 +137,6 @@ 51A1699F235E10D700EB091F /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16995235E10D600EB091F /* AboutViewController.swift */; }; 51A169A0235E10D700EB091F /* FeedbinAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */; }; 51A66685238075AE00CB272D /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; }; - 51A9A5E02380C3F10033AADF /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; }; 51A9A5E12380C4FE0033AADF /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C45255226507D200C03939 /* AppDefaults.swift */; }; 51A9A5E42380C8880033AADF /* ShareFolderPickerAccountCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */; }; 51A9A5E62380C8B20033AADF /* ShareFolderPickerFolderCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51A9A5E52380C8B20033AADF /* ShareFolderPickerFolderCell.xib */; }; @@ -150,6 +149,19 @@ 51A9A5F52380F6A60033AADF /* ModalNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */; }; 51A9A60A2382FD240033AADF /* PoppableGestureRecognizerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */; }; 51AB8AB323B7F4C6008F147D /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51AB8AB223B7F4C6008F147D /* WebViewController.swift */; }; + 51B5C87723F22B8200032075 /* ExtensionContainers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87623F22B8200032075 /* ExtensionContainers.swift */; }; + 51B5C87B23F2317700032075 /* ExtensionFeedAddRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */; }; + 51B5C87D23F2346200032075 /* ExtensionContainersFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */; }; + 51B5C8B923F368D000032075 /* ExtensionContainers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87623F22B8200032075 /* ExtensionContainers.swift */; }; + 51B5C8BA23F368D000032075 /* ExtensionContainersFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */; }; + 51B5C8BB23F368D000032075 /* ExtensionFeedAddRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */; }; + 51B5C8BE23F37B2400032075 /* ShareDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C8BC23F3780900032075 /* ShareDefaultContainer.swift */; }; + 51B5C8C023F3866C00032075 /* ExtensionFeedAddRequestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C8BF23F3866C00032075 /* ExtensionFeedAddRequestFile.swift */; }; + 51B5C8C123F3A0DB00032075 /* ExtensionFeedAddRequestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C8BF23F3866C00032075 /* ExtensionFeedAddRequestFile.swift */; }; + 51B5C8E423F4BBFA00032075 /* ExtensionContainers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87623F22B8200032075 /* ExtensionContainers.swift */; }; + 51B5C8E523F4BBFA00032075 /* ExtensionContainersFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */; }; + 51B5C8E623F4BBFA00032075 /* ExtensionFeedAddRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */; }; + 51B5C8E723F4BBFA00032075 /* ExtensionFeedAddRequestFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5C8BF23F3866C00032075 /* ExtensionFeedAddRequestFile.swift */; }; 51B62E68233186730085F949 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B62E67233186730085F949 /* IconView.swift */; }; 51BB7C272335A8E5008E8144 /* ArticleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */; }; 51BB7C312335ACDE008E8144 /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 51BB7C302335ACDE008E8144 /* page.html */; }; @@ -225,6 +237,7 @@ 51C452B42265141B00C03939 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51C452B32265141B00C03939 /* WebKit.framework */; }; 51C452B82265178500C03939 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 51C452B72265178500C03939 /* styleSheet.css */; }; 51C9DE5823EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C9DE5723EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift */; }; + 51C9DE8623F09FAA003D5A6D /* AccountMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C9DE8523F09FAA003D5A6D /* AccountMigrator.swift */; }; 51CE1C0923621EDA005548FC /* RefreshProgressView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51CE1C0823621EDA005548FC /* RefreshProgressView.xib */; }; 51CE1C0B23622007005548FC /* RefreshProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51CE1C0A23622006005548FC /* RefreshProgressView.swift */; }; 51CE1C712367721A005548FC /* testURLsOfCurrentArticle.applescript in Sources */ = {isa = PBXBuildFile; fileRef = 84F9EADB213660A100CF2DE4 /* testURLsOfCurrentArticle.applescript */; }; @@ -1331,6 +1344,11 @@ 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalNavigationController.swift; sourceTree = ""; }; 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoppableGestureRecognizerDelegate.swift; sourceTree = ""; }; 51AB8AB223B7F4C6008F147D /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; + 51B5C87623F22B8200032075 /* ExtensionContainers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionContainers.swift; sourceTree = ""; }; + 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFeedAddRequest.swift; sourceTree = ""; }; + 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionContainersFile.swift; sourceTree = ""; }; + 51B5C8BC23F3780900032075 /* ShareDefaultContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareDefaultContainer.swift; sourceTree = ""; }; + 51B5C8BF23F3866C00032075 /* ExtensionFeedAddRequestFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionFeedAddRequestFile.swift; sourceTree = ""; }; 51B62E67233186730085F949 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleActivityItemSource.swift; sourceTree = ""; }; 51BB7C302335ACDE008E8144 /* page.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; @@ -1356,6 +1374,7 @@ 51C452B32265141B00C03939 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.2.sdk/System/Library/Frameworks/WebKit.framework; sourceTree = DEVELOPER_DIR; }; 51C452B72265178500C03939 /* styleSheet.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = styleSheet.css; sourceTree = ""; }; 51C9DE5723EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrapperScriptMessageHandler.swift; sourceTree = ""; }; + 51C9DE8523F09FAA003D5A6D /* AccountMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMigrator.swift; sourceTree = ""; }; 51CE1C0823621EDA005548FC /* RefreshProgressView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RefreshProgressView.xib; sourceTree = ""; }; 51CE1C0A23622006005548FC /* RefreshProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshProgressView.swift; sourceTree = ""; }; 51D6A5BB23199C85001C27D8 /* MasterTimelineDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterTimelineDataSource.swift; sourceTree = ""; }; @@ -1793,6 +1812,7 @@ isa = PBXGroup; children = ( 513C5CE8232571C2003D4054 /* ShareViewController.swift */, + 51B5C8BC23F3780900032075 /* ShareDefaultContainer.swift */, 51707438232AA97100A461A3 /* ShareFolderPickerController.swift */, 51A9A5E72380CA130033AADF /* ShareFolderPickerCell.swift */, 51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */, @@ -1881,6 +1901,17 @@ path = Activity; sourceTree = ""; }; + 51B5C85A23F22A7A00032075 /* CommonExtension */ = { + isa = PBXGroup; + children = ( + 51B5C87623F22B8200032075 /* ExtensionContainers.swift */, + 51B5C87C23F2346200032075 /* ExtensionContainersFile.swift */, + 51B5C87A23F2317700032075 /* ExtensionFeedAddRequest.swift */, + 51B5C8BF23F3866C00032075 /* ExtensionFeedAddRequestFile.swift */, + ); + path = CommonExtension; + sourceTree = ""; + }; 51C45245226506C800C03939 /* UIKit Extensions */ = { isa = PBXGroup; children = ( @@ -2612,6 +2643,7 @@ 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */, C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */, 51B62E67233186730085F949 /* IconView.swift */, + 51C9DE8523F09FAA003D5A6D /* AccountMigrator.swift */, 51C4525D226508F600C03939 /* MasterFeed */, 51C4526D2265091600C03939 /* MasterTimeline */, 51C4527D2265092C00C03939 /* Article */, @@ -2621,6 +2653,7 @@ 513145F9235A55A700387FDC /* Intents */, 5183CCEB227117C70010922C /* Settings */, 51C45245226506C800C03939 /* UIKit Extensions */, + 51B5C85A23F22A7A00032075 /* CommonExtension */, 513C5CE7232571C2003D4054 /* ShareExtension */, 51314643235A7C2300387FDC /* IntentsExtension */, 84C9FC9A2262A1A900D921D6 /* Resources */, @@ -3644,8 +3677,12 @@ buildActionMask = 2147483647; files = ( 513146B3235A81A400387FDC /* AddWebFeedIntentHandler.swift in Sources */, + 51B5C8E623F4BBFA00032075 /* ExtensionFeedAddRequest.swift in Sources */, 51314705235C41FC00387FDC /* Intents.intentdefinition in Sources */, + 51B5C8E523F4BBFA00032075 /* ExtensionContainersFile.swift in Sources */, 51314668235A7E4600387FDC /* IntentHandler.swift in Sources */, + 51B5C8E423F4BBFA00032075 /* ExtensionContainers.swift in Sources */, + 51B5C8E723F4BBFA00032075 /* ExtensionFeedAddRequestFile.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3654,13 +3691,17 @@ buildActionMask = 2147483647; files = ( 515D4FC123257A3200EE1167 /* FolderTreeControllerDelegate.swift in Sources */, + 51B5C8B923F368D000032075 /* ExtensionContainers.swift in Sources */, 515D4FCA23257CB500EE1167 /* Node-Extensions.swift in Sources */, 513C5CE9232571C2003D4054 /* ShareViewController.swift in Sources */, 51707439232AA97100A461A3 /* ShareFolderPickerController.swift in Sources */, - 51A9A5E02380C3F10033AADF /* AddWebFeedDefaultContainer.swift in Sources */, + 51B5C8BE23F37B2400032075 /* ShareDefaultContainer.swift in Sources */, + 51B5C8BA23F368D000032075 /* ExtensionContainersFile.swift in Sources */, + 51B5C8BB23F368D000032075 /* ExtensionFeedAddRequest.swift in Sources */, 51A9A5E82380CA130033AADF /* ShareFolderPickerCell.swift in Sources */, 51A9A5EF2380D63B0033AADF /* IconImage.swift in Sources */, 51A9A5ED2380D6000033AADF /* AppAssets.swift in Sources */, + 51B5C8C123F3A0DB00032075 /* ExtensionFeedAddRequestFile.swift in Sources */, 51A9A5E12380C4FE0033AADF /* AppDefaults.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3899,9 +3940,11 @@ 51C452A022650A1900C03939 /* WebFeedIconDownloader.swift in Sources */, 51C4529E22650A1900C03939 /* ImageDownloader.swift in Sources */, 51A66685238075AE00CB272D /* AddWebFeedDefaultContainer.swift in Sources */, + 51B5C87723F22B8200032075 /* ExtensionContainers.swift in Sources */, 51C45292226509C800C03939 /* TodayFeedDelegate.swift in Sources */, 51C452A222650A1900C03939 /* RSHTMLMetadata+Extension.swift in Sources */, 514B7D1F23219F3C00BAC947 /* AddControllerType.swift in Sources */, + 51B5C87B23F2317700032075 /* ExtensionFeedAddRequest.swift in Sources */, 51627A93238A3836007B3B4B /* CroppingPreviewParameters.swift in Sources */, 512AF9DD236F05230066F8BE /* InteractiveLabel.swift in Sources */, 51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */, @@ -3925,6 +3968,7 @@ 51C4528E2265099C00C03939 /* SmartFeedsController.swift in Sources */, 51C9DE5823EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift in Sources */, 51627A6B238629D8007B3B4B /* MasterFeedDataSource.swift in Sources */, + 51B5C87D23F2346200032075 /* ExtensionContainersFile.swift in Sources */, 51102165233A7D6C0007A5F7 /* ArticleExtractorButton.swift in Sources */, 5141E7392373C18B0013FF27 /* WebFeedInspectorViewController.swift in Sources */, C5A6ED6D23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift in Sources */, @@ -3949,8 +3993,10 @@ 51C452762265091600C03939 /* MasterTimelineViewController.swift in Sources */, 5108F6D823763094001ABC45 /* TickMarkSlider.swift in Sources */, 51C452882265093600C03939 /* AddWebFeedViewController.swift in Sources */, + 51B5C8C023F3866C00032075 /* ExtensionFeedAddRequestFile.swift in Sources */, 51A169A0235E10D700EB091F /* FeedbinAccountViewController.swift in Sources */, 51934CCE2310792F006127BE /* ActivityManager.swift in Sources */, + 51C9DE8623F09FAA003D5A6D /* AccountMigrator.swift in Sources */, 5108F6B72375E612001ABC45 /* CacheCleaner.swift in Sources */, 518651DA235621840078E021 /* ImageTransition.swift in Sources */, 51C266EA238C334800F53014 /* ContextMenuPreviewViewController.swift in Sources */, diff --git a/Shared/Article Rendering/main.js b/Shared/Article Rendering/main.js index c99a7ece4..f11730264 100644 --- a/Shared/Article Rendering/main.js +++ b/Shared/Article Rendering/main.js @@ -79,9 +79,9 @@ function flattenPreElements() { ElementUnwrapper.unwrapAppropriateChildren("div.articleBody td > pre"); } -function reloadArticleImage(articleID) { +function reloadArticleImage(imageSrc) { var image = document.getElementById("nnwImageIcon"); - image.src = "nnwImageIcon://" + articleID; + image.src = imageSrc; } function error() { diff --git a/iOS/AccountMigrator.swift b/iOS/AccountMigrator.swift new file mode 100644 index 000000000..344513917 --- /dev/null +++ b/iOS/AccountMigrator.swift @@ -0,0 +1,24 @@ +// +// AccountMigrator.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 2/9/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation + +struct AccountMigrator { + + static func migrate() { + let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String + let containerAccountsURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) + let containerAccountsFolder = containerAccountsURL!.appendingPathComponent("Accounts") + + let documentAccountURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let documentAccountsFolder = documentAccountURL.appendingPathComponent("Accounts") + + try? FileManager.default.moveItem(at: containerAccountsFolder, to: documentAccountsFolder) + } + +} diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index 50778cb92..61b76c648 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -41,6 +41,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD var imageDownloader: ImageDownloader! var authorAvatarDownloader: AuthorAvatarDownloader! var webFeedIconDownloader: WebFeedIconDownloader! + var extensionContainersFile: ExtensionContainersFile! + var extensionFeedAddRequestFile: ExtensionFeedAddRequestFile! var unreadCount = 0 { didSet { @@ -58,7 +60,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD super.init() appDelegate = self - AccountManager.shared = AccountManager() + AccountMigrator.migrate() + + let documentAccountURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let documentAccountsFolder = documentAccountURL.appendingPathComponent("Accounts").absoluteString + let documentAccountsFolderPath = String(documentAccountsFolder.suffix(from: documentAccountsFolder.index(documentAccountsFolder.startIndex, offsetBy: 7))) + AccountManager.shared = AccountManager(accountsFolder: documentAccountsFolderPath) NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountRefreshDidFinish(_:)), name: .AccountRefreshDidFinish, object: nil) @@ -97,6 +104,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD UNUserNotificationCenter.current().delegate = self userNotificationManager = UserNotificationManager() + extensionContainersFile = ExtensionContainersFile() + extensionFeedAddRequestFile = ExtensionFeedAddRequestFile() + syncTimer = ArticleStatusSyncTimer() #if DEBUG @@ -132,6 +142,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } func prepareAccountsForBackground() { + extensionFeedAddRequestFile.suspend() syncTimer?.invalidate() scheduleBackgroundFeedRefresh() syncArticleStatus() @@ -139,6 +150,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } func prepareAccountsForForeground() { + extensionFeedAddRequestFile.resume() + if let lastRefresh = AppDefaults.lastRefresh { if Date() > lastRefresh.addingTimeInterval(15 * 60) { AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index 9ac2627e7..f621ba592 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -519,7 +519,14 @@ private extension WebViewController { func reloadArticleImage() { guard let article = article else { return } - webView?.evaluateJavaScript("reloadArticleImage(\"\(article.articleID)\")") + + var components = URLComponents() + components.scheme = ArticleRenderer.imageIconScheme + components.path = article.articleID + + if let imageSrc = components.string { + webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")") + } } func imageWasClicked(body: String?) { diff --git a/iOS/CommonExtension/ExtensionContainers.swift b/iOS/CommonExtension/ExtensionContainers.swift new file mode 100644 index 000000000..b283f177a --- /dev/null +++ b/iOS/CommonExtension/ExtensionContainers.swift @@ -0,0 +1,94 @@ +// +// ExtensionContainers.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 2/10/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import Account + +protocol ExtensionContainer: ContainerIdentifiable, Codable { + var name: String { get } + var accountID: String { get } +} + +struct ExtensionContainers: Codable { + + enum CodingKeys: String, CodingKey { + case accounts + } + + let accounts: [ExtensionAccount] + + var flattened: [ExtensionContainer] { + return accounts.reduce([ExtensionContainer](), { (containers, account) in + var result = containers + result.append(account) + result.append(contentsOf: account.folders) + return result + }) + } + + func findAccount(forName name: String) -> ExtensionAccount? { + return accounts.first(where: { $0.name == name }) + } + +} + +struct ExtensionAccount: ExtensionContainer { + + enum CodingKeys: String, CodingKey { + case name + case accountID + case type + case disallowFeedInRootFolder + case containerID + case folders + } + + let name: String + let accountID: String + let type: AccountType + let disallowFeedInRootFolder: Bool + let containerID: ContainerIdentifier? + let folders: [ExtensionFolder] + + init(account: Account) { + self.name = account.nameForDisplay + self.accountID = account.accountID + self.type = account.type + self.disallowFeedInRootFolder = account.behaviors.contains(.disallowFeedInRootFolder) + self.containerID = account.containerID + self.folders = account.sortedFolders?.map { ExtensionFolder(folder: $0) } ?? [ExtensionFolder]() + } + + func findFolder(forName name: String) -> ExtensionFolder? { + return folders.first(where: { $0.name == name }) + } + +} + +struct ExtensionFolder: ExtensionContainer { + + enum CodingKeys: String, CodingKey { + case accountName + case accountID + case name + case containerID + } + + let accountName: String + let accountID: String + let name: String + let containerID: ContainerIdentifier? + + init(folder: Folder) { + self.accountName = folder.account?.nameForDisplay ?? "" + self.accountID = folder.account?.accountID ?? "" + self.name = folder.nameForDisplay + self.containerID = folder.containerID + } + +} diff --git a/iOS/CommonExtension/ExtensionContainersFile.swift b/iOS/CommonExtension/ExtensionContainersFile.swift new file mode 100644 index 000000000..094a5360d --- /dev/null +++ b/iOS/CommonExtension/ExtensionContainersFile.swift @@ -0,0 +1,107 @@ +// +// ExtensionContainersFile.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 2/10/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import os.log +import RSCore +import RSParser +import Account + +final class ExtensionContainersFile { + + private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "extensionContainersFile") + + private static var filePath: String = { + let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String + let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) + return containerURL!.appendingPathComponent("extension_containers.plist").path + }() + + private var isDirty = false { + didSet { + queueSaveToDiskIfNeeded() + } + } + private let saveQueue = CoalescingQueue(name: "Save Queue", interval: 0.5) + + init() { + if !FileManager.default.fileExists(atPath: ExtensionContainersFile.filePath) { + save() + } + + NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .UserDidAddAccount, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .UserDidDeleteAccount, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .AccountStateDidChange, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .ChildrenDidChange, object: nil) + } + + /// Reads and decodes the shared plist file. + static func read() -> ExtensionContainers? { + let errorPointer: NSErrorPointer = nil + let fileCoordinator = NSFileCoordinator() + let fileURL = URL(fileURLWithPath: ExtensionContainersFile.filePath) + var extensionContainers: ExtensionContainers? = nil + + fileCoordinator.coordinate(readingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { readURL in + if let fileData = try? Data(contentsOf: readURL) { + let decoder = PropertyListDecoder() + extensionContainers = try? decoder.decode(ExtensionContainers.self, from: fileData) + } + }) + + if let error = errorPointer?.pointee { + os_log(.error, log: log, "Read from disk coordination failed: %@.", error.localizedDescription) + } + + return extensionContainers + } + +} + +private extension ExtensionContainersFile { + + @objc func markAsDirty() { + isDirty = true + } + + func queueSaveToDiskIfNeeded() { + saveQueue.add(self, #selector(saveToDiskIfNeeded)) + } + + @objc func saveToDiskIfNeeded() { + if isDirty { + isDirty = false + save() + } + } + + func save() { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + + let errorPointer: NSErrorPointer = nil + let fileCoordinator = NSFileCoordinator() + let fileURL = URL(fileURLWithPath: ExtensionContainersFile.filePath) + + fileCoordinator.coordinate(writingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { writeURL in + do { + let extensionAccounts = AccountManager.shared.sortedActiveAccounts.map { ExtensionAccount(account: $0) } + let extensionContainers = ExtensionContainers(accounts: extensionAccounts) + let data = try encoder.encode(extensionContainers) + try data.write(to: writeURL) + } catch let error as NSError { + os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription) + } + }) + + if let error = errorPointer?.pointee { + os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription) + } + } + +} diff --git a/iOS/CommonExtension/ExtensionFeedAddRequest.swift b/iOS/CommonExtension/ExtensionFeedAddRequest.swift new file mode 100644 index 000000000..86e54a7d6 --- /dev/null +++ b/iOS/CommonExtension/ExtensionFeedAddRequest.swift @@ -0,0 +1,24 @@ +// +// ExtensionFeedAddRequest.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 2/10/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import Account + +struct ExtensionFeedAddRequest: Codable { + + enum CodingKeys: String, CodingKey { + case name + case feedURL + case destinationContainerID + } + + let name: String? + let feedURL: URL + let destinationContainerID: ContainerIdentifier + +} diff --git a/iOS/CommonExtension/ExtensionFeedAddRequestFile.swift b/iOS/CommonExtension/ExtensionFeedAddRequestFile.swift new file mode 100644 index 000000000..6a8bc4d9c --- /dev/null +++ b/iOS/CommonExtension/ExtensionFeedAddRequestFile.swift @@ -0,0 +1,160 @@ +// +// ExtensionFeedAddRequestFile.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 2/11/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation +import os.log +import Account + +final class ExtensionFeedAddRequestFile: NSObject, NSFilePresenter { + + private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "extensionFeedAddRequestFile") + + private static var filePath: String = { + let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String + let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) + return containerURL!.appendingPathComponent("extension_feed_add_request.plist").path + }() + + private let operationQueue: OperationQueue + + var presentedItemURL: URL? { + return URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath) + } + + var presentedItemOperationQueue: OperationQueue { + return operationQueue + } + + override init() { + operationQueue = OperationQueue() + operationQueue.maxConcurrentOperationCount = 1 + + super.init() + + NSFileCoordinator.addFilePresenter(self) + process() + } + + func presentedItemDidChange() { + DispatchQueue.main.async { + self.process() + } + } + + func resume() { + NSFileCoordinator.addFilePresenter(self) + process() + } + + func suspend() { + NSFileCoordinator.removeFilePresenter(self) + } + + static func save(_ feedAddRequest: ExtensionFeedAddRequest) { + + let decoder = PropertyListDecoder() + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + + let errorPointer: NSErrorPointer = nil + let fileCoordinator = NSFileCoordinator() + let fileURL = URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath) + + fileCoordinator.coordinate(writingItemAt: fileURL, options: [.forMerging], error: errorPointer, byAccessor: { url in + do { + + var requests: [ExtensionFeedAddRequest] + if let fileData = try? Data(contentsOf: url), + let decodedRequests = try? decoder.decode([ExtensionFeedAddRequest].self, from: fileData) { + requests = decodedRequests + } else { + requests = [ExtensionFeedAddRequest]() + } + + requests.append(feedAddRequest) + + let data = try encoder.encode(requests) + try data.write(to: url) + + } catch let error as NSError { + os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription) + } + }) + + if let error = errorPointer?.pointee { + os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription) + } + } + +} + +private extension ExtensionFeedAddRequestFile { + + func process() { + + let decoder = PropertyListDecoder() + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + + let errorPointer: NSErrorPointer = nil + let fileCoordinator = NSFileCoordinator(filePresenter: self) + let fileURL = URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath) + + var requests: [ExtensionFeedAddRequest]? = nil + + fileCoordinator.coordinate(writingItemAt: fileURL, options: [.forMerging], error: errorPointer, byAccessor: { url in + do { + + if let fileData = try? Data(contentsOf: url), + let decodedRequests = try? decoder.decode([ExtensionFeedAddRequest].self, from: fileData) { + requests = decodedRequests + } + + let data = try encoder.encode([ExtensionFeedAddRequest]()) + try data.write(to: url) + + } catch let error as NSError { + os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription) + } + }) + + if let error = errorPointer?.pointee { + os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription) + } + + requests?.forEach { processRequest($0) } + } + + func processRequest(_ request: ExtensionFeedAddRequest) { + var destinationAccountID: String? = nil + switch request.destinationContainerID { + case .account(let accountID): + destinationAccountID = accountID + case .folder(let accountID, _): + destinationAccountID = accountID + default: + break + } + + guard let accountID = destinationAccountID, let account = AccountManager.shared.existingAccount(with: accountID) else { + return + } + + var destinationContainer: Container? = nil + if account.containerID == request.destinationContainerID { + destinationContainer = account + } else { + destinationContainer = account.folders?.first(where: { $0.containerID == request.destinationContainerID }) + } + + guard let container = destinationContainer else { return } + + account.createWebFeed(url: request.feedURL.absoluteString, name: request.name, container: container) { _ in } + } + +} diff --git a/iOS/Intents/AddWebFeedIntentHandler.swift b/iOS/Intents/AddWebFeedIntentHandler.swift index 350e80156..fb0711fdb 100644 --- a/iOS/Intents/AddWebFeedIntentHandler.swift +++ b/iOS/Intents/AddWebFeedIntentHandler.swift @@ -7,15 +7,24 @@ // import Intents -import Account + +public enum AddWebFeedIntentHandlerError: LocalizedError { + + case communicationFailure + + public var errorDescription: String? { + switch self { + case .communicationFailure: + return NSLocalizedString("Unable to communicate with NetNewsWire.", comment: "Communication failure") + } + } + +} public class AddWebFeedIntentHandler: NSObject, AddWebFeedIntentHandling { override init() { super.init() - DispatchQueue.main.sync { - AccountManager.shared = AccountManager() - } } public func resolveUrl(for intent: AddWebFeedIntent, with completion: @escaping (AddWebFeedUrlResolutionResult) -> Void) { @@ -27,10 +36,13 @@ public class AddWebFeedIntentHandler: NSObject, AddWebFeedIntentHandling { } public func provideAccountNameOptions(for intent: AddWebFeedIntent, with completion: @escaping ([String]?, Error?) -> Void) { - DispatchQueue.main.async { - let accountNames = AccountManager.shared.activeAccounts.compactMap { $0.nameForDisplay } - completion(accountNames, nil) + guard let extensionContainers = ExtensionContainersFile.read() else { + completion(nil, AddWebFeedIntentHandlerError.communicationFailure) + return } + + let accountNames = extensionContainers.accounts.map { $0.name } + completion(accountNames, nil) } public func resolveAccountName(for intent: AddWebFeedIntent, with completion: @escaping (AddWebFeedAccountNameResolutionResult) -> Void) { @@ -38,25 +50,32 @@ public class AddWebFeedIntentHandler: NSObject, AddWebFeedIntentHandling { completion(AddWebFeedAccountNameResolutionResult.notRequired()) return } - DispatchQueue.main.async { - if AccountManager.shared.findActiveAccount(forDisplayName: accountName) == nil { - completion(.unsupported(forReason: .invalid)) - } else { - completion(.success(with: accountName)) - } + + guard let extensionContainers = ExtensionContainersFile.read() else { + completion(.unsupported(forReason: .communication)) + return + } + + if extensionContainers.findAccount(forName: accountName) == nil { + completion(.unsupported(forReason: .invalid)) + } else { + completion(.success(with: accountName)) } } public func provideFolderNameOptions(for intent: AddWebFeedIntent, with completion: @escaping ([String]?, Error?) -> Void) { - DispatchQueue.main.async { - guard let accountName = intent.accountName, let account = AccountManager.shared.findActiveAccount(forDisplayName: accountName) else { - completion([String](), nil) - return - } - - let folderNames = account.folders?.map { $0.nameForDisplay } - completion(folderNames, nil) + guard let extensionContainers = ExtensionContainersFile.read() else { + completion(nil, AddWebFeedIntentHandlerError.communicationFailure) + return } + + guard let accountName = intent.accountName, let account = extensionContainers.findAccount(forName: accountName) else { + completion([String](), nil) + return + } + + let folderNames = account.folders.map { $0.name } + completion(folderNames, nil) } public func resolveFolderName(for intent: AddWebFeedIntent, with completion: @escaping (AddWebFeedFolderNameResolutionResult) -> Void) { @@ -65,73 +84,60 @@ public class AddWebFeedIntentHandler: NSObject, AddWebFeedIntentHandling { return } - DispatchQueue.main.async { - guard let account = AccountManager.shared.findActiveAccount(forDisplayName: accountName) else { - completion(.unsupported(forReason: .invalid)) - return - } - if account.findFolder(withDisplayName: folderName) == nil { - completion(.unsupported(forReason: .invalid)) - } else { - completion(.success(with: folderName)) - } + guard let extensionContainers = ExtensionContainersFile.read() else { + completion(.unsupported(forReason: .communication)) return } + + guard let account = extensionContainers.findAccount(forName: accountName) else { + completion(.unsupported(forReason: .invalid)) + return + } + + if account.findFolder(forName: folderName) == nil { + completion(.unsupported(forReason: .invalid)) + } else { + completion(.success(with: folderName)) + } + return + } public func handle(intent: AddWebFeedIntent, completion: @escaping (AddWebFeedIntentResponse) -> Void) { - guard let url = intent.url else { + guard let url = intent.url, let extensionContainers = ExtensionContainersFile.read() else { completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil)) return } - DispatchQueue.main.async { - - let account: Account? = { - if let accountName = intent.accountName { - return AccountManager.shared.findActiveAccount(forDisplayName: accountName) - } else { - return AccountManager.shared.sortedActiveAccounts.first - } - }() - - guard let validAccount = account else { - completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil)) - return - } - - let container: Container? = { - if let folderName = intent.folderName { - return validAccount.findFolder(withDisplayName: folderName) - } else { - return validAccount - } - }() - - guard let validContainer = container else { - completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil)) - return + let account: ExtensionAccount? = { + if let accountName = intent.accountName { + return extensionContainers.findAccount(forName: accountName) + } else { + return extensionContainers.accounts.first } + }() - validAccount.createWebFeed(url: url.absoluteString, name: nil, container: validContainer) { result in - switch result { - case .success: - AccountManager.shared.suspendNetworkAll() - AccountManager.shared.suspendDatabaseAll() - completion(AddWebFeedIntentResponse(code: .success, userActivity: nil)) - case .failure(let error): - switch error { - case AccountError.createErrorNotFound: - completion(AddWebFeedIntentResponse(code: .feedNotFound, userActivity: nil)) - case AccountError.createErrorAlreadySubscribed: - completion(AddWebFeedIntentResponse(code: .alreadySubscribed, userActivity: nil)) - default: - completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil)) - } - } - } + guard let validAccount = account else { + completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil)) + return } - + + let container: ExtensionContainer? = { + if let folderName = intent.folderName { + return validAccount.findFolder(forName: folderName) + } else { + return validAccount + } + }() + + guard let validContainer = container, let containerID = validContainer.containerID else { + completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil)) + return + } + + let request = ExtensionFeedAddRequest(name: nil, feedURL: url, destinationContainerID: containerID) + ExtensionFeedAddRequestFile.save(request) + completion(AddWebFeedIntentResponse(code: .success, userActivity: nil)) } - + } diff --git a/iOS/Intents/Base.lproj/Intents.intentdefinition b/iOS/Intents/Base.lproj/Intents.intentdefinition index 65b3af477..e1969b641 100644 --- a/iOS/Intents/Base.lproj/Intents.intentdefinition +++ b/iOS/Intents/Base.lproj/Intents.intentdefinition @@ -9,7 +9,7 @@ INIntentDefinitionNamespace U6u7RF INIntentDefinitionSystemVersion - 19B88 + 19D76 INIntentDefinitionToolsBuildVersion 11B53 INIntentDefinitionToolsVersion @@ -177,6 +177,16 @@ INIntentParameterUnsupportedReasonFormatStringID JGkCuS + + INIntentParameterUnsupportedReasonCode + communication + INIntentParameterUnsupportedReasonCustom + + INIntentParameterUnsupportedReasonFormatString + Unable to communicate with NetNewsWire. + INIntentParameterUnsupportedReasonFormatStringID + uSfloN + @@ -259,6 +269,16 @@ INIntentParameterUnsupportedReasonFormatStringID ef5kBt + + INIntentParameterUnsupportedReasonCode + communication + INIntentParameterUnsupportedReasonCustom + + INIntentParameterUnsupportedReasonFormatString + Unable to communicate with NetNewsWire. + INIntentParameterUnsupportedReasonFormatStringID + ExjqcE + @@ -276,30 +296,6 @@ INIntentResponseCodeName failure - - INIntentResponseCodeConciseFormatString - You are already subscribed to this feed in this account. - INIntentResponseCodeConciseFormatStringID - srME8b - INIntentResponseCodeFormatString - You are already subscribed to this feed in this account. - INIntentResponseCodeFormatStringID - UGGPkp - INIntentResponseCodeName - alreadySubscribed - - - INIntentResponseCodeConciseFormatString - No feed was found at the specified URL. - INIntentResponseCodeConciseFormatStringID - 8Dh9Yy - INIntentResponseCodeFormatString - No feed was found at the specified URL. - INIntentResponseCodeFormatStringID - drQfaI - INIntentResponseCodeName - feedNotFound - INIntentTitle diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index 1ea187b56..e7273a6fe 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -58,7 +58,6 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(webFeedSettingDidChange(_:)), name: .WebFeedSettingDidChange, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(webFeedMetadataDidChange(_:)), name: .WebFeedMetadataDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDidAddFeed(_:)), name: .UserDidAddFeed, 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) diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist index 01d6338ba..80d20fb24 100644 --- a/iOS/Resources/Info.plist +++ b/iOS/Resources/Info.plist @@ -60,10 +60,10 @@ Grant permission to save images from the article. NSUserActivityTypes - Restoration AddWebFeedIntent NextUnread ReadArticle + Restoration SelectFeed UIApplicationSceneManifest diff --git a/iOS/ShareExtension/ShareDefaultContainer.swift b/iOS/ShareExtension/ShareDefaultContainer.swift new file mode 100644 index 000000000..f039813fe --- /dev/null +++ b/iOS/ShareExtension/ShareDefaultContainer.swift @@ -0,0 +1,49 @@ +// +// ShareDefaultContainer.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 2/11/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import Foundation + +struct ShareDefaultContainer { + + static func defaultContainer(containers: ExtensionContainers) -> ExtensionContainer? { + + if let accountID = AppDefaults.addWebFeedAccountID, let account = containers.accounts.first(where: { $0.accountID == accountID }) { + if let folderName = AppDefaults.addWebFeedFolderName, let folder = account.folders.first(where: { $0.name == folderName }) { + return folder + } else { + return substituteContainerIfNeeded(account: account) + } + } else if let account = containers.accounts.first { + return substituteContainerIfNeeded(account: account) + } else { + return nil + } + + } + + static func saveDefaultContainer(_ container: ExtensionContainer) { + AppDefaults.addWebFeedAccountID = container.accountID + if let folder = container as? ExtensionFolder { + AppDefaults.addWebFeedFolderName = folder.name + } else { + AppDefaults.addWebFeedFolderName = nil + } + } + + private static func substituteContainerIfNeeded(account: ExtensionAccount) -> ExtensionContainer? { + if !account.disallowFeedInRootFolder { + return account + } else { + if let folder = account.folders.first { + return folder + } else { + return nil + } + } + } +} diff --git a/iOS/ShareExtension/ShareFolderPickerController.swift b/iOS/ShareExtension/ShareFolderPickerController.swift index 1ffa6fcf9..d70fcf332 100644 --- a/iOS/ShareExtension/ShareFolderPickerController.swift +++ b/iOS/ShareExtension/ShareFolderPickerController.swift @@ -7,30 +7,24 @@ // import UIKit -import RSCore import Account +import RSCore protocol ShareFolderPickerControllerDelegate: class { - func shareFolderPickerDidSelect(_ container: Container) + func shareFolderPickerDidSelect(_ container: ExtensionContainer) } class ShareFolderPickerController: UITableViewController { - var selectedContainer: Container? - var containers = [Container]() + var containers: [ExtensionContainer]? + var selectedContainerID: ContainerIdentifier? weak var delegate: ShareFolderPickerControllerDelegate? override func viewDidLoad() { - for account in AccountManager.shared.sortedActiveAccounts { - containers.append(account) - if let sortedFolders = account.sortedFolders { - containers.append(contentsOf: sortedFolders) - } - } - tableView.register(UINib(nibName: "ShareFolderPickerAccountCell", bundle: Bundle.main), forCellReuseIdentifier: "AccountCell") tableView.register(UINib(nibName: "ShareFolderPickerFolderCell", bundle: Bundle.main), forCellReuseIdentifier: "FolderCell") + } override func numberOfSections(in tableView: UITableView) -> Int { @@ -38,30 +32,28 @@ class ShareFolderPickerController: UITableViewController { } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return containers.count + return containers?.count ?? 0 } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let container = containers[indexPath.row] + let container = containers?[indexPath.row] let cell: ShareFolderPickerCell = { - if container is Account { + if container is ExtensionAccount { return tableView.dequeueReusableCell(withIdentifier: "AccountCell", for: indexPath) as! ShareFolderPickerCell } else { return tableView.dequeueReusableCell(withIdentifier: "FolderCell", for: indexPath) as! ShareFolderPickerCell } }() - if let account = container as? Account { + if let account = container as? ExtensionAccount { cell.icon.image = AppAssets.image(for: account.type) } else { cell.icon.image = AppAssets.masterFolderImage.image } - - if let displayNameProvider = container as? DisplayNameProvider { - cell.label?.text = displayNameProvider.nameForDisplay - } - - if let compContainer = selectedContainer, container === compContainer { + + cell.label?.text = container?.name ?? "" + + if let containerID = container?.containerID, containerID == selectedContainerID { cell.accessoryType = .checkmark } else { cell.accessoryType = .none @@ -71,9 +63,9 @@ class ShareFolderPickerController: UITableViewController { } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let container = containers[indexPath.row] + guard let container = containers?[indexPath.row] else { return } - if let account = container as? Account, account.behaviors.contains(.disallowFeedInRootFolder) { + if let account = container as? ExtensionAccount, account.disallowFeedInRootFolder { tableView.selectRow(at: nil, animated: false, scrollPosition: .none) } else { let cell = tableView.cellForRow(at: indexPath) diff --git a/iOS/ShareExtension/ShareViewController.swift b/iOS/ShareExtension/ShareViewController.swift index 6b5bc55f1..b1701c396 100644 --- a/iOS/ShareExtension/ShareViewController.swift +++ b/iOS/ShareExtension/ShareViewController.swift @@ -8,23 +8,26 @@ import UIKit import MobileCoreServices -import Social import Account -import Articles +import Social import RSCore import RSTree class ShareViewController: SLComposeServiceViewController, ShareFolderPickerControllerDelegate { private var url: URL? - private var container: Container? + private var extensionContainers: ExtensionContainers? + private var flattenedContainers: [ExtensionContainer]! + private var selectedContainer: ExtensionContainer? private var folderItem: SLComposeSheetConfigurationItem! override func viewDidLoad() { - AccountManager.shared = AccountManager() - - container = AddWebFeedDefaultContainer.defaultContainer + extensionContainers = ExtensionContainersFile.read() + flattenedContainers = extensionContainers?.flattened ?? [ExtensionContainer]() + if let extensionContainers = extensionContainers { + selectedContainer = ShareDefaultContainer.defaultContainer(containers: extensionContainers) + } title = "NetNewsWire" placeholder = "Feed Name (Optional)" @@ -32,14 +35,14 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont button.title = "Add Feed" button.isEnabled = true } - + // Hack the bottom table rows to be smaller since the controller itself doesn't have enough sense to size itself correctly if let nav = self.children.first as? UINavigationController, let tableView = nav.children.first?.view.subviews.first as? UITableView { tableView.rowHeight = 38 } var provider: NSItemProvider? = nil - + // Try to get any HTML that is maybe passed in for item in self.extensionContext!.inputItems as! [NSExtensionItem] { for itemProvider in item.attachments! { @@ -48,7 +51,7 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont } } } - + if provider != nil { provider!.loadItem(forTypeIdentifier: kUTTypePropertyList as String, options: nil, completionHandler: { [weak self] (pList, error) in if error != nil { @@ -66,7 +69,7 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont }) return } - + // Try to get the URL if it is passed in for item in self.extensionContext!.inputItems as! [NSExtensionItem] { for itemProvider in item.attachments! { @@ -75,7 +78,7 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont } } } - + if provider != nil { provider!.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil, completionHandler: { [weak self] (urlCoded, error) in if error != nil { @@ -91,53 +94,25 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont } override func isContentValid() -> Bool { - return url != nil && container != nil + return url != nil && selectedContainer != nil } override func didSelectPost() { - - guard let url = url, let container = container else { + guard let url = url, let selectedContainer = selectedContainer, let containerID = selectedContainer.containerID else { self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil) return } - - var account: Account? - if let containerAccount = container as? Account { - account = containerAccount - } else if let containerFolder = container as? Folder, let containerAccount = containerFolder.account { - account = containerAccount - } else { - self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil) - return - } - - if account!.hasWebFeed(withURL: url.absoluteString) { - let errorTitle = NSLocalizedString("Error", comment: "Error") - presentError(title: errorTitle, message: AccountError.createErrorAlreadySubscribed.localizedDescription) - self.extensionContext!.cancelRequest(withError: AccountError.createErrorAlreadySubscribed) - return - } - - let feedName = contentText.isEmpty ? nil : contentText - - ProcessInfo.processInfo.performExpiringActivity(withReason: "Adding web feed to account.") { expired in - guard !expired else { return } - - DispatchQueue.main.async { - account!.createWebFeed(url: url.absoluteString, name: feedName, container: container) { result in - account!.save() - AccountManager.shared.suspendNetworkAll() - AccountManager.shared.suspendDatabaseAll() - } - } - } + + let name = contentText.isEmpty ? nil : contentText + let request = ExtensionFeedAddRequest(name: name, feedURL: url, destinationContainerID: containerID) + ExtensionFeedAddRequestFile.save(request) self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil) } - func shareFolderPickerDidSelect(_ container: Container) { - AddWebFeedDefaultContainer.saveDefaultContainer(container) - self.container = container + func shareFolderPickerDidSelect(_ container: ExtensionContainer) { + ShareDefaultContainer.saveDefaultContainer(container) + self.selectedContainer = container updateFolderItemValue() self.popConfigurationViewController() } @@ -159,7 +134,8 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont folderPickerController.navigationController?.title = NSLocalizedString("Folder", comment: "Folder") folderPickerController.delegate = self - folderPickerController.selectedContainer = self.container + folderPickerController.containers = self.flattenedContainers + folderPickerController.selectedContainerID = self.selectedContainer?.containerID self.pushConfigurationViewController(folderPickerController) @@ -174,12 +150,10 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont private extension ShareViewController { func updateFolderItemValue() { - if let containerName = (container as? DisplayNameProvider)?.nameForDisplay { - if container is Folder { - self.folderItem.value = "\(container?.account?.nameForDisplay ?? "") / \(containerName)" - } else { - self.folderItem.value = containerName - } + if let account = selectedContainer as? ExtensionAccount { + self.folderItem.value = account.name + } else if let folder = selectedContainer as? ExtensionFolder { + self.folderItem.value = "\(folder.accountName) / \(folder.name)" } }