Merge pull request #1027 from kielgillard/master

Add a Feedly account with user consent
This commit is contained in:
Maurice Parker 2019-09-18 19:24:03 -05:00 committed by GitHub
commit fdd244def8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 742 additions and 2 deletions

View File

@ -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)

View File

@ -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 */,

View File

@ -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))
}
}
}
}

View File

@ -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))
}
}
}
}

View File

@ -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()
}
}

View File

@ -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>) -> ())
}

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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>

View File

@ -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.")
}
}

View File

@ -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