Add anonymous instance, lots of refactoring

This commit is contained in:
Justin Mazzocchi 2020-08-09 04:27:38 -07:00
parent 3995726a45
commit 96fdbad7b9
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
16 changed files with 235 additions and 296 deletions

View file

@ -23,18 +23,6 @@ let developmentKeychainService: KeychainServiceType = {
return keychainService return keychainService
}() }()
extension Defaults {
static func fresh() -> Defaults { Defaults(userDefaults: MockUserDefaults()) }
static let development: Defaults = {
let preferences = Defaults.fresh()
// Do future setup here
return preferences
}()
}
extension Account { extension Account {
static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8)) static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8))
} }
@ -69,30 +57,26 @@ extension IdentityDatabase {
} }
extension AppEnvironment { extension AppEnvironment {
static func fresh(
URLSessionConfiguration: URLSessionConfiguration = .stubbing,
identityDatabase: IdentityDatabase = .fresh(),
defaults: Defaults = .fresh(),
keychainService: KeychainServiceType = freshKeychainService(),
webAuthSessionType: WebAuthSessionType.Type = SuccessfulMockWebAuthSession.self) -> AppEnvironment {
AppEnvironment(
URLSessionConfiguration: URLSessionConfiguration,
identityDatabase: identityDatabase,
defaults: defaults,
keychainService: keychainService,
webAuthSessionType: webAuthSessionType)
}
static let development = AppEnvironment( static let development = AppEnvironment(
URLSessionConfiguration: .stubbing, URLSessionConfiguration: .stubbing,
identityDatabase: .development,
defaults: .development,
keychainService: developmentKeychainService,
webAuthSessionType: SuccessfulMockWebAuthSession.self) webAuthSessionType: SuccessfulMockWebAuthSession.self)
} }
extension IdentitiesService { extension IdentitiesService {
static let development = IdentitiesService(environment: .development) static func fresh(
identityDatabase: IdentityDatabase = .fresh(),
keychainService: KeychainServiceType = MockKeychainService(),
environment: AppEnvironment = .development) -> IdentitiesService {
IdentitiesService(
identityDatabase: identityDatabase,
keychainService: keychainService,
environment: environment)
}
static let development = IdentitiesService(
identityDatabase: .development,
keychainService: developmentKeychainService,
environment: .development)
} }
extension IdentityService { extension IdentityService {

View file

@ -57,8 +57,6 @@
D052BBC724D749C800A80A7A /* RootViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC624D749C800A80A7A /* RootViewModelTests.swift */; }; D052BBC724D749C800A80A7A /* RootViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC624D749C800A80A7A /* RootViewModelTests.swift */; };
D052BBCA24D74C9200A80A7A /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */; }; D052BBCA24D74C9200A80A7A /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */; };
D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */; }; D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */; };
D052BBCF24D750C000A80A7A /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCE24D750C000A80A7A /* Defaults.swift */; };
D052BBD024D750C000A80A7A /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCE24D750C000A80A7A /* Defaults.swift */; };
D052BBD124D750CA00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; }; D052BBD124D750CA00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; };
D052BBD224D750CB00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; }; D052BBD224D750CB00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; };
D065F53924D37E5100741304 /* CombineExpectations in Frameworks */ = {isa = PBXBuildFile; productRef = D065F53824D37E5100741304 /* CombineExpectations */; }; D065F53924D37E5100741304 /* CombineExpectations in Frameworks */ = {isa = PBXBuildFile; productRef = D065F53824D37E5100741304 /* CombineExpectations */; };
@ -211,7 +209,6 @@
D052BBC624D749C800A80A7A /* RootViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModelTests.swift; sourceTree = "<group>"; }; D052BBC624D749C800A80A7A /* RootViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModelTests.swift; sourceTree = "<group>"; };
D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = "<group>"; }; D052BBC824D74B6400A80A7A /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = "<group>"; };
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = "<group>"; }; D052BBCC24D750A100A80A7A /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = "<group>"; };
D052BBCE24D750C000A80A7A /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = "<group>"; };
D065F53A24D3B33A00741304 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; }; D065F53A24D3B33A00741304 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -446,7 +443,6 @@
D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */, D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */,
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */, D052BBCC24D750A100A80A7A /* AppEnvironment.swift */,
D0ED1BD624CF94B200B4899C /* Application.swift */, D0ED1BD624CF94B200B4899C /* Application.swift */,
D052BBCE24D750C000A80A7A /* Defaults.swift */,
D0666A5324C6C3E500F3F04B /* Emoji.swift */, D0666A5324C6C3E500F3F04B /* Emoji.swift */,
D0666A4A24C6C37700F3F04B /* Identity.swift */, D0666A4A24C6C37700F3F04B /* Identity.swift */,
D0666A4D24C6C39600F3F04B /* Instance.swift */, D0666A4D24C6C39600F3F04B /* Instance.swift */,
@ -778,7 +774,6 @@
D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */, D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */,
D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */, D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */,
D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */, D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
D052BBCF24D750C000A80A7A /* Defaults.swift in Sources */,
D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */, D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */,
D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */, D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */, D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */,
@ -859,7 +854,6 @@
D0159FA624DE98F600E78478 /* NSMutableAttributedString+Extensions.swift in Sources */, D0159FA624DE98F600E78478 /* NSMutableAttributedString+Extensions.swift in Sources */,
D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */, D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */,
D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */, D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */,
D052BBD024D750C000A80A7A /* Defaults.swift in Sources */,
D0ED1BE424CFA84400B4899C /* MastodonError.swift in Sources */, D0ED1BE424CFA84400B4899C /* MastodonError.swift in Sources */,
D0666A6424C6DC6C00F3F04B /* AppAuthorization.swift in Sources */, D0666A6424C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */, D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */,

