Refactoring

This commit is contained in:
Justin Mazzocchi 2020-08-08 22:37:04 -07:00
parent 6dfda031a6
commit 54f8c82fcd
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
13 changed files with 310 additions and 227 deletions

View file

@ -91,16 +91,24 @@ extension AppEnvironment {
webAuthSessionType: SuccessfulMockWebAuthSession.self) webAuthSessionType: SuccessfulMockWebAuthSession.self)
} }
extension IdentitiesService {
static let development = IdentitiesService(environment: .development)
}
extension IdentityService { extension IdentityService {
static let development = try! IdentityService(identityID: devIdentityID, appEnvironment: .development) static let development = try! IdentitiesService.development.identityService(id: devIdentityID)
} }
extension RootViewModel { extension RootViewModel {
static let development = RootViewModel(environment: .development) static let development = RootViewModel(identitiesService: .development)
}
extension AddIdentityViewModel {
static let development = RootViewModel.development.addIdentityViewModel()
} }
extension MainNavigationViewModel { extension MainNavigationViewModel {
static let development = RootViewModel.development.mainNavigationViewModel(identityID: devIdentityID)! static let development = RootViewModel.development.mainNavigationViewModel!
} }
#if os(iOS) #if os(iOS)

View file

@ -140,6 +140,10 @@
D0EC8DC624DF842700A08489 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC424DF842700A08489 /* KeychainService.swift */; }; D0EC8DC624DF842700A08489 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC424DF842700A08489 /* KeychainService.swift */; };
D0EC8DC824DF8B3C00A08489 /* SecretsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */; }; D0EC8DC824DF8B3C00A08489 /* SecretsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */; };
D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */; }; D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */; };
D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */; };
D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */; };
D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; };
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; };
D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; }; D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; };
D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; }; D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; }; D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
@ -245,6 +249,8 @@
D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityService.swift; sourceTree = "<group>"; }; D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityService.swift; sourceTree = "<group>"; };
D0EC8DC424DF842700A08489 /* KeychainService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = "<group>"; }; D0EC8DC424DF842700A08489 /* KeychainService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = "<group>"; };
D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecretsService.swift; sourceTree = "<group>"; }; D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecretsService.swift; sourceTree = "<group>"; };
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesService.swift; sourceTree = "<group>"; };
D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; };
D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = "<group>"; }; D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = "<group>"; };
D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSession.swift; sourceTree = "<group>"; }; D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSession.swift; sourceTree = "<group>"; };
D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; }; D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
@ -349,6 +355,8 @@
D019E6F224DF7C9E00697C7D /* Services */ = { D019E6F224DF7C9E00697C7D /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */,
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */,
D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */, D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */,
D0EC8DC424DF842700A08489 /* KeychainService.swift */, D0EC8DC424DF842700A08489 /* KeychainService.swift */,
D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */, D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */,
@ -796,6 +804,7 @@
D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */, D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */,
D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */, D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */,
D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */, D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */,
D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */,
D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */, D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */,
D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */, D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */,
D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */, D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
@ -814,6 +823,7 @@
D0EC8DC824DF8B3C00A08489 /* SecretsService.swift in Sources */, D0EC8DC824DF8B3C00A08489 /* SecretsService.swift in Sources */,
D0159FA324DE955900E78478 /* CustomEmojiText.swift in Sources */, D0159FA324DE955900E78478 /* CustomEmojiText.swift in Sources */,
D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */, D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */,
D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */,
D04FD73C24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift in Sources */, D04FD73C24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift in Sources */,
D0DC175B24D0154F00A75C65 /* MastodonAPI.swift in Sources */, D0DC175B24D0154F00A75C65 /* MastodonAPI.swift in Sources */,
D0ED1BD124CF779B00B4899C /* MastodonTarget.swift in Sources */, D0ED1BD124CF779B00B4899C /* MastodonTarget.swift in Sources */,
@ -849,6 +859,7 @@
D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */, D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */,
D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */, D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */,
D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */, D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */,
D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */,
D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */, D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */,
D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */, D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */,
D0A652AE24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift in Sources */, D0A652AE24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift in Sources */,
@ -875,6 +886,7 @@
D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */, D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */,
D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */, D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */,
D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */, D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */,
D0159F9C24DE748C00E78478 /* SidebarNavigationView.swift in Sources */, D0159F9C24DE748C00E78478 /* SidebarNavigationView.swift in Sources */,
D019E6F124DF7C2F00697C7D /* DatabaseError.swift in Sources */, D019E6F124DF7C2F00697C7D /* DatabaseError.swift in Sources */,
D074577824D29006004758DB /* MockWebAuthSession.swift in Sources */, D074577824D29006004758DB /* MockWebAuthSession.swift in Sources */,

View file

@ -138,8 +138,11 @@ extension IdentityDatabase {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
var mostRecentlyUsedIdentityID: UUID? { func mostRecentlyUsedIdentityIDObservation() -> AnyPublisher<UUID?, Error> {
try? databaseQueue.read(StoredIdentity.select(Column("id")).order(Column("lastUsedAt").desc).fetchOne) ValueObservation.tracking(StoredIdentity.select(Column("id")).order(Column("lastUsedAt").desc).fetchOne)
.removeDuplicates()
.publisher(in: databaseQueue, scheduling: .immediate)
.eraseToAnyPublisher()
} }
} }

