Make further progress on favicons. Should be close to a first cut now.

This commit is contained in:
Brent Simmons 2017-11-24 13:12:18 -08:00
parent 32973c4c85
commit f8a05badcb
7 changed files with 213 additions and 322 deletions

View File

@ -13,10 +13,8 @@
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 */; };
845A29091FC74B8E007B49E3 /* SingleFaviconDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845A29081FC74B8E007B49E3 /* SingleFaviconDownloader.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 */; };
@ -407,10 +405,8 @@
842E45E41ED8C6B7000A8B52 /* MainWindowSplitView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindowSplitView.swift; sourceTree = "<group>"; };
842E45E61ED8C747000A8B52 /* DB5.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = DB5.plist; path = Evergreen/Resources/DB5.plist; sourceTree = "<group>"; };
84513F8F1FAA63950023A1A9 /* FeedListControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListControlsView.swift; sourceTree = "<group>"; };
845A29081FC74B8E007B49E3 /* FaviconMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconMetadata.swift; sourceTree = "<group>"; };
845A29181FC7563E007B49E3 /* FaviconCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconCache.swift; sourceTree = "<group>"; };
845A29081FC74B8E007B49E3 /* SingleFaviconDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleFaviconDownloader.swift; sourceTree = "<group>"; };
845A291A1FC75AA6007B49E3 /* SeekingFavicon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeekingFavicon.swift; sourceTree = "<group>"; };
845A291C1FC75F49007B49E3 /* ImageDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDownloader.swift; sourceTree = "<group>"; };
845B14A51FC2299E0013CF92 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
845EE7B01FC2366500854A1F /* StarredFeedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarredFeedDelegate.swift; sourceTree = "<group>"; };
845EE7C01FC2488C00854A1F /* SmartFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartFeed.swift; sourceTree = "<group>"; };
@ -589,10 +585,8 @@
isa = PBXGroup;
children = (
848F6AE41FC29CFA002D422E /* FaviconDownloader.swift */,
845A291C1FC75F49007B49E3 /* ImageDownloader.swift */,
845A29081FC74B8E007B49E3 /* SingleFaviconDownloader.swift */,
845A291A1FC75AA6007B49E3 /* SeekingFavicon.swift */,
845A29081FC74B8E007B49E3 /* FaviconMetadata.swift */,
845A29181FC7563E007B49E3 /* FaviconCache.swift */,
84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */,
);
name = Favicons;
@ -1342,11 +1336,9 @@
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 */,
@ -1360,7 +1352,7 @@
84B99C691FAE36B800ECDEDB /* FeedListFolder.swift in Sources */,
84F204DE1FAACB8B0076E152 /* FeedListTimelineViewController.swift in Sources */,
849A97A31ED9F180007D329B /* FolderTreeControllerDelegate.swift in Sources */,
845A29091FC74B8E007B49E3 /* FaviconMetadata.swift in Sources */,
845A29091FC74B8E007B49E3 /* SingleFaviconDownloader.swift in Sources */,
849A97851ED9ECCD007D329B /* PreferencesWindowController.swift in Sources */,
849A977A1ED9EC04007D329B /* TimelineTableCellView.swift in Sources */,
849A97761ED9EC04007D329B /* TimelineCellAppearance.swift in Sources */,

View File

@ -1,29 +0,0 @@
//
// 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
}
}

View File

