Merge pull request #1027 from kielgillard/master
Add a Feedly account with user consent
This commit is contained in:
commit
fdd244def8
|
@ -227,6 +227,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||||
self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport)
|
self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport)
|
||||||
case .freshRSS:
|
case .freshRSS:
|
||||||
self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, transport: transport)
|
self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, transport: transport)
|
||||||
|
case .feedly:
|
||||||
|
self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport)
|
||||||
default:
|
default:
|
||||||
fatalError("Only Local and Feedbin accounts are supported")
|
fatalError("Only Local and Feedbin accounts are supported")
|
||||||
}
|
}
|
||||||
|
@ -307,6 +309,35 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
||||||
break
|
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) {
|
public func refreshAll(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
self.delegate.refreshAll(for: self, completion: completion)
|
self.delegate.refreshAll(for: self, completion: completion)
|
||||||
|
|
|
@ -73,6 +73,10 @@
|
||||||
84EAC4822148CC6300F154AB /* RSDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84EAC4812148CC6300F154AB /* RSDatabase.framework */; };
|
84EAC4822148CC6300F154AB /* RSDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84EAC4812148CC6300F154AB /* RSDatabase.framework */; };
|
||||||
84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */; };
|
84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */; };
|
||||||
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F73CF0202788D80000BCEF /* ArticleFetcher.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
@ -190,6 +194,10 @@
|
||||||
84D09622217418DC00D77525 /* FeedbinTagging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTagging.swift; sourceTree = "<group>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D511EEB7202422BB00712EC3 /* Account_project_release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
@ -357,6 +365,7 @@
|
||||||
8419742B1F6DDE84006346C4 /* LocalAccount */,
|
8419742B1F6DDE84006346C4 /* LocalAccount */,
|
||||||
84245C7D1FDDD2580074AFBB /* Feedbin */,
|
84245C7D1FDDD2580074AFBB /* Feedbin */,
|
||||||
552032EA229D5D5A009559E0 /* ReaderAPI */,
|
552032EA229D5D5A009559E0 /* ReaderAPI */,
|
||||||
|
9EA31339231E368100268BA0 /* Feedly */,
|
||||||
848935031F62484F00CEBD24 /* AccountTests */,
|
848935031F62484F00CEBD24 /* AccountTests */,
|
||||||
848934F71F62484F00CEBD24 /* Products */,
|
848934F71F62484F00CEBD24 /* Products */,
|
||||||
8469F80F1F6DC3C10084783E /* Frameworks */,
|
8469F80F1F6DC3C10084783E /* Frameworks */,
|
||||||
|
@ -390,6 +399,17 @@
|
||||||
path = AccountTests;
|
path = AccountTests;
|
||||||
sourceTree = "<group>";
|
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 */ = {
|
D511EEB4202422BB00712EC3 /* xcconfig */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -572,8 +592,11 @@
|
||||||
84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */,
|
84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */,
|
||||||
552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */,
|
552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */,
|
||||||
84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */,
|
84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */,
|
||||||
|
9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */,
|
||||||
|
9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */,
|
||||||
8469F81C1F6DD15E0084783E /* Account.swift in Sources */,
|
8469F81C1F6DD15E0084783E /* Account.swift in Sources */,
|
||||||
5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */,
|
5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */,
|
||||||
|
9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */,
|
||||||
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */,
|
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */,
|
||||||
846E77451F6EF9B900A165E2 /* Container.swift in Sources */,
|
846E77451F6EF9B900A165E2 /* Container.swift in Sources */,
|
||||||
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */,
|
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */,
|
||||||
|
@ -603,6 +626,7 @@
|
||||||
552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */,
|
552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */,
|
||||||
5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */,
|
5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */,
|
||||||
552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */,
|
552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */,
|
||||||
|
9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */,
|
||||||
84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */,
|
84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */,
|
||||||
84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */,
|
84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */,
|
||||||
5165D72A22835F7D00D9D53D /* HTMLFeedFinder.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 var accountsAddWindowController: NSWindowController?
|
||||||
|
|
||||||
private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .freshRSS]
|
private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .freshRSS, .feedly]
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
super.init(nibName: "AccountsAdd", bundle: nil)
|
super.init(nibName: "AccountsAdd", bundle: nil)
|
||||||
|
@ -68,6 +68,9 @@ extension AccountsAddViewController: NSTableViewDelegate {
|
||||||
case .freshRSS:
|
case .freshRSS:
|
||||||
cell.accountNameLabel?.stringValue = NSLocalizedString("FreshRSS", comment: "FreshRSS")
|
cell.accountNameLabel?.stringValue = NSLocalizedString("FreshRSS", comment: "FreshRSS")
|
||||||
cell.accountImageView?.image = AppAssets.accountFreshRSS
|
cell.accountImageView?.image = AppAssets.accountFreshRSS
|
||||||
|
case .feedly:
|
||||||
|
cell.accountNameLabel?.stringValue = NSLocalizedString("Feedly", comment: "Feedly")
|
||||||
|
cell.accountImageView?.image = AppAssets.accountFreshRSS
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -97,6 +100,10 @@ extension AccountsAddViewController: NSTableViewDelegate {
|
||||||
accountsReaderAPIWindowController.accountType = .freshRSS
|
accountsReaderAPIWindowController.accountType = .freshRSS
|
||||||
accountsReaderAPIWindowController.runSheetOnWindow(self.view.window!)
|
accountsReaderAPIWindowController.runSheetOnWindow(self.view.window!)
|
||||||
accountsAddWindowController = accountsReaderAPIWindowController
|
accountsAddWindowController = accountsReaderAPIWindowController
|
||||||
|
case .feedly:
|
||||||
|
let accountsFeedlyWindowController = AccountsFeedlyWebWindowController()
|
||||||
|
accountsFeedlyWindowController.runSheetOnWindow(self.view.window!)
|
||||||
|
accountsAddWindowController = accountsFeedlyWindowController
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,6 +66,9 @@ final class AccountsDetailViewController: NSViewController, NSTextFieldDelegate
|
||||||
accountsFreshRSSWindowController.account = account
|
accountsFreshRSSWindowController.account = account
|
||||||
accountsFreshRSSWindowController.runSheetOnWindow(self.view.window!)
|
accountsFreshRSSWindowController.runSheetOnWindow(self.view.window!)
|
||||||
accountsWindowController = accountsFreshRSSWindowController
|
accountsWindowController = accountsFreshRSSWindowController
|
||||||
|
case .feedly:
|
||||||
|
assertionFailure("Implement feedly logout window controller")
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
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 */; };
|
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, ); }; };
|
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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D57BE6DF204CD35F00D11AAC /* NSScriptCommand+NetNewsWire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScriptCommand+NetNewsWire.swift"; sourceTree = "<group>"; };
|
||||||
|
@ -1868,6 +1872,8 @@
|
||||||
5144EA2E2279FAB600D19003 /* AccountsDetailViewController.swift */,
|
5144EA2E2279FAB600D19003 /* AccountsDetailViewController.swift */,
|
||||||
5144EA50227B8E4500D19003 /* AccountsFeedbin.xib */,
|
5144EA50227B8E4500D19003 /* AccountsFeedbin.xib */,
|
||||||
5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */,
|
5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */,
|
||||||
|
9EA33BB72318F8C10097B644 /* AccountsFeedlyWebWindowController.swift */,
|
||||||
|
9EA33BB82318F8C10097B644 /* AccountsFeedlyWeb.xib */,
|
||||||
5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */,
|
5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */,
|
||||||
5144EA372279FC6200D19003 /* AccountsAddLocalWindowController.swift */,
|
5144EA372279FC6200D19003 /* AccountsAddLocalWindowController.swift */,
|
||||||
);
|
);
|
||||||
|
@ -2485,6 +2491,7 @@
|
||||||
5127B23A222B4849006D641D /* DetailKeyboardShortcuts.plist in Resources */,
|
5127B23A222B4849006D641D /* DetailKeyboardShortcuts.plist in Resources */,
|
||||||
845479881FEB77C000AD8B59 /* TimelineKeyboardShortcuts.plist in Resources */,
|
845479881FEB77C000AD8B59 /* TimelineKeyboardShortcuts.plist in Resources */,
|
||||||
848362FF2262A30E00DA1D35 /* template.html in Resources */,
|
848362FF2262A30E00DA1D35 /* template.html in Resources */,
|
||||||
|
9EA33BBA2318F8C10097B644 /* AccountsFeedlyWeb.xib in Resources */,
|
||||||
848363082262A3DD00DA1D35 /* Main.storyboard in Resources */,
|
848363082262A3DD00DA1D35 /* Main.storyboard in Resources */,
|
||||||
51EF0F8E2279C9260050506E /* AccountsAdd.xib in Resources */,
|
51EF0F8E2279C9260050506E /* AccountsAdd.xib in Resources */,
|
||||||
84C9FC8F22629E8F00D921D6 /* NetNewsWire.sdef in Resources */,
|
84C9FC8F22629E8F00D921D6 /* NetNewsWire.sdef in Resources */,
|
||||||
|
@ -2753,6 +2760,7 @@
|
||||||
84AD1EBA2031649C00BC20B7 /* SmartFeedPasteboardWriter.swift in Sources */,
|
84AD1EBA2031649C00BC20B7 /* SmartFeedPasteboardWriter.swift in Sources */,
|
||||||
84CC88181FE59CBF00644329 /* SmartFeedsController.swift in Sources */,
|
84CC88181FE59CBF00644329 /* SmartFeedsController.swift in Sources */,
|
||||||
849A97661ED9EB96007D329B /* SidebarViewController.swift in Sources */,
|
849A97661ED9EB96007D329B /* SidebarViewController.swift in Sources */,
|
||||||
|
9EA33BB92318F8C10097B644 /* AccountsFeedlyWebWindowController.swift in Sources */,
|
||||||
849A97641ED9EB96007D329B /* SidebarOutlineView.swift in Sources */,
|
849A97641ED9EB96007D329B /* SidebarOutlineView.swift in Sources */,
|
||||||
5127B238222B4849006D641D /* DetailKeyboardDelegate.swift in Sources */,
|
5127B238222B4849006D641D /* DetailKeyboardDelegate.swift in Sources */,
|
||||||
8405DD9922153B6B008CE1BF /* TimelineContainerView.swift in Sources */,
|
8405DD9922153B6B008CE1BF /* TimelineContainerView.swift in Sources */,
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit 168ce1a628847d986d032247498b00293e7659f2
|
Subproject commit b1230d5aae49ee6c908fe694cd4f77b98d17cc42
|
Loading…
Reference in New Issue