View file

@ -25,7 +25,7 @@ struct MetatextApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView(viewModel: RootViewModel(environment: environment)) RootView(viewModel: RootViewModel(identitiesService: IdentitiesService(environment: environment)))
} }
} }
} }

View file

@ -0,0 +1,171 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Combine
struct AuthenticationService {
private let environment: AppEnvironment
private let networkClient: MastodonClient
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
init(environment: AppEnvironment) {
self.environment = environment
self.networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
}
}
extension AuthenticationService {
func authenticate(instanceURL: URL) -> AnyPublisher<UUID, 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(
clientName: MastodonAPI.OAuth.clientName,
redirectURI: redirectURL.absoluteString,
scopes: MastodonAPI.OAuth.scopes,
website: nil)
let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil)
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
}
.eraseToAnyPublisher()
}
}
private extension Publisher where Output == AppAuthorization {
func authenticationURL(instanceURL: URL, redirectURL: URL) -> AnyPublisher<(AppAuthorization, URL), Error> {
tryMap { appAuthorization in
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
throw URLError(.badURL)
}
authorizationURLComponents.path = "/oauth/authorize"
authorizationURLComponents.queryItems = [
"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 {
throw URLError(.badURL)
}
return (appAuthorization, authorizationURL)
}
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
}
private extension Publisher where Output == (AppAuthorization, URL), Failure == Error {
func authenticate(
webAuthSessionType: WebAuthSessionType.Type,
contextProvider: WebAuthSessionContextProvider,
callbackURLScheme: String) -> AnyPublisher<(AppAuthorization, URL), Error> {
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

@ -0,0 +1,43 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Combine
class IdentitiesService {
@Published var mostRecentlyUsedIdentityID: UUID?
private let environment: AppEnvironment
private var cancellables = Set<AnyCancellable>()
init(environment: AppEnvironment) {
self.environment = environment
environment.identityDatabase.mostRecentlyUsedIdentityIDObservation()
.replaceError(with: nil)
.assign(to: &$mostRecentlyUsedIdentityID)
}
}
extension IdentitiesService {
func identityService(id: UUID) throws -> IdentityService {
try IdentityService(identityID: id, environment: environment)
}
func authenticationService() -> AuthenticationService {
AuthenticationService(environment: environment)
}
func deleteIdentity(id: UUID) -> AnyPublisher<Void, Error> {
environment.identityDatabase.deleteIdentity(id: id)
.continuingIfWeakReferenceIsStillAlive(to: self)
.tryMap { _, welf -> Void in
try SecretsService(
identityID: id,
keychainService: welf.environment.keychainService)
.deleteAllItems()
return ()
}
.eraseToAnyPublisher()
}
}

View file

@ -8,20 +8,20 @@ class IdentityService {
let observationErrors: AnyPublisher<Error, Never> let observationErrors: AnyPublisher<Error, Never>
private let networkClient: MastodonClient private let networkClient: MastodonClient
private let appEnvironment: AppEnvironment private let environment: AppEnvironment
private let observationErrorsInput = PassthroughSubject<Error, Never>() private let observationErrorsInput = PassthroughSubject<Error, Never>()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(identityID: UUID, appEnvironment: AppEnvironment) throws { init(identityID: UUID, environment: AppEnvironment) throws {
self.appEnvironment = appEnvironment self.environment = environment
observationErrors = observationErrorsInput.eraseToAnyPublisher() observationErrors = observationErrorsInput.eraseToAnyPublisher()
networkClient = MastodonClient(configuration: appEnvironment.URLSessionConfiguration) networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
networkClient.accessToken = try SecretsService( networkClient.accessToken = try SecretsService(
identityID: identityID, identityID: identityID,
keychainService: appEnvironment.keychainService) keychainService: environment.keychainService)
.item(.accessToken) .item(.accessToken)
let observation = appEnvironment.identityDatabase.identityObservation(id: identityID).share() let observation = environment.identityDatabase.identityObservation(id: identityID).share()
var initialIdentity: Identity? var initialIdentity: Identity?
@ -47,11 +47,15 @@ class IdentityService {
extension IdentityService { extension IdentityService {
var isAuthorized: Bool { networkClient.accessToken != nil } var isAuthorized: Bool { networkClient.accessToken != nil }
func updateLastUse() -> AnyPublisher<Void, Error> {
environment.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(appEnvironment.identityDatabase.updateAccount) .flatMap(environment.identityDatabase.updateAccount)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -59,7 +63,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(appEnvironment.identityDatabase.updatePreferences) .flatMap(environment.identityDatabase.updatePreferences)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -67,19 +71,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(appEnvironment.identityDatabase.updateInstance) .flatMap(environment.identityDatabase.updateInstance)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func identitiesObservation() -> AnyPublisher<[Identity], Error> { func identitiesObservation() -> AnyPublisher<[Identity], Error> {
appEnvironment.identityDatabase.identitiesObservation() environment.identityDatabase.identitiesObservation()
} }
func recentIdentitiesObservation() -> AnyPublisher<[Identity], Error> { func recentIdentitiesObservation() -> AnyPublisher<[Identity], Error> {
appEnvironment.identityDatabase.recentIdentitiesObservation(excluding: identity.id) environment.identityDatabase.recentIdentitiesObservation(excluding: identity.id)
} }
func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher<Void, Error> { func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher<Void, Error> {
appEnvironment.identityDatabase.updatePreferences(preferences, forIdentityID: identity.id) environment.identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
} }
} }

View file

@ -9,49 +9,19 @@ 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 environment: AppEnvironment private let authenticationService: AuthenticationService
private let networkClient: MastodonClient
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
private let addedIdentityIDInput = PassthroughSubject<UUID, Never>() private let addedIdentityIDInput = PassthroughSubject<UUID, Never>()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(environment: AppEnvironment) { init(authenticationService: AuthenticationService) {
self.environment = environment self.authenticationService = authenticationService
self.networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher() addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher()
} }
func goTapped() { func goTapped() {
let identityID = UUID() Just(urlFieldText)
let instanceURL: URL .tryMap { try $0.url() }
let redirectURL: URL .flatMap(authenticationService.authenticate(instanceURL:))
do {
instanceURL = try urlFieldText.url()
redirectURL = try identityID.uuidString.url(scheme: MastodonAPI.OAuth.callbackURLScheme)
} catch {
alertItem = AlertItem(error: error)
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)
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.handleEvents( .handleEvents(
@ -61,128 +31,3 @@ class AddIdentityViewModel: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
} }
} }
private extension AddIdentityViewModel {
private func authorizeApp(
identityID: UUID,
instanceURL: URL,
redirectURL: URL,
keychainService: KeychainServiceType) -> AnyPublisher<AppAuthorization, Error> {
let endpoint = AppAuthorizationEndpoint.apps(
clientName: MastodonAPI.OAuth.clientName,
redirectURI: redirectURL.absoluteString,
scopes: MastodonAPI.OAuth.scopes,
website: nil)
let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil)
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
}
.eraseToAnyPublisher()
}
}
private extension Publisher where Output == AppAuthorization {
func authenticationURL(instanceURL: URL, redirectURL: URL) -> AnyPublisher<(AppAuthorization, URL), Error> {
tryMap { appAuthorization in
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
throw URLError(.badURL)
}
authorizationURLComponents.path = "/oauth/authorize"
authorizationURLComponents.queryItems = [
"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 {
throw URLError(.badURL)
}
return (appAuthorization, authorizationURL)
}
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
}
private extension Publisher where Output == (AppAuthorization, URL), Failure == Error {
func authenticate(
webAuthSessionType: WebAuthSessionType.Type,
contextProvider: WebAuthSessionContextProvider,
callbackURLScheme: String) -> AnyPublisher<(AppAuthorization, URL), Error> {
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

@ -4,57 +4,54 @@ import Foundation
import Combine import Combine
class RootViewModel: ObservableObject { class RootViewModel: ObservableObject {
@Published private(set) var identityID: UUID? @Published private(set) var mainNavigationViewModel: MainNavigationViewModel?
private let environment: AppEnvironment
private let identitiesService: IdentitiesService
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(environment: AppEnvironment) { init(identitiesService: IdentitiesService) {
self.environment = environment self.identitiesService = identitiesService
identityID = environment.identityDatabase.mostRecentlyUsedIdentityID
newIdentitySelected(id: identitiesService.mostRecentlyUsedIdentityID)
} }
} }
extension RootViewModel { extension RootViewModel {
func newIdentitySelected(id: UUID) { func newIdentitySelected(id: UUID?) {
identityID = id guard let id = id else {
mainNavigationViewModel = nil
environment.identityDatabase return
.updateLastUsedAt(identityID: id) }
let identityService: IdentityService
do {
identityService = try identitiesService.identityService(id: id)
} catch {
return
}
identityService.observationErrors
.receive(on: RunLoop.main)
.map { [weak self] _ in self?.identitiesService.mostRecentlyUsedIdentityID }
.sink(receiveValue: newIdentitySelected(id:))
.store(in: &cancellables)
identityService.updateLastUse()
.sink(receiveCompletion: { _ in }, receiveValue: {}) .sink(receiveCompletion: { _ in }, receiveValue: {})
.store(in: &cancellables) .store(in: &cancellables)
mainNavigationViewModel = MainNavigationViewModel(identityService: identityService)
} }
func deleteIdentity(id: UUID) { func deleteIdentity(id: UUID) {
environment.identityDatabase.deleteIdentity(id: id) identitiesService.deleteIdentity(id: id)
.continuingIfWeakReferenceIsStillAlive(to: self)
.tryMap {
try SecretsService(
identityID: id,
keychainService: $1.environment.keychainService)
.deleteAllItems()
}
.sink(receiveCompletion: { _ in }, receiveValue: {}) .sink(receiveCompletion: { _ in }, receiveValue: {})
.store(in: &cancellables) .store(in: &cancellables)
} }
func addIdentityViewModel() -> AddIdentityViewModel { func addIdentityViewModel() -> AddIdentityViewModel {
AddIdentityViewModel(environment: environment) AddIdentityViewModel(authenticationService: identitiesService.authenticationService())
}
func mainNavigationViewModel(identityID: UUID) -> MainNavigationViewModel? {
let identityService: IdentityService
do {
identityService = try IdentityService(identityID: identityID, appEnvironment: environment)
} catch {
return nil
}
identityService.observationErrors
.receive(on: RunLoop.main)
.map { [weak self] _ in self?.environment.identityDatabase.mostRecentlyUsedIdentityID }
.assign(to: &$identityID)
return MainNavigationViewModel(identityService: identityService)
} }
} }

View file

@ -60,7 +60,7 @@ private extension View {
#if DEBUG #if DEBUG
struct AddAccountView_Previews: PreviewProvider { struct AddAccountView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
AddIdentityView(viewModel: AddIdentityViewModel(environment: .development)) AddIdentityView(viewModel: .development)
} }
} }
#endif #endif

View file

@ -12,10 +12,9 @@ struct RootView: View {
@StateObject var viewModel: RootViewModel @StateObject var viewModel: RootViewModel
var body: some View { var body: some View {
if let id = viewModel.identityID, if let mainNavigationViewModel = viewModel.mainNavigationViewModel {
let mainNavigationViewModel = viewModel.mainNavigationViewModel(identityID: id) {
Self.mainNavigation(mainNavigationViewModel: mainNavigationViewModel) Self.mainNavigation(mainNavigationViewModel: mainNavigationViewModel)
.id(id) .id(UUID())
.environmentObject(viewModel) .environmentObject(viewModel)
.transition(.opacity) .transition(.opacity)
} else { } else {

View file

@ -8,7 +8,7 @@ import CombineExpectations
class AddIdentityViewModelTests: XCTestCase { class AddIdentityViewModelTests: XCTestCase {
func testAddIdentity() throws { func testAddIdentity() throws {
let environment = AppEnvironment.fresh() let environment = AppEnvironment.fresh()
let sut = AddIdentityViewModel(environment: environment) let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
let addedIDRecorder = sut.addedIdentityID.record() let addedIDRecorder = sut.addedIdentityID.record()
sut.urlFieldText = "https://mastodon.social" sut.urlFieldText = "https://mastodon.social"
@ -33,7 +33,7 @@ class AddIdentityViewModelTests: XCTestCase {
func testAddIdentityWithoutScheme() throws { func testAddIdentityWithoutScheme() throws {
let environment = AppEnvironment.fresh() let environment = AppEnvironment.fresh()
let sut = AddIdentityViewModel(environment: environment) let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
let addedIDRecorder = sut.addedIdentityID.record() let addedIDRecorder = sut.addedIdentityID.record()
sut.urlFieldText = "mastodon.social" sut.urlFieldText = "mastodon.social"
@ -47,7 +47,7 @@ class AddIdentityViewModelTests: XCTestCase {
} }
func testInvalidURL() throws { func testInvalidURL() throws {
let sut = AddIdentityViewModel(environment: .fresh()) let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: .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))
@ -62,7 +62,7 @@ class AddIdentityViewModelTests: XCTestCase {
func testDoesNotAlertCanceledLogin() throws { func testDoesNotAlertCanceledLogin() throws {
let environment = AppEnvironment.fresh(webAuthSessionType: CanceledLoginMockWebAuthSession.self) let environment = AppEnvironment.fresh(webAuthSessionType: CanceledLoginMockWebAuthSession.self)
let sut = AddIdentityViewModel(environment: environment) let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
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))

View file

@ -9,8 +9,10 @@ class RootViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>() var cancellables = Set<AnyCancellable>()
func testAddIdentity() throws { func testAddIdentity() throws {
let sut = RootViewModel(environment: .fresh()) let environment = AppEnvironment.fresh()
let recorder = sut.$identityID.record() let sut = RootViewModel(
identitiesService: IdentitiesService(environment: environment))
let recorder = sut.$mainNavigationViewModel.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
@ -23,9 +25,8 @@ class RootViewModelTests: XCTestCase {
addIdentityViewModel.urlFieldText = "https://mastodon.social" addIdentityViewModel.urlFieldText = "https://mastodon.social"
addIdentityViewModel.goTapped() addIdentityViewModel.goTapped()
let identityID = try wait(for: recorder.next(), timeout: 1)! let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)!
XCTAssertNotNil(identityID) XCTAssertNotNil(mainNavigationViewModel)
XCTAssertNotNil(sut.mainNavigationViewModel(identityID: identityID))
} }
} }