View file

@ -1,7 +1,8 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
"go" = "Go";
"add-identity.instance-url" = "Instance URL"; "add-identity.instance-url" = "Instance URL";
"add-identity.log-in" = "Log in";
"add-identity.browse-anonymously" = "Browse anonymously";
"oauth.error.code-not-found" = "OAuth error: code not found"; "oauth.error.code-not-found" = "OAuth error: code not found";
"secondary-navigation.manage-accounts" = "Manage Accounts"; "secondary-navigation.manage-accounts" = "Manage Accounts";
"secondary-navigation.preferences" = "Preferences"; "secondary-navigation.preferences" = "Preferences";

View file

@ -4,28 +4,27 @@ import SwiftUI
@main @main
struct MetatextApp: App { struct MetatextApp: App {
private let environment: AppEnvironment private let identityDatabase: IdentityDatabase
private let keychainServive = KeychainService(serviceName: "com.metabolist.metatext")
private let environment = AppEnvironment(
URLSessionConfiguration: .default,
webAuthSessionType: WebAuthSession.self)
init() { init() {
let identityDatabase: IdentityDatabase
do { do {
try identityDatabase = IdentityDatabase() try identityDatabase = IdentityDatabase()
} catch { } catch {
fatalError("Failed to initialize identity database") fatalError("Failed to initialize identity database")
} }
environment = AppEnvironment(
URLSessionConfiguration: .default,
identityDatabase: identityDatabase,
defaults: Defaults(userDefaults: .standard),
keychainService: KeychainService(serviceName: Self.keychainServiceName),
webAuthSessionType: WebAuthSession.self)
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView(viewModel: RootViewModel(identitiesService: IdentitiesService(environment: environment))) RootView(
viewModel: RootViewModel(identitiesService: IdentitiesService(
identityDatabase: identityDatabase,
keychainService: keychainServive,
environment: environment)))
} }
} }
} }

View file

@ -4,8 +4,5 @@ import Foundation
struct AppEnvironment { struct AppEnvironment {
let URLSessionConfiguration: URLSessionConfiguration let URLSessionConfiguration: URLSessionConfiguration
let identityDatabase: IdentityDatabase
let defaults: Defaults
let keychainService: KeychainServiceType
let webAuthSessionType: WebAuthSessionType.Type let webAuthSessionType: WebAuthSessionType.Type
} }

View file

@ -1,24 +0,0 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
class Defaults {
private let userDefaults: UserDefaults
init(userDefaults: UserDefaults) {
self.userDefaults = userDefaults
}
}
extension Defaults {
enum Item: String {
case recentIdentityID = "recent-identity-id"
}
}
extension Defaults {
subscript<T>(index: Defaults.Item) -> T? {
get { userDefaults.value(forKey: index.rawValue) as? T }
set { userDefaults.set(newValue, forKey: index.rawValue) }
}
}

