Merge branch 'extension-point'

This commit is contained in:
Maurice Parker
2020-04-24 13:34:13 -05:00
163 changed files with 6165 additions and 480 deletions

View File

@@ -8,6 +8,7 @@
import Foundation
import Account
import Secrets
public enum ArticleExtractorState {
case ready

View File

@@ -235,6 +235,25 @@ blockquote {
max-height: 1em;
}
/* Twitter */
.twitterAvatar {
vertical-align: middle;
border-radius: 4px;
height: 24px;
width: 24px;
}
.twitterUsername {
margin-left: 4px;
display: inline-block;
vertical-align: middle;
}
.twitterTimestamp {
font-size: 66%;
}
/*Block ads and junk*/
iframe[src*="feedads"],

View File

@@ -0,0 +1,60 @@
//
// ExtensionPoint.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
#if os(macOS)
import AppKit
#else
import UIKit
#endif
import RSCore
protocol ExtensionPoint {
static var isSinglton: Bool { get }
static var isDeveloperBuildRestricted: Bool { get }
static var title: String { get }
static var templateImage: RSImage { get }
static var description: NSAttributedString { get }
var title: String { get }
var extensionPointID: ExtensionPointIdentifer { get }
}
extension ExtensionPoint {
var templateImage: RSImage {
return extensionPointID.extensionPointType.templateImage
}
var description: NSAttributedString {
return extensionPointID.extensionPointType.description
}
static func makeAttrString(_ text: String) -> NSMutableAttributedString {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
#if os(macOS)
let attrs = [
NSAttributedString.Key.paragraphStyle: paragraphStyle,
NSAttributedString.Key.font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
NSAttributedString.Key.foregroundColor: NSColor.textColor
]
#else
let attrs = [
NSAttributedString.Key.paragraphStyle: paragraphStyle,
NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .body),
NSAttributedString.Key.foregroundColor: UIColor.label
]
#endif
return NSMutableAttributedString(string: text, attributes: attrs)
}
}

View File

@@ -0,0 +1,85 @@
//
// ExtensionPointIdentifer.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/8/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import RSCore
enum ExtensionPointIdentifer: Hashable {
#if os(macOS)
case marsEdit
case microblog
#endif
case twitter(String)
var extensionPointType: ExtensionPoint.Type {
switch self {
#if os(macOS)
case .marsEdit:
return SendToMarsEditCommand.self
case .microblog:
return SendToMicroBlogCommand.self
#endif
case .twitter:
return TwitterFeedProvider.self
}
}
public var userInfo: [AnyHashable: AnyHashable] {
switch self {
#if os(macOS)
case .marsEdit:
return [
"type": "marsEdit"
]
case .microblog:
return [
"type": "microblog"
]
#endif
case .twitter(let screenName):
return [
"type": "twitter",
"screenName": screenName
]
}
}
public init?(userInfo: [AnyHashable: AnyHashable]) {
guard let type = userInfo["type"] as? String else { return nil }
switch type {
#if os(macOS)
case "marsEdit":
self = ExtensionPointIdentifer.marsEdit
case "microblog":
self = ExtensionPointIdentifer.microblog
#endif
case "twitter":
guard let screenName = userInfo["screenName"] as? String else { return nil }
self = ExtensionPointIdentifer.twitter(screenName)
default:
return nil
}
}
public func hash(into hasher: inout Hasher) {
switch self {
#if os(macOS)
case .marsEdit:
hasher.combine("marsEdit")
case .microblog:
hasher.combine("microblog")
#endif
case .twitter(let screenName):
hasher.combine("twitter")
hasher.combine(screenName)
}
}
}

View File

