Initial implementation allowing Feedly accounts to be added given the Feedly user's consent.
This commit is contained in:
parent
0fcbcb50e0
commit
9cc5fa3608
|
@ -227,6 +227,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport)
|
||||
case .freshRSS:
|
||||
self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, transport: transport)
|
||||
case .feedly:
|
||||
self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport)
|
||||
default:
|
||||
fatalError("Only Local and Feedbin accounts are supported")
|
||||
}
|
||||
|
@ -307,6 +309,35 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
break
|
||||
}
|
||||
}
|
||||
|
||||
public static func oauthAuthorizationCodeGrantRequest(for type: AccountType, client: OAuthAuthorizationClient) -> URLRequest {
|
||||
let grantingType: OAuthAuthorizationGranting.Type
|
||||
switch type {
|
||||
case .feedly:
|
||||
grantingType = FeedlyAccountDelegate.self
|
||||
default:
|
||||
fatalError("\(type) does not support OAuth authorization code granting.")
|
||||
}
|
||||
|
||||
return grantingType.oauthAuthorizationCodeGrantRequest(for: client)
|
||||
}
|
||||
|
||||
public static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse,
|
||||
client: OAuthAuthorizationClient,
|
||||
accountType: AccountType,
|
||||
transport: Transport = URLSession.webserviceTransport(),
|
||||
completionHandler: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
|
||||
let grantingType: OAuthAuthorizationGranting.Type
|
||||
|
||||
switch accountType {
|
||||
case .feedly:
|
||||
grantingType = FeedlyAccountDelegate.self
|
||||
default:
|
||||
fatalError("\(accountType) does not support OAuth authorization code granting.")
|
||||
}
|
||||
|
||||
grantingType.requestOAuthAccessToken(with: response, client: client, transport: transport, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
public func refreshAll(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
self.delegate.refreshAll(for: self, completion: completion)
|
||||
|
|
|
@ -73,6 +73,10 @@
|
|||
84EAC4822148CC6300F154AB /* RSDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84EAC4812148CC6300F154AB /* RSDatabase.framework */; };
|
||||
84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */; };
|
||||
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F73CF0202788D80000BCEF /* ArticleFetcher.swift */; };
|
||||
9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */; };
|
||||
9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */; };
|
||||
9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */; };
|
||||
9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
@ -190,6 +194,10 @@
|
|||
84D09622217418DC00D77525 /* FeedbinTagging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTagging.swift; sourceTree = "<group>"; };
|
||||
84EAC4812148CC6300F154AB /* RSDatabase.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = RSDatabase.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
84F73CF0202788D80000BCEF /* ArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleFetcher.swift; sourceTree = "<group>"; };
|
||||
9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedlyAccountDelegate.swift; sourceTree = "<group>"; };
|
||||
9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedlyAPICaller.swift; sourceTree = "<group>"; };
|
||||
9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedlyAccountDelegate+OAuth.swift"; sourceTree = "<group>"; };
|
||||
9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAuthorizationCodeGranting.swift; sourceTree = "<group>"; };
|
||||
D511EEB5202422BB00712EC3 /* Account_project_debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_debug.xcconfig; sourceTree = "<group>"; };
|
||||
D511EEB6202422BB00712EC3 /* Account_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_target.xcconfig; sourceTree = "<group>"; };
|
||||
D511EEB7202422BB00712EC3 /* Account_project_release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_release.xcconfig; sourceTree = "<group>"; };
|
||||
|
@ -357,6 +365,7 @@
|
|||
8419742B1F6DDE84006346C4 /* LocalAccount */,
|
||||
84245C7D1FDDD2580074AFBB /* Feedbin */,
|
||||
552032EA229D5D5A009559E0 /* ReaderAPI */,
|
||||
9EA31339231E368100268BA0 /* Feedly */,
|
||||
848935031F62484F00CEBD24 /* AccountTests */,
|
||||
848934F71F62484F00CEBD24 /* Products */,
|
||||
8469F80F1F6DC3C10084783E /* Frameworks */,
|
||||
|
@ -390,6 +399,17 @@
|
|||
path = AccountTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9EA31339231E368100268BA0 /* Feedly */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9EA3133A231E368100268BA0 /* FeedlyAccountDelegate.swift */,
|
||||
9EC688EB232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift */,
|
||||
9EC688ED232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift */,
|
||||
9EC688E9232B973C00A8D0A2 /* FeedlyAPICaller.swift */,
|
||||
);
|
||||
path = Feedly;
|
||||
sourceTree = SOURCE_ROOT;
|
||||
};
|
||||
D511EEB4202422BB00712EC3 /* xcconfig */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -572,8 +592,11 @@
|
|||
84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */,
|
||||
552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */,
|
||||
84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */,
|
||||
9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */,
|
||||
9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */,
|
||||
8469F81C1F6DD15E0084783E /* Account.swift in Sources */,
|
||||
5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */,
|
||||
9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */,
|
||||
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */,
|
||||
846E77451F6EF9B900A165E2 /* Container.swift in Sources */,
|
||||
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */,
|
||||
|
@ -603,6 +626,7 @@
|
|||
552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */,
|
||||
5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */,
|
||||
552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */,
|
||||
9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */,
|
||||
84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */,
|
||||
84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */,
|
||||
5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */,
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
//
|
||||
// FeedlyAPICaller.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 13/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
|
||||
final class FeedlyAPICaller {
|
||||
|
||||
enum API {
|
||||
case sandbox
|
||||
case cloud
|
||||
|
||||
static var `default`: API {
|
||||
return .sandbox
|
||||
}
|
||||
|
||||
var baseUrlComponents: URLComponents {
|
||||
var components = URLComponents()
|
||||
components.scheme = "https"
|
||||
switch self{
|
||||
case .sandbox:
|
||||
// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw
|
||||
components.host = "sandbox7.feedly.com"
|
||||
case .cloud:
|
||||
// https://developer.feedly.com/cloud/
|
||||
components.host = "cloud.feedly.com"
|
||||
}
|
||||
return components
|
||||
}
|
||||
}
|
||||
|
||||
private let transport: Transport
|
||||
private let baseUrlComponents: URLComponents
|
||||
|
||||
init(transport: Transport, api: API) {
|
||||
self.transport = transport
|
||||
self.baseUrlComponents = api.baseUrlComponents
|
||||
}
|
||||
|
||||
var credentials: Credentials?
|
||||
|
||||
var server: String? {
|
||||
return baseUrlComponents.host
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
|
||||
|
||||
static func authorizationCodeUrlRequest(for request: OAuthAuthorizationRequest) -> URLRequest {
|
||||
let api = API.default
|
||||
var components = api.baseUrlComponents
|
||||
components.path = "/v3/auth/auth"
|
||||
components.queryItems = request.queryItems
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
typealias AccessTokenResponse = FeedlyOAuthAccessTokenResponse
|
||||
|
||||
func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest, completionHandler: @escaping (Result<FeedlyOAuthAccessTokenResponse, Error>) -> ()) {
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/auth/token"
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
request.httpBody = try encoder.encode(authorizationRequest)
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
transport.send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, tokenResponse)):
|
||||
if let response = tokenResponse {
|
||||
completionHandler(.success(response))
|
||||
} else {
|
||||
completionHandler(.failure(URLError(.cannotDecodeContentData)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// FeedlyAccountDelegate+OAuth.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 14/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
|
||||
/// Models the access token response from Feedly.
|
||||
/// https://developer.feedly.com/v3/auth/#exchanging-an-auth-code-for-a-refresh-token-and-an-access-token
|
||||
public struct FeedlyOAuthAccessTokenResponse: Decodable, OAuthAccessTokenResponse {
|
||||
/// The ID of the Feedly user.
|
||||
public var id: String
|
||||
|
||||
// Required properties of the OAuth 2.0 Authorization Framework section 4.1.4.
|
||||
public var accessToken: String
|
||||
public var tokenType: String
|
||||
public var expiresIn: Int
|
||||
public var refreshToken: String?
|
||||
public var scope: String
|
||||
}
|
||||
|
||||
extension FeedlyAccountDelegate: OAuthAuthorizationGranting {
|
||||
|
||||
private static let oauthAuthorizationGrantScope = "https://cloud.feedly.com/subscriptions"
|
||||
|
||||
static func oauthAuthorizationCodeGrantRequest(for client: OAuthAuthorizationClient) -> URLRequest {
|
||||
let authorizationRequest = OAuthAuthorizationRequest(clientId: client.id,
|
||||
redirectUri: client.redirectUri,
|
||||
scope: oauthAuthorizationGrantScope,
|
||||
state: client.state)
|
||||
return FeedlyAPICaller.authorizationCodeUrlRequest(for: authorizationRequest)
|
||||
}
|
||||
|
||||
static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, client: OAuthAuthorizationClient, transport: Transport, completionHandler: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
|
||||
let request = OAuthAccessTokenRequest(authorizationResponse: response,
|
||||
scope: oauthAuthorizationGrantScope,
|
||||
client: client)
|
||||
let caller = FeedlyAPICaller(transport: transport, api: .default)
|
||||
caller.requestAccessToken(request) { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
|
||||
|
||||
let refreshToken: Credentials? = {
|
||||
guard let token = response.refreshToken else {
|
||||
return nil
|
||||
}
|
||||
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
|
||||
}()
|
||||
|
||||
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
|
||||
|
||||
completionHandler(.success(grant))
|
||||
|
||||
case .failure(let error):
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
//
|
||||
// FeedlyAccountDelegate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 3/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Articles
|
||||
import RSCore
|
||||
import RSParser
|
||||
import RSWeb
|
||||
import SyncDatabase
|
||||
import os.log
|
||||
|
||||
final class FeedlyAccountDelegate: AccountDelegate {
|
||||
|
||||
// Collections are one-level deep.
|
||||
let isSubfoldersSupported = false
|
||||
|
||||
// Feedly uses collections and streams. But it does have tags?
|
||||
let isTagBasedSystem = false
|
||||
|
||||
// Could be true. See https://developer.feedly.com/v3/opml/
|
||||
let isOPMLImportSupported = false
|
||||
|
||||
var isOPMLImportInProgress = false
|
||||
|
||||
var server: String? {
|
||||
return caller.server
|
||||
}
|
||||
|
||||
var credentials: Credentials?
|
||||
|
||||
var accountMetadata: AccountMetadata?
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
private let database: SyncDatabase
|
||||
private let caller: FeedlyAPICaller
|
||||
|
||||
init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API = .default) {
|
||||
|
||||
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
||||
database = SyncDatabase(databaseFilePath: databaseFilePath)
|
||||
|
||||
if let transport = transport {
|
||||
caller = FeedlyAPICaller(transport: transport, api: api)
|
||||
|
||||
} else {
|
||||
|
||||
let sessionConfiguration = URLSessionConfiguration.default
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.timeoutIntervalForRequest = 60.0
|
||||
sessionConfiguration.httpShouldSetCookies = false
|
||||
sessionConfiguration.httpCookieAcceptPolicy = .never
|
||||
sessionConfiguration.httpMaximumConnectionsPerHost = 1
|
||||
sessionConfiguration.httpCookieStorage = nil
|
||||
sessionConfiguration.urlCache = nil
|
||||
|
||||
if let userAgentHeaders = UserAgent.headers() {
|
||||
sessionConfiguration.httpAdditionalHeaders = userAgentHeaders
|
||||
}
|
||||
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
caller = FeedlyAPICaller(transport: session, api: api)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Account API
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func addFeed(for account: Account, with: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func accountDidInitialize(_ account: Account) {
|
||||
// accountMetadata = account.metadata
|
||||
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
|
||||
}
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result<Credentials?, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,171 @@
|
|||
//
|
||||
// OAuthAuthorizationCodeGranting.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 14/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSWeb
|
||||
|
||||
/// Client-specific information for requesting an authorization code grant.
|
||||
/// Accounts are responsible for the scope.
|
||||
public struct OAuthAuthorizationClient {
|
||||
public var id: String
|
||||
public var redirectUri: String
|
||||
public var state: String?
|
||||
public var secret: String
|
||||
|
||||
public init(id: String, redirectUri: String, state: String?, secret: String) {
|
||||
self.id = id
|
||||
self.redirectUri = redirectUri
|
||||
self.state = state
|
||||
self.secret = secret
|
||||
}
|
||||
}
|
||||
|
||||
/// Models section 4.1.1 of the OAuth 2.0 Authorization Framework
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.1
|
||||
public struct OAuthAuthorizationRequest {
|
||||
public let responseType = "code"
|
||||
public var clientId: String
|
||||
public var redirectUri: String
|
||||
public var scope: String
|
||||
public var state: String?
|
||||
|
||||
public init(clientId: String, redirectUri: String, scope: String, state: String?) {
|
||||
self.clientId = clientId
|
||||
self.redirectUri = redirectUri
|
||||
self.scope = scope
|
||||
self.state = state
|
||||
}
|
||||
|
||||
public var queryItems: [URLQueryItem] {
|
||||
return [
|
||||
URLQueryItem(name: "response_type", value: responseType),
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
URLQueryItem(name: "scope", value: scope),
|
||||
URLQueryItem(name: "redirect_uri", value: redirectUri),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Models section 4.1.2 of the OAuth 2.0 Authorization Framework
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.2
|
||||
public struct OAuthAuthorizationResponse {
|
||||
public var code: String
|
||||
public var state: String?
|
||||
}
|
||||
|
||||
public extension OAuthAuthorizationResponse {
|
||||
|
||||
init(url: URL, client: OAuthAuthorizationClient) throws {
|
||||
guard let host = url.host, client.redirectUri.contains(host) else {
|
||||
throw URLError(.unsupportedURL)
|
||||
}
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
guard let queryItems = components.queryItems, !queryItems.isEmpty else {
|
||||
throw URLError(.unsupportedURL)
|
||||
}
|
||||
let code = queryItems.firstElementPassingTest { $0.name.lowercased() == "code" }
|
||||
guard let codeValue = code?.value, !codeValue.isEmpty else {
|
||||
throw URLError(.unsupportedURL)
|
||||
}
|
||||
|
||||
let state = queryItems.firstElementPassingTest { $0.name.lowercased() == "state" }
|
||||
let stateValue = state?.value
|
||||
|
||||
self.init(code: codeValue, state: stateValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Models section 4.1.2.1 of the OAuth 2.0 Authorization Framework
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
public struct OAuthAuthorizationErrorResponse: Error {
|
||||
public var error: OAuthAuthorizationError
|
||||
public var state: String?
|
||||
public var errorDescription: String?
|
||||
|
||||
public var localizedDescription: String {
|
||||
return errorDescription ?? error.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
/// Error values as enumerated in section 4.1.2.1 of the OAuth 2.0 Authorization Framework.
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
public enum OAuthAuthorizationError: String {
|
||||
case invalidRequest = "invalid_request"
|
||||
case unauthorizedClient = "unauthorized_client"
|
||||
case accessDenied = "access_denied"
|
||||
case unsupportedResponseType = "unsupported_response_type"
|
||||
case invalidScope = "invalid_scope"
|
||||
case serverError = "server_error"
|
||||
case temporarilyUnavailable = "temporarily_unavailable"
|
||||
}
|
||||
|
||||
/// Models section 4.1.3 of the OAuth 2.0 Authorization Framework
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.3
|
||||
public struct OAuthAccessTokenRequest: Encodable {
|
||||
public let grantType = "authorization_code"
|
||||
public var code: String
|
||||
public var redirectUri: String
|
||||
public var state: String?
|
||||
public var clientId: String
|
||||
|
||||
// Possibly not part of the standard but specific to certain implementations (e.g.: Feedly).
|
||||
public var clientSecret: String
|
||||
public var scope: String
|
||||
|
||||
public init(authorizationResponse: OAuthAuthorizationResponse, scope: String, client: OAuthAuthorizationClient) {
|
||||
self.code = authorizationResponse.code
|
||||
self.redirectUri = client.redirectUri
|
||||
self.state = authorizationResponse.state
|
||||
self.clientId = client.id
|
||||
self.clientSecret = client.secret
|
||||
self.scope = scope
|
||||
}
|
||||
}
|
||||
|
||||
/// Models the minimum subset of properties of a response in section 4.1.4 of the OAuth 2.0 Authorization Framework
|
||||
/// Concrete types model other paramters beyond the scope of the OAuth spec.
|
||||
/// For example, Feedly provides the ID of the user who has consented to the grant.
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1.4
|
||||
public protocol OAuthAccessTokenResponse {
|
||||
var accessToken: String { get }
|
||||
var tokenType: String { get }
|
||||
var expiresIn: Int { get }
|
||||
var refreshToken: String? { get }
|
||||
var scope: String { get }
|
||||
}
|
||||
|
||||
/// The access and refresh tokens from a successful authorization grant.
|
||||
public struct OAuthAuthorizationGrant {
|
||||
public var accessToken: Credentials
|
||||
public var refreshToken: Credentials?
|
||||
}
|
||||
|
||||
/// Conformed to by API callers to provide a consistent interface for `AccountDelegate` types to enable OAuth Authorization Grants. Conformers provide an associated type that models any custom parameters/properties, as well as the standard ones, in the response to a request for an access token.
|
||||
/// https://tools.ietf.org/html/rfc6749#section-4.1
|
||||
public protocol OAuthAuthorizationCodeGrantRequesting {
|
||||
associatedtype AccessTokenResponse: OAuthAccessTokenResponse
|
||||
|
||||
/// Provides the URL request that allows users to consent to the client having access to their information. Typically loaded by a web view.
|
||||
/// - Parameter request: The
|
||||
static func authorizationCodeUrlRequest(for request: OAuthAuthorizationRequest) -> URLRequest
|
||||
|
||||
|
||||
/// Performs the request for the access token given an authorization code.
|
||||
/// - Parameter authorizationRequest: The authorization code and other information the authorization server requires to grant the client access tokes on the user's behalf.
|
||||
/// - Parameter completionHandler: On success, the access token response appropriate for concrete type's service. On failure, possibly a `URLError` or `OAuthAuthorizationErrorResponse` value.
|
||||
func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest, completionHandler: @escaping (Result<AccessTokenResponse, Error>) -> ())
|
||||
}
|
||||
|
||||
protocol OAuthAuthorizationGranting: AccountDelegate {
|
||||
|
||||
static func oauthAuthorizationCodeGrantRequest(for client: OAuthAuthorizationClient) -> URLRequest
|
||||
|
||||
static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, client: OAuthAuthorizationClient, transport: Transport, completionHandler: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ())
|
||||
}
|
|
@ -15,7 +15,7 @@ class AccountsAddViewController: NSViewController {
|
|||
|
||||
private var accountsAddWindowController: NSWindowController?
|
||||
|
||||
private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .freshRSS]
|
||||
private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .freshRSS, .feedly]
|
||||
|
||||
init() {
|
||||
super.init(nibName: "AccountsAdd", bundle: nil)
|
||||
|
@ -68,6 +68,9 @@ extension AccountsAddViewController: NSTableViewDelegate {
|
|||
case .freshRSS:
|
||||
cell.accountNameLabel?.stringValue = NSLocalizedString("FreshRSS", comment: "FreshRSS")
|
||||
cell.accountImageView?.image = AppAssets.accountFreshRSS
|
||||
case .feedly:
|
||||
cell.accountNameLabel?.stringValue = NSLocalizedString("Feedly", comment: "Feedly")
|
||||
cell.accountImageView?.image = AppAssets.accountFreshRSS
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
@ -97,6 +100,10 @@ extension AccountsAddViewController: NSTableViewDelegate {
|
|||
accountsReaderAPIWindowController.accountType = .freshRSS
|
||||
accountsReaderAPIWindowController.runSheetOnWindow(self.view.window!)
|
||||
accountsAddWindowController = accountsReaderAPIWindowController
|
||||
case .feedly:
|
||||
let accountsFeedlyWindowController = AccountsFeedlyWebWindowController()
|
||||
accountsFeedlyWindowController.runSheetOnWindow(self.view.window!)
|
||||
accountsAddWindowController = accountsFeedlyWindowController
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -66,6 +66,9 @@ final class AccountsDetailViewController: NSViewController, NSTextFieldDelegate
|
|||
accountsFreshRSSWindowController.account = account
|
||||
accountsFreshRSSWindowController.runSheetOnWindow(self.view.window!)
|
||||
accountsWindowController = accountsFreshRSSWindowController
|
||||
case .feedly:
|
||||
assertionFailure("Implement feedly logout window controller")
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14865.1" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14865.1"/>
|
||||
<plugIn identifier="com.apple.WebKit2IBPlugin" version="14865.1"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="AccountsFeedlyWebWindowController" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="webView" destination="W4c-Xp-rpq" id="l11-5B-8yc"/>
|
||||
<outlet property="window" destination="F0z-JX-Cv5" id="gIp-Ho-8D9"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="F0z-JX-Cv5">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="196" y="240" width="480" height="708"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="1680" height="1027"/>
|
||||
<view key="contentView" id="se5-gp-TjO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="708"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<wkWebView wantsLayer="YES" translatesAutoresizingMaskIntoConstraints="NO" id="W4c-Xp-rpq">
|
||||
<rect key="frame" x="0.0" y="61" width="480" height="647"/>
|
||||
<wkWebViewConfiguration key="configuration">
|
||||
<audiovisualMediaTypes key="mediaTypesRequiringUserActionForPlayback" none="YES"/>
|
||||
<wkPreferences key="preferences"/>
|
||||
</wkWebViewConfiguration>
|
||||
<connections>
|
||||
<outlet property="navigationDelegate" destination="-2" id="wAp-Oh-5EK"/>
|
||||
</connections>
|
||||
</wkWebView>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="PD2-Zk-3yM">
|
||||
<rect key="frame" x="384" y="13" width="82" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="IEi-N0-sbw">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
Gw
|
||||
</string>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="cancel:" target="-2" id="5BT-to-e4W"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="W4c-Xp-rpq" secondAttribute="trailing" id="D9j-IU-BZj"/>
|
||||
<constraint firstAttribute="bottom" secondItem="PD2-Zk-3yM" secondAttribute="bottom" constant="20" symbolic="YES" id="Qdc-tu-9kO"/>
|
||||
<constraint firstItem="W4c-Xp-rpq" firstAttribute="top" secondItem="se5-gp-TjO" secondAttribute="top" id="V7Q-kM-JDA"/>
|
||||
<constraint firstAttribute="trailing" secondItem="PD2-Zk-3yM" secondAttribute="trailing" constant="20" symbolic="YES" id="bQS-L4-jbx"/>
|
||||
<constraint firstItem="W4c-Xp-rpq" firstAttribute="leading" secondItem="se5-gp-TjO" secondAttribute="leading" id="ec6-U0-t8X"/>
|
||||
<constraint firstItem="PD2-Zk-3yM" firstAttribute="top" secondItem="W4c-Xp-rpq" secondAttribute="bottom" constant="20" symbolic="YES" id="zlA-8I-aKr"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="-2" id="0bl-1N-AYu"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="134" y="556"/>
|
||||
</window>
|
||||
</objects>
|
||||
</document>
|
|
@ -0,0 +1,116 @@
|
|||
//
|
||||
// AccountsFeedlyWebWindowController.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Kiel Gillard on 30/8/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Account
|
||||
import WebKit
|
||||
|
||||
class AccountsFeedlyWebWindowController: NSWindowController, WKNavigationDelegate {
|
||||
|
||||
@IBOutlet private weak var webView: WKWebView!
|
||||
|
||||
private weak var hostWindow: NSWindow?
|
||||
|
||||
convenience init() {
|
||||
self.init(windowNibName: NSNib.Name("AccountsFeedlyWeb"))
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
func runSheetOnWindow(_ hostWindow: NSWindow, completionHandler handler: ((NSApplication.ModalResponse) -> Void)? = nil) {
|
||||
self.hostWindow = hostWindow
|
||||
hostWindow.beginSheet(window!, completionHandler: handler)
|
||||
beginAuthorization()
|
||||
}
|
||||
|
||||
// MARK: Requesting an Access Token
|
||||
|
||||
private let client = OAuthAuthorizationClient.feedlySandboxClient
|
||||
|
||||
private func beginAuthorization() {
|
||||
let request = Account.oauthAuthorizationCodeGrantRequest(for: .feedly, client: client)
|
||||
webView.load(request)
|
||||
}
|
||||
|
||||
private func requestAccessToken(for response: OAuthAuthorizationResponse) {
|
||||
Account.requestOAuthAccessToken(with: response, client: client, accountType: .feedly) { [weak self] result in
|
||||
switch result {
|
||||
case .success(let tokenResponse):
|
||||
self?.saveAccount(for: tokenResponse)
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@IBAction func cancel(_ sender: Any) {
|
||||
hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
|
||||
do {
|
||||
guard let url = navigationAction.request.url else { return }
|
||||
|
||||
let response = try OAuthAuthorizationResponse(url: url, client: client)
|
||||
|
||||
requestAccessToken(for: response)
|
||||
|
||||
// No point the web view trying to load this.
|
||||
return decisionHandler(.cancel)
|
||||
|
||||
} catch let error as OAuthAuthorizationErrorResponse {
|
||||
NSApplication.shared.presentError(error)
|
||||
|
||||
} catch {
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
print(error)
|
||||
}
|
||||
|
||||
private func saveAccount(for grant: OAuthAuthorizationGrant) {
|
||||
// TODO: Find an already existing account for this username?
|
||||
let account = AccountManager.shared.createAccount(type: .feedly)
|
||||
do {
|
||||
try account.storeCredentials(grant.accessToken)
|
||||
|
||||
if let token = grant.refreshToken {
|
||||
try account.storeCredentials(token)
|
||||
}
|
||||
|
||||
self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK)
|
||||
} catch {
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension OAuthAuthorizationClient {
|
||||
|
||||
/// Models public sandbox API values found at:
|
||||
/// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw
|
||||
static var feedlySandboxClient: OAuthAuthorizationClient {
|
||||
return OAuthAuthorizationClient(id: "sandbox",
|
||||
redirectUri: "http://localhost",
|
||||
state: nil,
|
||||
secret: "ReVGXA6WekanCxbf")
|
||||
}
|
||||
|
||||
/// Models private NetNewsWire client secrets.
|
||||
/// https://developer.feedly.com/v3/auth/#authenticating-a-user-and-obtaining-an-auth-code
|
||||
static var netNewsWireClient: OAuthAuthorizationClient {
|
||||
fatalError("This app is not registered as a client with Feedly. Follow the URL in the code comments for this property.")
|
||||
}
|
||||
}
|
|
@ -363,6 +363,8 @@
|
|||
84FB9A2F1EDCD6C4003D53B9 /* Sparkle.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84FB9A2D1EDCD6B8003D53B9 /* Sparkle.framework */; };
|
||||
84FB9A301EDCD6C4003D53B9 /* Sparkle.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 84FB9A2D1EDCD6B8003D53B9 /* Sparkle.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
84FF69B11FC3793300DC198E /* FaviconURLFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */; };
|
||||
9EA33BB92318F8C10097B644 /* AccountsFeedlyWebWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EA33BB72318F8C10097B644 /* AccountsFeedlyWebWindowController.swift */; };
|
||||
9EA33BBA2318F8C10097B644 /* AccountsFeedlyWeb.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9EA33BB82318F8C10097B644 /* AccountsFeedlyWeb.xib */; };
|
||||
D553738B20186C20006D8857 /* Article+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553737C20186C1F006D8857 /* Article+Scriptability.swift */; };
|
||||
D57BE6E0204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57BE6DF204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift */; };
|
||||
D5907D7F2004AC00005947E5 /* NSApplication+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5907D7E2004AC00005947E5 /* NSApplication+Scriptability.swift */; };
|
||||
|
@ -1040,6 +1042,8 @@
|
|||
84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconURLFinder.swift; sourceTree = "<group>"; };
|
||||
B24EFD482330FF99006C6242 /* NetNewsWire-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NetNewsWire-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
B24EFD5923310109006C6242 /* WKPreferencesPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKPreferencesPrivate.h; sourceTree = "<group>"; };
|
||||
9EA33BB72318F8C10097B644 /* AccountsFeedlyWebWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsFeedlyWebWindowController.swift; sourceTree = "<group>"; };
|
||||
9EA33BB82318F8C10097B644 /* AccountsFeedlyWeb.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountsFeedlyWeb.xib; sourceTree = "<group>"; };
|
||||
D519E74722EE553300923F27 /* NetNewsWire_safariextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target.xcconfig; sourceTree = "<group>"; };
|
||||
D553737C20186C1F006D8857 /* Article+Scriptability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Article+Scriptability.swift"; sourceTree = "<group>"; };
|
||||
D57BE6DF204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScriptCommand+NetNewsWire.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1868,6 +1872,8 @@
|
|||
5144EA2E2279FAB600D19003 /* AccountsDetailViewController.swift */,
|
||||
5144EA50227B8E4500D19003 /* AccountsFeedbin.xib */,
|
||||
5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */,
|
||||
9EA33BB72318F8C10097B644 /* AccountsFeedlyWebWindowController.swift */,
|
||||
9EA33BB82318F8C10097B644 /* AccountsFeedlyWeb.xib */,
|
||||
5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */,
|
||||
5144EA372279FC6200D19003 /* AccountsAddLocalWindowController.swift */,
|
||||
);
|
||||
|
@ -2485,6 +2491,7 @@
|
|||
5127B23A222B4849006D641D /* DetailKeyboardShortcuts.plist in Resources */,
|
||||
845479881FEB77C000AD8B59 /* TimelineKeyboardShortcuts.plist in Resources */,
|
||||
848362FF2262A30E00DA1D35 /* template.html in Resources */,
|
||||
9EA33BBA2318F8C10097B644 /* AccountsFeedlyWeb.xib in Resources */,
|
||||
848363082262A3DD00DA1D35 /* Main.storyboard in Resources */,
|
||||
51EF0F8E2279C9260050506E /* AccountsAdd.xib in Resources */,
|
||||
84C9FC8F22629E8F00D921D6 /* NetNewsWire.sdef in Resources */,
|
||||
|
@ -2753,6 +2760,7 @@
|
|||
84AD1EBA2031649C00BC20B7 /* SmartFeedPasteboardWriter.swift in Sources */,
|
||||
84CC88181FE59CBF00644329 /* SmartFeedsController.swift in Sources */,
|
||||
849A97661ED9EB96007D329B /* SidebarViewController.swift in Sources */,
|
||||
9EA33BB92318F8C10097B644 /* AccountsFeedlyWebWindowController.swift in Sources */,
|
||||
849A97641ED9EB96007D329B /* SidebarOutlineView.swift in Sources */,
|
||||
5127B238222B4849006D641D /* DetailKeyboardDelegate.swift in Sources */,
|
||||
8405DD9922153B6B008CE1BF /* TimelineContainerView.swift in Sources */,
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 168ce1a628847d986d032247498b00293e7659f2
|
||||
Subproject commit b1230d5aae49ee6c908fe694cd4f77b98d17cc42
|
Loading…
Reference in New Issue