View file

@ -4,25 +4,4 @@ import Foundation
struct MastodonAPI { struct MastodonAPI {
static let dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" static let dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
struct OAuth {
static let clientName = "Metatext"
static let scopes = "read write follow push"
static let codeCallbackQueryItemName = "code"
static let grantType = "authorization_code"
static let callbackURLScheme = "metatext"
}
enum OAuthError {
case codeNotFound
}
}
extension MastodonAPI.OAuthError: LocalizedError {
var errorDescription: String? {
switch self {
case .codeNotFound:
return NSLocalizedString("oauth.error.code-not-found", comment: "")
}
}
} }

View file

@ -4,168 +4,109 @@ import Foundation
import Combine import Combine
struct AuthenticationService { struct AuthenticationService {
private let environment: AppEnvironment
private let networkClient: MastodonClient private let networkClient: MastodonClient
private let webAuthSessionType: WebAuthSessionType.Type
private let webAuthSessionContextProvider = WebAuthSessionContextProvider() private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
init(environment: AppEnvironment) { init(environment: AppEnvironment) {
self.environment = environment
networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
webAuthSessionType = environment.webAuthSessionType
} }
} }
extension AuthenticationService { extension AuthenticationService {
func authenticate(instanceURL: URL) -> AnyPublisher<UUID, Error> { func authorizeApp(instanceURL: URL) -> AnyPublisher<AppAuthorization, Error> {
let identityID = UUID()
let redirectURL: URL
do {
redirectURL = try identityID.uuidString.url(scheme: MastodonAPI.OAuth.callbackURLScheme)
} catch {
return Fail(error: error).eraseToAnyPublisher()
}
return authorizeApp(
identityID: identityID,
instanceURL: instanceURL,
redirectURL: redirectURL,
keychainService: environment.keychainService)
.authenticationURL(instanceURL: instanceURL, redirectURL: redirectURL)
.authenticate(
webAuthSessionType: environment.webAuthSessionType,
contextProvider: webAuthSessionContextProvider,
callbackURLScheme: MastodonAPI.OAuth.callbackURLScheme)
.extractCode()
.requestAccessToken(
networkClient: networkClient,
identityID: identityID,
instanceURL: instanceURL,
redirectURL: redirectURL)
.createIdentity(id: identityID, instanceURL: instanceURL, environment: environment)
}
}
private extension AuthenticationService {
func authorizeApp(
identityID: UUID,
instanceURL: URL,
redirectURL: URL,
keychainService: KeychainServiceType) -> AnyPublisher<AppAuthorization, Error> {
let endpoint = AppAuthorizationEndpoint.apps( let endpoint = AppAuthorizationEndpoint.apps(
clientName: MastodonAPI.OAuth.clientName, clientName: OAuth.clientName,
redirectURI: redirectURL.absoluteString, redirectURI: OAuth.callbackURL.absoluteString,
scopes: MastodonAPI.OAuth.scopes, scopes: OAuth.scopes,
website: nil) website: OAuth.website)
let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil) let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil)
return networkClient.request(target) return networkClient.request(target)
.tryMap { }
let secretsService = SecretsService(identityID: identityID, keychainService: keychainService)
try secretsService.set($0.clientId, forItem: .clientID)
try secretsService.set($0.clientSecret, forItem: .clientSecret)
return $0 func authenticate(instanceURL: URL, appAuthorization: AppAuthorization) -> AnyPublisher<AccessToken, Error> {
guard let authorizationURL = authorizationURL(
instanceURL: instanceURL,
clientID: appAuthorization.clientId) else {
return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
}
return webAuthSessionType.publisher(
url: authorizationURL,
callbackURLScheme: OAuth.callbackURLScheme,
presentationContextProvider: webAuthSessionContextProvider)
.tryCatch { error -> AnyPublisher<URL?, Error> in
if (error as? WebAuthSessionError)?.code == .canceledLogin {
return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher()
}
throw error
}
.compactMap { $0 }
.tryMap { url -> String in
guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems,
let code = queryItems.first(where: {
$0.name == OAuth.codeCallbackQueryItemName
})?.value
else { throw OAuthError.codeNotFound }
return code
}
.flatMap { code -> AnyPublisher<AccessToken, Error> in
let endpoint = AccessTokenEndpoint.oauthToken(
clientID: appAuthorization.clientId,
clientSecret: appAuthorization.clientSecret,
code: code,
grantType: OAuth.grantType,
scopes: OAuth.scopes,
redirectURI: OAuth.callbackURL.absoluteString)
let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil)
return networkClient.request(target)
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} }
private extension Publisher where Output == AppAuthorization { private extension AuthenticationService {
func authenticationURL(instanceURL: URL, redirectURL: URL) -> AnyPublisher<(AppAuthorization, URL), Error> { struct OAuth {
tryMap { appAuthorization in static let clientName = "Metatext"
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else { static let scopes = "read write follow push"
throw URLError(.badURL) static let codeCallbackQueryItemName = "code"
} static let grantType = "authorization_code"
static let callbackURLScheme = "metatext"
static let callbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")!
static let website = URL(string: "https://metabolist.com/metatext")!
}
authorizationURLComponents.path = "/oauth/authorize" enum OAuthError {
authorizationURLComponents.queryItems = [ case codeNotFound
"client_id": appAuthorization.clientId, }
"scope": MastodonAPI.OAuth.scopes,
"response_type": "code",
"redirect_uri": redirectURL.absoluteString
].map { URLQueryItem(name: $0, value: $1) }
guard let authorizationURL = authorizationURLComponents.url else { private func authorizationURL(instanceURL: URL, clientID: String) -> URL? {
throw URLError(.badURL) guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
} return nil
return (appAuthorization, authorizationURL)
} }
.mapError { $0 as Error }
.eraseToAnyPublisher() authorizationURLComponents.path = "/oauth/authorize"
authorizationURLComponents.queryItems = [
"client_id": clientID,
"scope": OAuth.scopes,
"response_type": "code",
"redirect_uri": OAuth.callbackURL.absoluteString
].map { URLQueryItem(name: $0, value: $1) }
return authorizationURLComponents.url
} }
} }
private extension Publisher where Output == (AppAuthorization, URL), Failure == Error { extension AuthenticationService.OAuthError: LocalizedError {
func authenticate( var errorDescription: String? {
webAuthSessionType: WebAuthSessionType.Type, switch self {
contextProvider: WebAuthSessionContextProvider, case .codeNotFound:
callbackURLScheme: String) -> AnyPublisher<(AppAuthorization, URL), Error> { return NSLocalizedString("oauth.error.code-not-found", comment: "")
flatMap { appAuthorization, url in
webAuthSessionType.publisher(
url: url,
callbackURLScheme: callbackURLScheme,
presentationContextProvider: contextProvider)
.tryCatch { error -> AnyPublisher<URL?, Error> in
if (error as? WebAuthSessionError)?.code == .canceledLogin {
return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher()
}
throw error
}
.compactMap { $0 }
.map { (appAuthorization, $0) }
} }
.eraseToAnyPublisher()
}
}
private extension Publisher where Output == (AppAuthorization, URL) {
func extractCode() -> AnyPublisher<(AppAuthorization, String), Error> {
tryMap { appAuthorization, url -> (AppAuthorization, String) in
guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems,
let code = queryItems.first(where: { $0.name == MastodonAPI.OAuth.codeCallbackQueryItemName })?.value
else { throw MastodonAPI.OAuthError.codeNotFound }
return (appAuthorization, code)
}
.eraseToAnyPublisher()
}
}
private extension Publisher where Output == (AppAuthorization, String), Failure == Error {
func requestAccessToken(
networkClient: HTTPClient,
identityID: UUID,
instanceURL: URL,
redirectURL: URL) -> AnyPublisher<AccessToken, Error> {
flatMap { appAuthorization, code -> AnyPublisher<AccessToken, Error> in
let endpoint = AccessTokenEndpoint.oauthToken(
clientID: appAuthorization.clientId,
clientSecret: appAuthorization.clientSecret,
code: code,
grantType: MastodonAPI.OAuth.grantType,
scopes: MastodonAPI.OAuth.scopes,
redirectURI: redirectURL.absoluteString)
let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil)
return networkClient.request(target)
}
.eraseToAnyPublisher()
}
}
private extension Publisher where Output == AccessToken {
func createIdentity(id: UUID, instanceURL: URL, environment: AppEnvironment) -> AnyPublisher<UUID, Error> {
tryMap { accessToken -> (UUID, URL) in
let secretsService = SecretsService(identityID: id, keychainService: environment.keychainService)
try secretsService.set(accessToken.accessToken, forItem: .accessToken)
return (id, instanceURL)
}
.flatMap(environment.identityDatabase.createIdentity)
.map { id }
.eraseToAnyPublisher()
} }
} }