@@ -0,0 +1,142 @@
//
// ExtensionPointManager.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import RSCore
import OAuthSwift
public extension Notification.Name {
static let ActiveExtensionPointsDidChange = Notification.Name(rawValue: "ActiveExtensionPointsDidChange")
}
final class ExtensionPointManager: FeedProviderManagerDelegate {
static let shared = ExtensionPointManager()
var activeExtensionPoints = [ExtensionPointIdentifer: ExtensionPoint]()
let possibleExtensionPointTypes: [ExtensionPoint.Type]
var availableExtensionPointTypes: [ExtensionPoint.Type] {
let activeExtensionPointTypes = activeExtensionPoints.keys.compactMap({ ObjectIdentifier($0.extensionPointType) })
var available = [ExtensionPoint.Type]()
for possibleExtensionPointType in possibleExtensionPointTypes {
if !(AppDefaults.isDeveloperBuild && possibleExtensionPointType.isDeveloperBuildRestricted) {
if possibleExtensionPointType.isSinglton {
if !activeExtensionPointTypes.contains(ObjectIdentifier(possibleExtensionPointType)) {
available.append(possibleExtensionPointType)
}
} else {
available.append(possibleExtensionPointType)
}
}
}
return available
}
var activeSendToCommands: [SendToCommand] {
return activeExtensionPoints.values.compactMap({ return $0 as? SendToCommand })
}
var activeFeedProviders: [FeedProvider] {
return activeExtensionPoints.values.compactMap({ return $0 as? FeedProvider })
}
init() {
#if os(macOS)
#if DEBUG
possibleExtensionPointTypes = [SendToMarsEditCommand.self, SendToMicroBlogCommand.self, TwitterFeedProvider.self]
#else
possibleExtensionPointTypes = [SendToMarsEditCommand.self, SendToMicroBlogCommand.self, TwitterFeedProvider.self]
#endif
#else
#if DEBUG
possibleExtensionPointTypes = [TwitterFeedProvider.self]
#else
possibleExtensionPointTypes = [TwitterFeedProvider.self]
#endif
#endif
loadExtensionPoints()
}
func activateExtensionPoint(_ extensionPointType: ExtensionPoint.Type, tokenSuccess: OAuthSwift.TokenSuccess? = nil) {
if let extensionPoint = self.extensionPoint(for: extensionPointType, tokenSuccess: tokenSuccess) {
activeExtensionPoints[extensionPoint.extensionPointID] = extensionPoint
saveExtensionPointIDs()
}
}
func deactivateExtensionPoint(_ extensionPointID: ExtensionPointIdentifer) {
activeExtensionPoints[extensionPointID] = nil
saveExtensionPointIDs()
}
}
private extension ExtensionPointManager {
func loadExtensionPoints() {
if let extensionPointUserInfos = AppDefaults.activeExtensionPointIDs {
for extensionPointUserInfo in extensionPointUserInfos {
if let extensionPointID = ExtensionPointIdentifer(userInfo: extensionPointUserInfo) {
activeExtensionPoints[extensionPointID] = extensionPoint(for: extensionPointID)
}
}
}
}
func saveExtensionPointIDs() {
AppDefaults.activeExtensionPointIDs = activeExtensionPoints.keys.map({ $0.userInfo })
NotificationCenter.default.post(name: .ActiveExtensionPointsDidChange, object: nil, userInfo: nil)
}
func extensionPoint(for extensionPointType: ExtensionPoint.Type, tokenSuccess: OAuthSwift.TokenSuccess?) -> ExtensionPoint? {
switch extensionPointType {
#if os(macOS)
case is SendToMarsEditCommand.Type:
return SendToMarsEditCommand()
case is SendToMicroBlogCommand.Type:
return SendToMicroBlogCommand()
#endif
case is TwitterFeedProvider.Type:
if let tokenSuccess = tokenSuccess {
return TwitterFeedProvider(tokenSuccess: tokenSuccess)
} else {
return nil
}
default:
assertionFailure("Unrecognized Extension Point Type.")
}
return nil
}
func extensionPoint(for extensionPointID: ExtensionPointIdentifer) -> ExtensionPoint? {
switch extensionPointID {
#if os(macOS)
case .marsEdit:
return SendToMarsEditCommand()
case .microblog:
return SendToMicroBlogCommand()
#endif
case .twitter(let screenName):
return TwitterFeedProvider(screenName: screenName)
}
}
func feedProviderMatching(_ offered: URLComponents, ability: FeedProviderAbility) -> FeedProvider? {
for extensionPoint in activeExtensionPoints.values {
if let feedProvider = extensionPoint as? FeedProvider, feedProvider.ability(offered) == ability {
return feedProvider
}
}
return nil
}
}