@ -9,37 +9,32 @@
import AppKit
import Data
import RSCore
import RSWeb
extension Notification.Name {
static let FaviconDidBecomeAvailable = Notification.Name("FaviconDidBecomeAvailableNotification") // userInfo keys, one or more of which will be present: homePageURL, faviconURL
static let FaviconDidBecomeAvailable = Notification.Name("FaviconDidBecomeAvailableNotification") // userInfo key: FaviconDownloader.UserInfoKey.faviconURL
}
final class FaviconDownloader {
private var imageCache = [String: NSImage]()
private var seekingFaviconCache = [String: SeekingFavicon]() // homePageURL: SeekingFavicon
private var cache = ThreadSafeCache<NSImage>() // faviconURL: NSImage
private var faviconURLCache = ThreadSafeCache<String>() // homePageURL: faviconURL
private var singleFaviconDownloaderCache = [String: SingleFaviconDownloader]() // faviconURL: SingleFaviconDownloader
private let folder: String
private var urlsBeingDownloaded = Set<String>()
private var badURLs = Set<String>() // URLs that didnt work for some reason; dont try again
private let binaryCache: RSBinaryCache
private var badImages = Set<String>() // keys for images on disk that NSImage cant handle
private let diskCache: BinaryDiskCache
private let queue: DispatchQueue
public struct UserInfoKey {
static let homePageURL = "homePageURL"
struct UserInfoKey {
static let faviconURL = "faviconURL"
static let image = "image" // NSImage
}
init(folder: String) {
self.folder = folder
self.binaryCache = RSBinaryCache(folder: folder)
self.diskCache = BinaryDiskCache(folder: folder)
self.queue = DispatchQueue(label: "FaviconDownloader serial queue - \(folder)")
NotificationCenter.default.addObserver(self, selector: #selector(seekingFaviconDidSeek(_:)), name: .SeekingFaviconSeekDidComplete, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(didLoadFavicon(_:)), name: .DidLoadFavicon, object: nil)
}
// MARK: - API
@ -49,9 +44,7 @@ final class FaviconDownloader {
assert(Thread.isMainThread)
if let faviconURL = feed.faviconURL {
// JSON Feeds may include the faviconURL in the feed,
// so we dont have to hunt for it.
return favicon(withURL: faviconURL)
return favicon(with: faviconURL)
}
guard let homePageURL = feed.homePageURL else {
@ -60,181 +53,82 @@ final class FaviconDownloader {
return favicon(withHomePageURL: homePageURL)
}
func favicon(withURL faviconURL: String) -> NSImage? {
func favicon(with faviconURL: String) -> NSImage? {
if let cachedImage = imageCache[faviconURL] {
return cachedImage
}
let controller = faviconController(withURL: faviconURL)
return favicon(withController: controller)
let downloader = faviconDownloader(withURL: faviconURL)
return downloader.image
}
func faviconController(withURL faviconURL: String) -> FaviconController {
func favicon(withHomePageURL homePageURL: String) -> NSImage? {
if let controller = faviconControllerCache[faviconURL] {
return controller
guard let seekingFavicon = seekingFavicon(with: homePageURL) else {
return nil
}
let controller = FaviconController(faviconURL: faviconURL)
faviconControllerCache[faviconURL] = controller
return controller
return favicon(withSeekingFavicon: seekingFavicon)
}
func favicon(withController controller: FaviconController) -> NSImage? {
// MARK: - Notifications
if let image = controller.image {
return image
@objc func seekingFaviconDidSeek(_ note: Notification) {
guard let seekingFavicon = note.object as? SeekingFavicon else {
return
}
controller.readFromDisk(binaryCache) { (image) in
if let image = image {
post
}
}
favicon(withSeekingFavicon: seekingFavicon)
}
func findFavicon(for feed: Feed) {
@objc func didLoadFavicon(_ note: Notification) {
// if let faviconMetadata = cachedFaviconMetadata
if let faviconURL = faviconURL(for: feed) {
// 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
}
}
guard let singleFaviconDownloader = note.object as? SingleFaviconDownloader else {
return
}
guard let _ = singleFaviconDownloader.image else {
return
}
// Try to find the faviconURL. It might be in the web page.
FaviconURLFinder.findFaviconURL(homePageURL) { (faviconURL) in
if let faviconURL = faviconURL {
print(faviconURL) // cache it; then download favicon
}
else {
// Try appending /favicon.ico
// It often works.
}
}
return nil
postFaviconDidBecomeAvailableNotification(singleFaviconDownloader.faviconURL)
}
}
private extension FaviconDownloader {
func cachedInMemoryFavicon(for feed: Feed) -> NSImage? {
@discardableResult
func favicon(withSeekingFavicon seekingFavicon: SeekingFavicon) -> NSImage? {
guard let faviconURL = faviconURL(for: feed), let cachedFavicon = cache[faviconURL] else {
guard let faviconURL = seekingFavicon.faviconURL else {
return nil
}
return cachedFavicon
return favicon(with: faviconURL)
}
func shouldDownloadFaviconURL(_ faviconURL: String) -> Bool {
func faviconDownloader(withURL faviconURL: String) -> SingleFaviconDownloader {
return !urlsBeingDownloaded.contains(faviconURL) && !badURLs.contains(faviconURL)
if let downloader = singleFaviconDownloaderCache[faviconURL] {
downloader.downloadFaviconIfNeeded()
return downloader
}
let downloader = SingleFaviconDownloader(faviconURL: faviconURL, diskCache: diskCache, queue: queue)
singleFaviconDownloaderCache[faviconURL] = downloader
return downloader
}
func downloadFavicon(_ faviconURL: String, _ homePageURL: String) {
func seekingFavicon(with homePageURL: String) -> SeekingFavicon? {
guard let url = URL(string: faviconURL) else {
return
if let seekingFavicon = seekingFaviconCache[homePageURL] {
return seekingFavicon
}
urlsBeingDownloaded.insert(faviconURL)
downloadUsingCache(url) { (data, response, error) in
self.urlsBeingDownloaded.remove(faviconURL)
if response == nil || !response!.statusIsOK {
self.badURLs.insert(faviconURL)
}
if let data = data {
self.queue.async {
let _ = NSImage(data: data)
}
}
}
}
func faviconURL(for feed: Feed) -> String? {
if let faviconURL = feed.faviconURL {
return faviconURL
}
if let homePageURL = feed.homePageURL {
return faviconURLCache[homePageURL]
}
return nil
}
func readFaviconFromDisk(_ faviconURL: String, _ callback: @escaping (NSImage?) -> Void) {
queue.async {
let image = self.tryToInstantiateNSImageFromDisk(faviconURL)
DispatchQueue.main.async {
callback(image)
}
}
}
func tryToInstantiateNSImageFromDisk(_ faviconURL: String) -> NSImage? {
// Call on serial queue.
if badImages.contains(faviconURL) {
guard let seekingFavicon = SeekingFavicon(homePageURL: homePageURL) else {
return nil
}
let key = keyFor(faviconURL)
var data: Data?
do {
data = try binaryCache.binaryData(forKey: key)
}
catch {
return nil
}
if data == nil {
return nil
}
guard let image = NSImage(data: data!) else {
badImages.insert(faviconURL)
return nil
}
return image
seekingFaviconCache[homePageURL] = seekingFavicon
return seekingFavicon
}
func keyFor(_ faviconURL: String) -> String {
func postFaviconDidBecomeAvailableNotification(_ faviconURL: String) {
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]
let userInfo: [AnyHashable: Any] = [UserInfoKey.faviconURL: faviconURL]
NotificationCenter.default.post(name: .FaviconDidBecomeAvailable, object: self, userInfo: userInfo)
}
}

View File

@ -1,51 +0,0 @@
//
// Favicon.swift
// Evergreen
//
// Created by Brent Simmons on 11/23/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import AppKit
import RSCore
final class FaviconController {
enum DiskStatus {
case unknown, notOnDisk, onDisk
}
let faviconURL: String
var lastDownloadAttemptDate: Date?
var diskStatus = DiskStatus.unknown
let diskCache: RSBinaryCache
var image: NSImage?
init?(faviconURL: String, _ diskCache: RSBinaryCache) {
self.faviconURL = faviconURL
self.diskCache = diskCache
findFavicon()
}
}
private extension FaviconController {
func findFavicon() {
readFromDisk { (image) in
self.image = image
}
}
func readFromDisk(_ callback: (NSImage?) -> Void) {
if diskStatus == .notOnDisk {
callback(nil)
return
}
}
}

View File

@ -1,57 +0,0 @@
//
// 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<String>()
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 ImageDownloader {
func shouldDownloadImage(_ url: String) -> Bool {
if urlsBeingDownloaded.contains(url) {
return false
}
if let lastAttemptDate = lastAttemptDates[url], Date().timeIntervalSince(lastAttemptDate) < minimumAttemptInterval {
return false
}
return true
}
}

View File

@ -10,8 +10,7 @@ import Foundation
extension Notification.Name {
static let SeekingFaviconDidFindFaviconURL = Notification.Name("SeekingFaviconDidFindFaviconURLNotification")
static let SeekingFaviconDidNotFindFaviconURL = Notification.Name("SeekingFaviconDidNotFindFaviconURLNotification")
static let SeekingFaviconSeekDidComplete = Notification.Name("SeekingFaviconSeekDidCompleteNotification")
}
final class SeekingFavicon {
@ -21,16 +20,14 @@ final class SeekingFavicon {
// 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
var didSeek = false
var faviconURL: String? {
return didSeek ? (foundFaviconURL ?? defaultFaviconURL) : nil
}
private let homePageURL: String
private var foundFaviconURL: String?
private let defaultFaviconURL: String // /favicon.ico
private static let localeForLowercasing = Locale(identifier: "en_US")
init?(homePageURL: String) {
@ -52,15 +49,10 @@ private extension SeekingFavicon {
FaviconURLFinder.findFaviconURL(homePageURL) { (faviconURL) in
self.didAttemptToLookAtHomePageMetadata = true
self.foundFaviconURL = faviconURL
self.didSeek = true
if let _ = faviconURL {
NotificationCenter.default.post(name: .SeekingFaviconDidFindFaviconURL, object: self)
}
else {
NotificationCenter.default.post(name: .SeekingFaviconDidNotFindFaviconURL, object: self)
}
NotificationCenter.default.post(name: .SeekingFaviconSeekDidComplete, object: self)
}
}
}

View File

@ -0,0 +1,150 @@
//
// SingleFaviconDownloader.swift
// Evergreen
//
// Created by Brent Simmons on 11/23/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import AppKit
import RSCore
import RSWeb
// The image may be on disk already. If not, download it.
// Post .DidLoadFavicon notification once its in memory.
extension Notification.Name {
static let DidLoadFavicon = Notification.Name("DidLoadFaviconNotification")
}
final class SingleFaviconDownloader {
enum DiskStatus {
case unknown, notOnDisk, onDisk
}
let faviconURL: String
var image: NSImage?
private var lastDownloadAttemptDate: Date
private var diskStatus = DiskStatus.unknown
private var diskCache: BinaryDiskCache
private let queue: DispatchQueue
private var diskKey: String {
return (faviconURL as NSString).rs_md5Hash()
}
init(faviconURL: String, diskCache: BinaryDiskCache, queue: DispatchQueue) {
self.faviconURL = faviconURL
self.diskCache = diskCache
self.queue = queue
self.lastDownloadAttemptDate = Date()
findFavicon()
}
func downloadFaviconIfNeeded() {
// If we dont have an image, and lastDownloadAttemptDate is a while ago, try again.
if let _ = image {
return
}
let retryInterval: TimeInterval = 30 * 60 // 30 minutes
if Date().timeIntervalSince(lastDownloadAttemptDate) < retryInterval {
return
}
lastDownloadAttemptDate = Date()
findFavicon()
}
}
private extension SingleFaviconDownloader {
func findFavicon() {
readFromDisk { (image) in
if let image = image {
self.diskStatus = .onDisk
self.image = image
self.postDidLoadFaviconNotification()
return
}
self.diskStatus = .notOnDisk
self.downloadFavicon { (image) in
if let image = image {
self.image = image
self.postDidLoadFaviconNotification()
}
}
}
}
func readFromDisk(_ callback: @escaping (NSImage?) -> Void) {
guard diskStatus != .notOnDisk else {
callback(nil)
return
}
queue.async {
if let data = self.diskCache[self.diskKey], !data.isEmpty {
NSImage.rs_image(with: data, imageResultBlock: callback)
return
}
DispatchQueue.main.async {
callback(nil)
}
}
}
func saveToDisk(_ data: Data) {
queue.async {
do {
try self.diskCache.setData(data, forKey: self.diskKey)
DispatchQueue.main.async {
self.diskStatus = .onDisk
}
}
catch {}
}
}
func downloadFavicon(_ callback: @escaping (NSImage?) -> Void) {
guard let url = URL(string: faviconURL) else {
callback(nil)
return
}
downloadUsingCache(url) { (data, response, error) in
if let data = data, !data.isEmpty, let response = response, response.statusIsOK, error == nil {
self.saveToDisk(data)
NSImage.rs_image(with: data, imageResultBlock: callback)
return
}
callback(nil)
}
}
func postDidLoadFaviconNotification() {
assert(Thread.isMainThread)
NotificationCenter.default.post(name: .DidLoadFavicon, object: self)
}
}