From 9e3e093bcd6d36f8103e31d490795d156cc5607f Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Thu, 23 Nov 2017 14:15:28 -0800 Subject: [PATCH] Make progress on favicons. --- Evergreen.xcodeproj/project.pbxproj | 16 ++++++ Evergreen/Favicons/FaviconCache.swift | 29 ++++++++++ Evergreen/Favicons/FaviconDownloader.swift | 57 ++++++++++++++----- Evergreen/Favicons/FaviconMetadata.swift | 26 +++++++++ Evergreen/Favicons/ImageDownloader.swift | 57 +++++++++++++++++++ Evergreen/Favicons/SeekingFavicon.swift | 66 ++++++++++++++++++++++ 6 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 Evergreen/Favicons/FaviconCache.swift create mode 100644 Evergreen/Favicons/FaviconMetadata.swift create mode 100644 Evergreen/Favicons/ImageDownloader.swift create mode 100644 Evergreen/Favicons/SeekingFavicon.swift diff --git a/Evergreen.xcodeproj/project.pbxproj b/Evergreen.xcodeproj/project.pbxproj index 827e6f99d..aea0aff0d 100644 --- a/Evergreen.xcodeproj/project.pbxproj +++ b/Evergreen.xcodeproj/project.pbxproj @@ -13,6 +13,10 @@ 842E45E51ED8C6B7000A8B52 /* MainWindowSplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842E45E41ED8C6B7000A8B52 /* MainWindowSplitView.swift */; }; 842E45E71ED8C747000A8B52 /* DB5.plist in Resources */ = {isa = PBXBuildFile; fileRef = 842E45E61ED8C747000A8B52 /* DB5.plist */; }; 84513F901FAA63950023A1A9 /* FeedListControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84513F8F1FAA63950023A1A9 /* FeedListControlsView.swift */; }; + 845A29091FC74B8E007B49E3 /* FaviconMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845A29081FC74B8E007B49E3 /* FaviconMetadata.swift */; }; + 845A29191FC7563E007B49E3 /* FaviconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845A29181FC7563E007B49E3 /* FaviconCache.swift */; }; + 845A291B1FC75AA6007B49E3 /* SeekingFavicon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845A291A1FC75AA6007B49E3 /* SeekingFavicon.swift */; }; + 845A291D1FC75F49007B49E3 /* ImageDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845A291C1FC75F49007B49E3 /* ImageDownloader.swift */; }; 845EE7B11FC2366500854A1F /* StarredFeedDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845EE7B01FC2366500854A1F /* StarredFeedDelegate.swift */; }; 845EE7C11FC2488C00854A1F /* SmartFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845EE7C01FC2488C00854A1F /* SmartFeed.swift */; }; 845F52ED1FB2B9FC00C10BF0 /* FeedPasteboardWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F52EC1FB2B9FC00C10BF0 /* FeedPasteboardWriter.swift */; }; @@ -403,6 +407,10 @@ 842E45E41ED8C6B7000A8B52 /* MainWindowSplitView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindowSplitView.swift; sourceTree = ""; }; 842E45E61ED8C747000A8B52 /* DB5.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = DB5.plist; path = Evergreen/Resources/DB5.plist; sourceTree = ""; }; 84513F8F1FAA63950023A1A9 /* FeedListControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListControlsView.swift; sourceTree = ""; }; + 845A29081FC74B8E007B49E3 /* FaviconMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconMetadata.swift; sourceTree = ""; }; + 845A29181FC7563E007B49E3 /* FaviconCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconCache.swift; sourceTree = ""; }; + 845A291A1FC75AA6007B49E3 /* SeekingFavicon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeekingFavicon.swift; sourceTree = ""; }; + 845A291C1FC75F49007B49E3 /* ImageDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDownloader.swift; sourceTree = ""; }; 845B14A51FC2299E0013CF92 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 845EE7B01FC2366500854A1F /* StarredFeedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarredFeedDelegate.swift; sourceTree = ""; }; 845EE7C01FC2488C00854A1F /* SmartFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeed.swift; sourceTree = ""; }; @@ -581,6 +589,10 @@ isa = PBXGroup; children = ( 848F6AE41FC29CFA002D422E /* FaviconDownloader.swift */, + 845A291C1FC75F49007B49E3 /* ImageDownloader.swift */, + 845A291A1FC75AA6007B49E3 /* SeekingFavicon.swift */, + 845A29081FC74B8E007B49E3 /* FaviconMetadata.swift */, + 845A29181FC7563E007B49E3 /* FaviconCache.swift */, 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */, ); name = Favicons; @@ -1308,6 +1320,7 @@ 84F2D5371FC22FCC00998D64 /* PseudoFeed.swift in Sources */, 845EE7C11FC2488C00854A1F /* SmartFeed.swift in Sources */, 84702AA41FA27AC0006B8943 /* MarkReadOrUnreadCommand.swift in Sources */, + 845A291B1FC75AA6007B49E3 /* SeekingFavicon.swift in Sources */, 849A979F1ED9F130007D329B /* SidebarCell.swift in Sources */, 849A97651ED9EB96007D329B /* SidebarTreeControllerDelegate.swift in Sources */, 849A97671ED9EB96007D329B /* UnreadCountView.swift in Sources */, @@ -1329,9 +1342,11 @@ 849A97831ED9EC63007D329B /* StatusBarView.swift in Sources */, 84F2D5381FC22FCC00998D64 /* TodayFeedDelegate.swift in Sources */, 849A97431ED9EAA9007D329B /* AddFolderWindowController.swift in Sources */, + 845A29191FC7563E007B49E3 /* FaviconCache.swift in Sources */, 849A97921ED9EF65007D329B /* IndeterminateProgressWindowController.swift in Sources */, 849A97801ED9EC42007D329B /* DetailViewController.swift in Sources */, 849A976E1ED9EBC8007D329B /* TimelineViewController.swift in Sources */, + 845A291D1FC75F49007B49E3 /* ImageDownloader.swift in Sources */, 849A978D1ED9EE4D007D329B /* FeedListWindowController.swift in Sources */, 849A97771ED9EC04007D329B /* TimelineCellData.swift in Sources */, 84B99C6B1FAE370B00ECDEDB /* FeedListFeed.swift in Sources */, @@ -1345,6 +1360,7 @@ 84B99C691FAE36B800ECDEDB /* FeedListFolder.swift in Sources */, 84F204DE1FAACB8B0076E152 /* FeedListTimelineViewController.swift in Sources */, 849A97A31ED9F180007D329B /* FolderTreeControllerDelegate.swift in Sources */, + 845A29091FC74B8E007B49E3 /* FaviconMetadata.swift in Sources */, 849A97851ED9ECCD007D329B /* PreferencesWindowController.swift in Sources */, 849A977A1ED9EC04007D329B /* TimelineTableCellView.swift in Sources */, 849A97761ED9EC04007D329B /* TimelineCellAppearance.swift in Sources */, diff --git a/Evergreen/Favicons/FaviconCache.swift b/Evergreen/Favicons/FaviconCache.swift new file mode 100644 index 000000000..e340ff8d0 --- /dev/null +++ b/Evergreen/Favicons/FaviconCache.swift @@ -0,0 +1,29 @@ +// +// FaviconCache.swift +// Evergreen +// +// Created by Brent Simmons on 11/23/17. +// Copyright © 2017 Ranchero Software. All rights reserved. +// + +import Foundation + +final class FaviconCache { + + static var cache = [String: Favicon]() + + class func cachedFavicon(_ homePageURL: String) -> Favicon? { + + return cache[homePageURL] + } + + class func cacheFavicon(_ homePageURL: String, _ favicon: Favicon) { + + cache[homePageURL] = favicon + } + + class func removeFavicon(_ homePageURL: String) { + + cache[homePageURL] = nil + } +} diff --git a/Evergreen/Favicons/FaviconDownloader.swift b/Evergreen/Favicons/FaviconDownloader.swift index 6009e5861..d18e17201 100644 --- a/Evergreen/Favicons/FaviconDownloader.swift +++ b/Evergreen/Favicons/FaviconDownloader.swift @@ -18,6 +18,7 @@ extension Notification.Name { final class FaviconDownloader { + private var seekingFaviconCache: [String: SeekingFavicon]() // homePageURL: SeekingFavicon private var cache = ThreadSafeCache() // faviconURL: NSImage private var faviconURLCache = ThreadSafeCache() // homePageURL: faviconURL private let folder: String @@ -37,7 +38,7 @@ final class FaviconDownloader { self.folder = folder self.binaryCache = RSBinaryCache(folder: folder) - self.queue = DispatchQueue(label: "FaviconCache serial queue - \(folder)") + self.queue = DispatchQueue(label: "FaviconDownloader serial queue - \(folder)") } // MARK: - API @@ -45,27 +46,43 @@ final class FaviconDownloader { func favicon(for feed: Feed) -> NSImage? { assert(Thread.isMainThread) - guard let homePageURL = feed.homePageURL else { return nil } + if let favicon = cachedInMemoryFavicon(for: feed) { + return favicon + } + + findFavicon(for: feed) + } + + func findFavicon(for feed: Feed) { + + if let faviconMetadata = cachedFaviconMetadata if let faviconURL = faviconURL(for: feed) { - if let cachedFavicon = cache[faviconURL] { - return cachedFavicon + // It might be on disk. + + readFaviconFromDisk(faviconURL) { (image) in + + if let image = image { + self.cache[faviconURL] = image + self.postFaviconDidBecomeAvailableNotification(homePageURL: homePageURL, faviconURL: faviconURL, image: image) + return + } + + // Download it (probably). + + if !self.shouldDownloadFaviconURL(faviconURL) { + return + } + + } - - // TODO: read from disk and return if present. - - if shouldDownloadFaviconURL(faviconURL) { - downloadFavicon(faviconURL, homePageURL) - return nil - } - - return nil } + // Try to find the faviconURL. It might be in the web page. FaviconURLFinder.findFaviconURL(homePageURL) { (faviconURL) in @@ -84,6 +101,14 @@ final class FaviconDownloader { private extension FaviconDownloader { + func cachedInMemoryFavicon(for feed: Feed) -> NSImage? { + + guard let faviconURL = faviconURL(for: feed), let cachedFavicon = cache[faviconURL] else { + return nil + } + return cachedFavicon + } + func shouldDownloadFaviconURL(_ faviconURL: String) -> Bool { return !urlsBeingDownloaded.contains(faviconURL) && !badURLs.contains(faviconURL) @@ -168,4 +193,10 @@ private extension FaviconDownloader { return (faviconURL as NSString).rs_md5Hash() } + + func postFaviconDidBecomeAvailableNotification(homePageURL: String, faviconURL: String, image: NSImage) { + + let userInfo: [AnyHashable: Any] = [UserInfoKey.homePageURL: homePageURL, UserInfoKey.faviconURL: faviconURL, UserInfoKey.image: image] + NotificationCenter.default.post(name: .FaviconDidBecomeAvailable, object: self, userInfo: userInfo) + } } diff --git a/Evergreen/Favicons/FaviconMetadata.swift b/Evergreen/Favicons/FaviconMetadata.swift new file mode 100644 index 000000000..8e25a1215 --- /dev/null +++ b/Evergreen/Favicons/FaviconMetadata.swift @@ -0,0 +1,26 @@ +// +// Favicon.swift +// Evergreen +// +// Created by Brent Simmons on 11/23/17. +// Copyright © 2017 Ranchero Software. All rights reserved. +// + +import AppKit + +final class FaviconMetadata { + + enum DiskStatus { + case unknown, notOnDisk, onDisk + } + + let faviconURL: String + var lastDownloadAttemptDate: Date? + var diskStatus = DiskStatus.unknown + var image: NSImage? + + init?(faviconURL: String) { + + self.faviconURL = faviconURL + } +} diff --git a/Evergreen/Favicons/ImageDownloader.swift b/Evergreen/Favicons/ImageDownloader.swift new file mode 100644 index 000000000..2cca8b84b --- /dev/null +++ b/Evergreen/Favicons/ImageDownloader.swift @@ -0,0 +1,57 @@ +// +// FaviconImageDownloader.swift +// Evergreen +// +// Created by Brent Simmons on 11/23/17. +// Copyright © 2017 Ranchero Software. All rights reserved. +// + +import AppKit +import RSWeb +import RSCore + +// Downloads using cache. Enforces a minimum time interval between attempts. + +final class ImageDownloader { + + private var urlsBeingDownloaded = Set() + private var lastAttemptDates = [String: Date]() + private let minimumAttemptInterval: TimeInterval = 5 * 60 + + func downloadImage(_ url: String, _ callback: @escaping (NSImage?) -> Void) { + + guard shouldDownloadImage(url) else { + callback(nil) + return + } + + urlsBeingDownloaded.insert(url) + lastAttemptDates[url] = Date() + + downloadUsingCache(url) { (data, response, error) in + + urlsBeingDownloaded.remove(url) + + if let data = data, let response = response, response.statusIsOK, error == nil { + NSImage.rs_image(with: data, imageResultBlock: callback) + return + } + callback(nil) + } + } +} + +private extension ImageDownload { + + func shouldDownloadImage(_ url: String) -> Bool { + + if urlsBeingDownloaded.contains(url) { + return false + } + if let lastAttemptDate = lastAttemptDates[url], Date().timeIntervalSince(lastAttemptDate) < minimumAttemptInterval { + return false + } + + return true + } +} diff --git a/Evergreen/Favicons/SeekingFavicon.swift b/Evergreen/Favicons/SeekingFavicon.swift new file mode 100644 index 000000000..809686eb6 --- /dev/null +++ b/Evergreen/Favicons/SeekingFavicon.swift @@ -0,0 +1,66 @@ +// +// SeekingFavicon.swift +// Evergreen +// +// Created by Brent Simmons on 11/23/17. +// Copyright © 2017 Ranchero Software. All rights reserved. +// + +import Foundation + +extension Notification.Name { + + static let SeekingFaviconDidFindFaviconURL = Notification.Name("SeekingFaviconDidFindFaviconURLNotification") + static let SeekingFaviconDidNotFindFaviconURL = Notification.Name("SeekingFaviconDidNotFindFaviconURLNotification") +} + +final class SeekingFavicon { + + // At first, when looking for a favicon, we only know the homePageURL. + // The faviconURL may be specified by metadata in the home page, + // or it might be at /favicon.ico, + // or it might not exist (or be unfindable, which is the same thing). + + let homePageURL: String + let defaultFaviconURL: String // /favicon.ico + var didAttemptToLookAtHomePageMetadata = false + var foundFaviconURL: String? + + var shouldUseDefaultFaviconURL: Bool { + + return didAttemptToLookAtHomePageMetadata && foundFaviconURL == nil + } + + private static let localeForLowercasing = Locale(identifier: "en_US") + + init?(homePageURL: String) { + + guard let url = URL(string: homePageURL), let scheme = url.scheme, let host = url.host else { + return nil + } + + self.homePageURL = homePageURL + self.defaultFaviconURL = "\(scheme)://\(host)/favicon.ico".lowercased(with: SeekingFavicon.localeForLowercasing) + + findFaviconURL() + } +} + +private extension SeekingFavicon { + + func findFaviconURL() { + + FaviconURLFinder.findFaviconURL(homePageURL) { (faviconURL) in + + self.didAttemptToLookAtHomePageMetadata = true + self.foundFaviconURL = faviconURL + + if let _ = faviconURL { + NotificationCenter.default.post(name: .SeekingFaviconDidFindFaviconURL, object: self) + } + else { + NotificationCenter.default.post(name: .SeekingFaviconDidNotFindFaviconURL, object: self) + } + } + } +}