View File

@@ -10,10 +10,28 @@ import AppKit
import RSCore
import Articles
final class SendToMarsEditCommand: SendToCommand {
let title = "MarsEdit"
final class SendToMarsEditCommand: ExtensionPoint, SendToCommand {
static var isSinglton = true
static var isDeveloperBuildRestricted = false
static var title = NSLocalizedString("MarsEdit", comment: "MarsEdit")
static var templateImage = AppAssets.extensionPointMarsEdit
static var description: NSAttributedString = {
let attrString = SendToMarsEditCommand.makeAttrString("This extension enables share menu functionality to send selected article text to MarsEdit. You need the MarsEdit application for this to work.")
let range = NSRange(location: 81, length: 8)
attrString.beginEditing()
attrString.addAttribute(NSAttributedString.Key.link, value: "https://red-sweater.com/marsedit/", range: range)
attrString.addAttribute(NSAttributedString.Key.foregroundColor, value: NSColor.systemBlue, range: range)
attrString.endEditing()
return attrString
}()
let extensionPointID = ExtensionPointIdentifer.marsEdit
var title: String {
return extensionPointID.extensionPointType.title
}
var image: NSImage? {
return appToUse()?.icon ?? nil
}

View File

@@ -12,10 +12,29 @@ import RSCore
// Not undoable.
final class SendToMicroBlogCommand: SendToCommand {
final class SendToMicroBlogCommand: ExtensionPoint, SendToCommand {
let title = "Micro.blog"
static var isSinglton = true
static var isDeveloperBuildRestricted = false
static var title: String = NSLocalizedString("Micro.blog", comment: "Micro.blog")
static var templateImage = AppAssets.extensionPointMicroblog
static var description: NSAttributedString = {
let attrString = SendToMicroBlogCommand.makeAttrString("This extension enables share menu functionality to send selected article text to Micro.blog. You need the Micro.blog application for this to work.")
let range = NSRange(location: 81, length: 10)
attrString.beginEditing()
attrString.addAttribute(NSAttributedString.Key.link, value: "https://micro.blog", range: range)
attrString.addAttribute(NSAttributedString.Key.foregroundColor, value: NSColor.systemBlue, range: range)
attrString.endEditing()
return attrString
}()
let extensionPointID = ExtensionPointIdentifer.microblog
var title: String {
return extensionPointID.extensionPointType.title
}
var image: NSImage? {
return microBlogApp.icon
}

View File

@@ -0,0 +1,30 @@
//
// TwitterFeedProvider+Extensions.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
extension TwitterFeedProvider: ExtensionPoint {
static var isSinglton = false
static var isDeveloperBuildRestricted = true
static var title = NSLocalizedString("Twitter", comment: "Twitter")
static var templateImage = AppAssets.extensionPointTwitter
static var description: NSAttributedString = {
return TwitterFeedProvider.makeAttrString("This extension enables you to subscribe to Twitter URL's as if they were RSS feeds. It only works with \(Account.defaultLocalAccountName) or iCloud accounts.")
}()
var extensionPointID: ExtensionPointIdentifer {
return ExtensionPointIdentifer.twitter(screenName)
}
var title: String {
return "@\(screenName)"
}
}

View File

@@ -28,10 +28,11 @@ struct CacheCleaner {
let tempDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
let faviconsFolderURL = tempDir.appendingPathComponent("Favicons")
let imagesFolderURL = tempDir.appendingPathComponent("Images")
let feedURLToIconURL = tempDir.appendingPathComponent("FeedURLToIconURLCache.plist")
let homePageToIconURL = tempDir.appendingPathComponent("HomePageToIconURLCache.plist")
let homePagesWithNoIconURL = tempDir.appendingPathComponent("HomePagesWithNoIconURLCache.plist")
for tempItem in [faviconsFolderURL, imagesFolderURL, homePageToIconURL, homePagesWithNoIconURL] {
for tempItem in [faviconsFolderURL, imagesFolderURL, feedURLToIconURL, homePageToIconURL, homePagesWithNoIconURL] {
do {
os_log(.info, log: self.log, "Removing cache file: %@", tempItem.absoluteString)
try FileManager.default.removeItem(at: tempItem)

View File

@@ -24,6 +24,14 @@ public final class WebFeedIconDownloader {
private let imageDownloader: ImageDownloader
private var feedURLToIconURLCache = [String: String]()
private var feedURLToIconURLCachePath: String
private var feedURLToIconURLCacheDirty = false {
didSet {
queueSaveFeedURLToIconURLCacheIfNeeded()
}
}
private var homePageToIconURLCache = [String: String]()
private var homePageToIconURLCachePath: String
private var homePageToIconURLCacheDirty = false {
@@ -47,11 +55,13 @@ public final class WebFeedIconDownloader {
private var urlsInProgress = Set<String>()
private var cache = [WebFeed: IconImage]()
private var waitingForFeedURLs = [String: WebFeed]()
init(imageDownloader: ImageDownloader, folder: String) {
self.imageDownloader = imageDownloader
self.feedURLToIconURLCachePath = (folder as NSString).appendingPathComponent("FeedURLToIconURLCache.plist")
self.homePageToIconURLCachePath = (folder as NSString).appendingPathComponent("HomePageToIconURLCache.plist")
self.homePagesWithNoIconURLCachePath = (folder as NSString).appendingPathComponent("HomePagesWithNoIconURLCache.plist")
loadFeedURLToIconURLCache()
loadHomePageToIconURLCache()
loadHomePagesWithNoIconURLCache()
NotificationCenter.default.addObserver(self, selector: #selector(imageDidBecomeAvailable(_:)), name: .ImageDidBecomeAvailable, object: imageDownloader)
@@ -82,22 +92,51 @@ public final class WebFeedIconDownloader {
}
}
}
if let iconURL = feed.iconURL {
icon(forURL: iconURL, feed: feed) { (image) in
func checkFeedIconURL() {
if let iconURL = feed.iconURL {
icon(forURL: iconURL, feed: feed) { (image) in
if let image = image {
self.postFeedIconDidBecomeAvailableNotification(feed)
self.cache[feed] = IconImage(image)
} else {
checkHomePageURL()
}
}
} else {
checkHomePageURL()
}
}
if let feedProviderURL = feedURLToIconURLCache[feed.url] {
self.icon(forURL: feedProviderURL, feed: feed) { (image) in
if let image = image {
self.postFeedIconDidBecomeAvailableNotification(feed)
self.cache[feed] = IconImage(image)
}
else {
checkHomePageURL()
}
return nil
}
if let components = URLComponents(string: feed.url), let feedProvider = FeedProviderManager.shared.best(for: components) {
feedProvider.iconURL(components) { result in
switch result {
case .success(let feedProviderURL):
self.feedURLToIconURLCache[feed.url] = feedProviderURL
self.feedURLToIconURLCacheDirty = true
self.icon(forURL: feedProviderURL, feed: feed) { (image) in
if let image = image {
self.postFeedIconDidBecomeAvailableNotification(feed)
self.cache[feed] = IconImage(image)
}
}
case .failure:
checkFeedIconURL()
}
}
} else {
checkFeedIconURL()
}
else {
checkHomePageURL()
}
return nil
}
@@ -110,6 +149,12 @@ public final class WebFeedIconDownloader {
_ = icon(for: feed)
}
@objc func saveFeedURLToIconURLCacheIfNeeded() {
if feedURLToIconURLCacheDirty {
saveFeedURLToIconURLCache()
}
}
@objc func saveHomePageToIconURLCacheIfNeeded() {
if homePageToIconURLCacheDirty {
saveHomePageToIconURLCache()
@@ -200,6 +245,15 @@ private extension WebFeedIconDownloader {
homePagesWithNoIconURLCacheDirty = true
}
func loadFeedURLToIconURLCache() {
let url = URL(fileURLWithPath: feedURLToIconURLCachePath)
guard let data = try? Data(contentsOf: url) else {
return
}
let decoder = PropertyListDecoder()
feedURLToIconURLCache = (try? decoder.decode([String: String].self, from: data)) ?? [String: String]()
}
func loadHomePageToIconURLCache() {
let url = URL(fileURLWithPath: homePageToIconURLCachePath)
guard let data = try? Data(contentsOf: url) else {
@@ -219,6 +273,10 @@ private extension WebFeedIconDownloader {
homePagesWithNoIconURLCache = Set(decoded)
}
func queueSaveFeedURLToIconURLCacheIfNeeded() {
WebFeedIconDownloader.saveQueue.add(self, #selector(saveFeedURLToIconURLCacheIfNeeded))
}
func queueSaveHomePageToIconURLCacheIfNeeded() {
WebFeedIconDownloader.saveQueue.add(self, #selector(saveHomePageToIconURLCacheIfNeeded))
}
@@ -227,6 +285,20 @@ private extension WebFeedIconDownloader {
WebFeedIconDownloader.saveQueue.add(self, #selector(saveHomePagesWithNoIconURLCacheIfNeeded))
}
func saveFeedURLToIconURLCache() {
feedURLToIconURLCacheDirty = false
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
let url = URL(fileURLWithPath: feedURLToIconURLCachePath)
do {
let data = try encoder.encode(feedURLToIconURLCache)
try data.write(to: url)
} catch {
assertionFailure(error.localizedDescription)
}
}
func saveHomePageToIconURLCache() {
homePageToIconURLCacheDirty = false

View File

@@ -1,53 +0,0 @@
// Generated by Secrets.swift.gyb
%{
import os
secrets = ['FEED_WRANGLER_KEY', 'MERCURY_CLIENT_ID', 'MERCURY_CLIENT_SECRET', 'FEEDLY_CLIENT_ID', 'FEEDLY_CLIENT_SECRET']
def chunks(seq, size):
return (seq[i:(i + size)] for i in range(0, len(seq), size))
def encode(string, salt):
bytes = string.encode("UTF-8")
return [ord(bytes[i]) ^ salt[i % len(salt)] for i in range(0, len(bytes))]
def snake_to_camel(snake_str):
components = snake_str.split('_')
return components[0].lower() + ''.join(x.title() for x in components[1:])
salt = [ord(byte) for byte in os.urandom(64)]
}%
public enum Secrets {
% for secret in secrets:
public static var ${snake_to_camel(secret)}: String {
let encoded: [UInt8] = [
% for chunk in chunks(encode(os.environ.get(secret) or "", salt), 8):
${"".join(["0x%02x, " % byte for byte in chunk])}
% end
]
return decode(encoded, salt: salt)
}
% end
%{
# custom example: static let myVariable = "${os.environ.get('MY_CUSTOM_VARIABLE')}"
}%
}
private extension Secrets {
private static let salt: [UInt8] = [
% for chunk in chunks(salt, 8):
${"".join(["0x%02x, " % byte for byte in chunk])}
% end
]
private static func decode(_ encoded: [UInt8], salt: [UInt8]) -> String {
String(decoding: encoded.enumerated().map { (offset, element) in
element ^ salt[offset % salt.count]
}, as: UTF8.self)
}
}