View file

@ -6,12 +6,16 @@ import Combine
class IdentitiesService { class IdentitiesService {
@Published var mostRecentlyUsedIdentityID: UUID? @Published var mostRecentlyUsedIdentityID: UUID?
private let identityDatabase: IdentityDatabase
private let keychainService: KeychainServiceType
private let environment: AppEnvironment private let environment: AppEnvironment
init(environment: AppEnvironment) { init(identityDatabase: IdentityDatabase, keychainService: KeychainServiceType, environment: AppEnvironment) {
self.identityDatabase = identityDatabase
self.keychainService = keychainService
self.environment = environment self.environment = environment
environment.identityDatabase.mostRecentlyUsedIdentityIDObservation() identityDatabase.mostRecentlyUsedIdentityIDObservation()
.replaceError(with: nil) .replaceError(with: nil)
.assign(to: &$mostRecentlyUsedIdentityID) .assign(to: &$mostRecentlyUsedIdentityID)
} }
@ -19,20 +23,43 @@ class IdentitiesService {
extension IdentitiesService { extension IdentitiesService {
func identityService(id: UUID) throws -> IdentityService { func identityService(id: UUID) throws -> IdentityService {
try IdentityService(identityID: id, environment: environment) try IdentityService(identityID: id,
identityDatabase: identityDatabase,
keychainService: keychainService,
environment: environment)
} }
func authenticationService() -> AuthenticationService { func createIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Void, Error> {
AuthenticationService(environment: environment) identityDatabase.createIdentity(id: id, url: instanceURL)
}
func authorizeIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Void, Error> {
let secretsService = SecretsService(identityID: id, keychainService: keychainService)
let authenticationService = AuthenticationService(environment: environment)
return authenticationService.authorizeApp(instanceURL: instanceURL)
.tryMap { appAuthorization -> (URL, AppAuthorization) in
try secretsService.set(appAuthorization.clientId, forItem: .clientID)
try secretsService.set(appAuthorization.clientSecret, forItem: .clientSecret)
return (instanceURL, appAuthorization)
}
.flatMap(authenticationService.authenticate(instanceURL:appAuthorization:))
.tryMap { accessToken -> Void in
try secretsService.set(accessToken.accessToken, forItem: .accessToken)
return ()
}
.eraseToAnyPublisher()
} }
func deleteIdentity(id: UUID) -> AnyPublisher<Void, Error> { func deleteIdentity(id: UUID) -> AnyPublisher<Void, Error> {
environment.identityDatabase.deleteIdentity(id: id) identityDatabase.deleteIdentity(id: id)
.continuingIfWeakReferenceIsStillAlive(to: self) .continuingIfWeakReferenceIsStillAlive(to: self)
.tryMap { _, welf -> Void in .tryMap { _, welf -> Void in
try SecretsService( try SecretsService(
identityID: id, identityID: id,
keychainService: welf.environment.keychainService) keychainService: welf.keychainService)
.deleteAllItems() .deleteAllItems()
return () return ()

View file

@ -7,15 +7,20 @@ class IdentityService {
@Published private(set) var identity: Identity @Published private(set) var identity: Identity
let observationErrors: AnyPublisher<Error, Never> let observationErrors: AnyPublisher<Error, Never>
private let networkClient: MastodonClient private let identityDatabase: IdentityDatabase
private let environment: AppEnvironment private let environment: AppEnvironment
private let networkClient: MastodonClient
private let observationErrorsInput = PassthroughSubject<Error, Never>() private let observationErrorsInput = PassthroughSubject<Error, Never>()
init(identityID: UUID, environment: AppEnvironment) throws { init(identityID: UUID,
identityDatabase: IdentityDatabase,
keychainService: KeychainServiceType,
environment: AppEnvironment) throws {
self.identityDatabase = identityDatabase
self.environment = environment self.environment = environment
observationErrors = observationErrorsInput.eraseToAnyPublisher() observationErrors = observationErrorsInput.eraseToAnyPublisher()
let observation = environment.identityDatabase.identityObservation(id: identityID).share() let observation = identityDatabase.identityObservation(id: identityID).share()
var initialIdentity: Identity? var initialIdentity: Identity?
_ = observation.first().sink( _ = observation.first().sink(
@ -29,7 +34,7 @@ class IdentityService {
networkClient.instanceURL = identity.url networkClient.instanceURL = identity.url
networkClient.accessToken = try SecretsService( networkClient.accessToken = try SecretsService(
identityID: identityID, identityID: identityID,
keychainService: environment.keychainService) keychainService: keychainService)
.item(.accessToken) .item(.accessToken)
observation.catch { [weak self] error -> Empty<Identity, Never> in observation.catch { [weak self] error -> Empty<Identity, Never> in
@ -45,14 +50,14 @@ extension IdentityService {
var isAuthorized: Bool { networkClient.accessToken != nil } var isAuthorized: Bool { networkClient.accessToken != nil }
func updateLastUse() -> AnyPublisher<Void, Error> { func updateLastUse() -> AnyPublisher<Void, Error> {
environment.identityDatabase.updateLastUsedAt(identityID: identity.id) identityDatabase.updateLastUsedAt(identityID: identity.id)
} }
func verifyCredentials() -> AnyPublisher<Void, Error> { func verifyCredentials() -> AnyPublisher<Void, Error> {
networkClient.request(AccountEndpoint.verifyCredentials) networkClient.request(AccountEndpoint.verifyCredentials)
.continuingIfWeakReferenceIsStillAlive(to: self) .continuingIfWeakReferenceIsStillAlive(to: self)
.map { ($0, $1.identity.id) } .map { ($0, $1.identity.id) }
.flatMap(environment.identityDatabase.updateAccount) .flatMap(identityDatabase.updateAccount)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -60,7 +65,7 @@ extension IdentityService {
networkClient.request(PreferencesEndpoint.preferences) networkClient.request(PreferencesEndpoint.preferences)
.continuingIfWeakReferenceIsStillAlive(to: self) .continuingIfWeakReferenceIsStillAlive(to: self)
.map { ($1.identity.preferences.updated(from: $0), $1.identity.id) } .map { ($1.identity.preferences.updated(from: $0), $1.identity.id) }
.flatMap(environment.identityDatabase.updatePreferences) .flatMap(identityDatabase.updatePreferences)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -68,19 +73,19 @@ extension IdentityService {
networkClient.request(InstanceEndpoint.instance) networkClient.request(InstanceEndpoint.instance)
.continuingIfWeakReferenceIsStillAlive(to: self) .continuingIfWeakReferenceIsStillAlive(to: self)
.map { ($0, $1.identity.id) } .map { ($0, $1.identity.id) }
.flatMap(environment.identityDatabase.updateInstance) .flatMap(identityDatabase.updateInstance)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func identitiesObservation() -> AnyPublisher<[Identity], Error> { func identitiesObservation() -> AnyPublisher<[Identity], Error> {
environment.identityDatabase.identitiesObservation() identityDatabase.identitiesObservation()
} }
func recentIdentitiesObservation() -> AnyPublisher<[Identity], Error> { func recentIdentitiesObservation() -> AnyPublisher<[Identity], Error> {
environment.identityDatabase.recentIdentitiesObservation(excluding: identity.id) identityDatabase.recentIdentitiesObservation(excluding: identity.id)
} }
func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher<Void, Error> { func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher<Void, Error> {
environment.identityDatabase.updatePreferences(preferences, forIdentityID: identity.id) identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
} }
} }

View file

@ -9,19 +9,31 @@ class AddIdentityViewModel: ObservableObject {
@Published private(set) var loading = false @Published private(set) var loading = false
let addedIdentityID: AnyPublisher<UUID, Never> let addedIdentityID: AnyPublisher<UUID, Never>
private let authenticationService: AuthenticationService private let identitiesService: IdentitiesService
private let addedIdentityIDInput = PassthroughSubject<UUID, Never>() private let addedIdentityIDInput = PassthroughSubject<UUID, Never>()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(authenticationService: AuthenticationService) { init(identitiesService: IdentitiesService) {
self.authenticationService = authenticationService self.identitiesService = identitiesService
addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher() addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher()
} }
func goTapped() { func logInTapped() {
Just(urlFieldText) let identityID = UUID()
.tryMap { try $0.url() } let instanceURL: URL
.flatMap(authenticationService.authenticate(instanceURL:))
do {
try instanceURL = urlFieldText.url()
} catch {
alertItem = AlertItem(error: error)
return
}
identitiesService.authorizeIdentity(id: identityID, instanceURL: instanceURL)
.map { (identityID, instanceURL) }
.flatMap(identitiesService.createIdentity(id:instanceURL:))
.map { identityID }
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.handleEvents( .handleEvents(
@ -30,4 +42,23 @@ class AddIdentityViewModel: ObservableObject {
.sink(receiveValue: addedIdentityIDInput.send) .sink(receiveValue: addedIdentityIDInput.send)
.store(in: &cancellables) .store(in: &cancellables)
} }
func browseAnonymouslyTapped() {
let identityID = UUID()
let instanceURL: URL
do {
try instanceURL = urlFieldText.url()
} catch {
alertItem = AlertItem(error: error)
return
}
identitiesService.createIdentity(id: identityID, instanceURL: instanceURL)
.map { identityID }
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink(receiveValue: addedIdentityIDInput.send)
.store(in: &cancellables)
}
} }

View file

@ -52,6 +52,6 @@ extension RootViewModel {
} }
func addIdentityViewModel() -> AddIdentityViewModel { func addIdentityViewModel() -> AddIdentityViewModel {
AddIdentityViewModel(authenticationService: identitiesService.authenticationService()) AddIdentityViewModel(identitiesService: identitiesService)
} }
} }

View file

@ -21,12 +21,13 @@ struct AddIdentityView: View {
if viewModel.loading { if viewModel.loading {
ProgressView() ProgressView()
} else { } else {
Button( Button("add-identity.log-in",
action: viewModel.goTapped, action: viewModel.logInTapped)
label: { Text("go") })
} }
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped)
.frame(maxWidth: .infinity, alignment: .center)
#if os(macOS) #if os(macOS)
Spacer() Spacer()
#endif #endif

View file

@ -6,26 +6,21 @@ import CombineExpectations
@testable import Metatext @testable import Metatext
class AuthenticationServiceTests: XCTestCase { class AuthenticationServiceTests: XCTestCase {
func testAddIdentity() throws { func testAuthentication() throws {
let environment = AppEnvironment.fresh() let sut = AuthenticationService(environment: .development)
let sut = AuthenticationService(environment: environment)
let instanceURL = URL(string: "https://mastodon.social")! let instanceURL = URL(string: "https://mastodon.social")!
let addedIDRecorder = sut.authenticate(instanceURL: instanceURL).record() let appAuthorizationRecorder = sut.authorizeApp(instanceURL: instanceURL).record()
let appAuthorization = try wait(for: appAuthorizationRecorder.next(), timeout: 1)
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1) XCTAssertEqual(appAuthorization.clientId, "AUTHORIZATION_CLIENT_ID_STUB_VALUE")
let identityRecorder = environment.identityDatabase.identityObservation(id: addedIdentityID).record() XCTAssertEqual(appAuthorization.clientSecret, "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE")
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
XCTAssertEqual(addedIdentity.id, addedIdentityID) let accessTokenRecorder = sut.authenticate(
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) instanceURL: instanceURL,
appAuthorization: appAuthorization)
.record()
let accessToken = try wait(for: accessTokenRecorder.next(), timeout: 1)
let secretsService = SecretsService(identityID: addedIdentity.id, keychainService: environment.keychainService) XCTAssertEqual(accessToken.accessToken, "ACCESS_TOKEN_STUB_VALUE")
XCTAssertEqual(
try secretsService.item(.clientID) as String?, "AUTHORIZATION_CLIENT_ID_STUB_VALUE")
XCTAssertEqual(
try secretsService.item(.clientSecret) as String?, "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE")
XCTAssertEqual(
try secretsService.item(.accessToken) as String?, "ACCESS_TOKEN_STUB_VALUE")
} }
} }

View file

@ -7,43 +7,45 @@ import CombineExpectations
class AddIdentityViewModelTests: XCTestCase { class AddIdentityViewModelTests: XCTestCase {
func testAddIdentity() throws { func testAddIdentity() throws {
let environment = AppEnvironment.fresh() let identityDatabase = IdentityDatabase.fresh()
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment)) let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
let addedIDRecorder = sut.addedIdentityID.record() let addedIDRecorder = sut.addedIdentityID.record()
sut.urlFieldText = "https://mastodon.social" sut.urlFieldText = "https://mastodon.social"
sut.goTapped() sut.logInTapped()
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1) let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1)
let identityRecorder = environment.identityDatabase.identityObservation(id: addedIdentityID).record() let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record()
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1) let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
XCTAssertEqual(addedIdentity.id, addedIdentityID)
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
} }
func testAddIdentityWithoutScheme() throws { func testAddIdentityWithoutScheme() throws {
let environment = AppEnvironment.fresh() let identityDatabase = IdentityDatabase.fresh()
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment)) let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
let addedIDRecorder = sut.addedIdentityID.record() let addedIDRecorder = sut.addedIdentityID.record()
sut.urlFieldText = "mastodon.social" sut.urlFieldText = "mastodon.social"
sut.goTapped() sut.logInTapped()
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1) let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1)
let identityRecorder = environment.identityDatabase.identityObservation(id: addedIdentityID).record() let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record()
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1) let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
XCTAssertEqual(addedIdentity.id, addedIdentityID)
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
} }
func testInvalidURL() throws { func testInvalidURL() throws {
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: .fresh())) let sut = AddIdentityViewModel(identitiesService: .fresh())
let recorder = sut.$alertItem.record() let recorder = sut.$alertItem.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
sut.urlFieldText = "🐘.social" sut.urlFieldText = "🐘.social"
sut.goTapped() sut.logInTapped()
let alertItem = try wait(for: recorder.next(), timeout: 1) let alertItem = try wait(for: recorder.next(), timeout: 1)
@ -51,14 +53,20 @@ class AddIdentityViewModelTests: XCTestCase {
} }
func testDoesNotAlertCanceledLogin() throws { func testDoesNotAlertCanceledLogin() throws {
let environment = AppEnvironment.fresh(webAuthSessionType: CanceledLoginMockWebAuthSession.self) let environment = AppEnvironment(
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment)) URLSessionConfiguration: .stubbing,
webAuthSessionType: CanceledLoginMockWebAuthSession.self)
let identitiesService = IdentitiesService(
identityDatabase: .fresh(),
keychainService: MockKeychainService(),
environment: environment)
let sut = AddIdentityViewModel(identitiesService: identitiesService)
let recorder = sut.$alertItem.record() let recorder = sut.$alertItem.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
sut.urlFieldText = "https://mastodon.social" sut.urlFieldText = "https://mastodon.social"
sut.goTapped() sut.logInTapped()
try wait(for: recorder.next().inverted, timeout: 1) try wait(for: recorder.next().inverted, timeout: 1)
} }

View file

@ -9,9 +9,10 @@ class RootViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>() var cancellables = Set<AnyCancellable>()
func testAddIdentity() throws { func testAddIdentity() throws {
let environment = AppEnvironment.fresh() let sut = RootViewModel(identitiesService: IdentitiesService(
let sut = RootViewModel( identityDatabase: .fresh(),
identitiesService: IdentitiesService(environment: environment)) keychainService: MockKeychainService(),
environment: .development))
let recorder = sut.$mainNavigationViewModel.record() let recorder = sut.$mainNavigationViewModel.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
@ -23,7 +24,7 @@ class RootViewModelTests: XCTestCase {
.store(in: &cancellables) .store(in: &cancellables)
addIdentityViewModel.urlFieldText = "https://mastodon.social" addIdentityViewModel.urlFieldText = "https://mastodon.social"
addIdentityViewModel.goTapped() addIdentityViewModel.logInTapped()
let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)! let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)!