mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 09:41:00 +00:00
Refactoring
This commit is contained in:
parent
6dfda031a6
commit
54f8c82fcd
13 changed files with 310 additions and 227 deletions
|
@ -91,16 +91,24 @@ extension AppEnvironment {
|
|||
webAuthSessionType: SuccessfulMockWebAuthSession.self)
|
||||
}
|
||||
|
||||
extension IdentitiesService {
|
||||
static let development = IdentitiesService(environment: .development)
|
||||
}
|
||||
|
||||
extension IdentityService {
|
||||
static let development = try! IdentityService(identityID: devIdentityID, appEnvironment: .development)
|
||||
static let development = try! IdentitiesService.development.identityService(id: devIdentityID)
|
||||
}
|
||||
|
||||
extension RootViewModel {
|
||||
static let development = RootViewModel(environment: .development)
|
||||
static let development = RootViewModel(identitiesService: .development)
|
||||
}
|
||||
|
||||
extension AddIdentityViewModel {
|
||||
static let development = RootViewModel.development.addIdentityViewModel()
|
||||
}
|
||||
|
||||
extension MainNavigationViewModel {
|
||||
static let development = RootViewModel.development.mainNavigationViewModel(identityID: devIdentityID)!
|
||||
static let development = RootViewModel.development.mainNavigationViewModel!
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
|
|
@ -140,6 +140,10 @@
|
|||
D0EC8DC624DF842700A08489 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC424DF842700A08489 /* KeychainService.swift */; };
|
||||
D0EC8DC824DF8B3C00A08489 /* 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 */; };
|
||||
D0ED1BB724CE47F400B4899C /* 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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -349,6 +355,8 @@
|
|||
D019E6F224DF7C9E00697C7D /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */,
|
||||
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */,
|
||||
D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */,
|
||||
D0EC8DC424DF842700A08489 /* KeychainService.swift */,
|
||||
D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */,
|
||||
|
@ -796,6 +804,7 @@
|
|||
D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */,
|
||||
D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */,
|
||||
D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */,
|
||||
D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */,
|
||||
D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */,
|
||||
D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */,
|
||||
D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
|
||||
|
@ -814,6 +823,7 @@
|
|||
D0EC8DC824DF8B3C00A08489 /* SecretsService.swift in Sources */,
|
||||
D0159FA324DE955900E78478 /* CustomEmojiText.swift in Sources */,
|
||||
D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */,
|
||||
D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */,
|
||||
D04FD73C24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift in Sources */,
|
||||
D0DC175B24D0154F00A75C65 /* MastodonAPI.swift in Sources */,
|
||||
D0ED1BD124CF779B00B4899C /* MastodonTarget.swift in Sources */,
|
||||
|
@ -849,6 +859,7 @@
|
|||
D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */,
|
||||
D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */,
|
||||
D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */,
|
||||
D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */,
|
||||
D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */,
|
||||
D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */,
|
||||
D0A652AE24DE3EB6002EA33F /* PreferencesEndpoint+Stubbing.swift in Sources */,
|
||||
|
@ -875,6 +886,7 @@
|
|||
D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */,
|
||||
D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */,
|
||||
D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
|
||||
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */,
|
||||
D0159F9C24DE748C00E78478 /* SidebarNavigationView.swift in Sources */,
|
||||
D019E6F124DF7C2F00697C7D /* DatabaseError.swift in Sources */,
|
||||
D074577824D29006004758DB /* MockWebAuthSession.swift in Sources */,
|
||||
|
|
|
@ -138,8 +138,11 @@ extension IdentityDatabase {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var mostRecentlyUsedIdentityID: UUID? {
|
||||
try? databaseQueue.read(StoredIdentity.select(Column("id")).order(Column("lastUsedAt").desc).fetchOne)
|
||||
func mostRecentlyUsedIdentityIDObservation() -> AnyPublisher<UUID?, Error> {
|
||||
ValueObservation.tracking(StoredIdentity.select(Column("id")).order(Column("lastUsedAt").desc).fetchOne)
|
||||
.removeDuplicates()
|
||||
.publisher(in: databaseQueue, scheduling: .immediate)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@ struct MetatextApp: App {
|
|||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(viewModel: RootViewModel(environment: environment))
|
||||
RootView(viewModel: RootViewModel(identitiesService: IdentitiesService(environment: environment)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
171
Shared/Services/AuthenticationService.swift
Normal file
171
Shared/Services/AuthenticationService.swift
Normal 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()
|
||||
}
|
||||
}
|
43
Shared/Services/IdentitiesService.swift
Normal file
43
Shared/Services/IdentitiesService.swift
Normal 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()
|
||||
}
|
||||
}
|
|
@ -8,20 +8,20 @@ class IdentityService {
|
|||
let observationErrors: AnyPublisher<Error, Never>
|
||||
|
||||
private let networkClient: MastodonClient
|
||||
private let appEnvironment: AppEnvironment
|
||||
private let environment: AppEnvironment
|
||||
private let observationErrorsInput = PassthroughSubject<Error, Never>()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(identityID: UUID, appEnvironment: AppEnvironment) throws {
|
||||
self.appEnvironment = appEnvironment
|
||||
init(identityID: UUID, environment: AppEnvironment) throws {
|
||||
self.environment = environment
|
||||
observationErrors = observationErrorsInput.eraseToAnyPublisher()
|
||||
networkClient = MastodonClient(configuration: appEnvironment.URLSessionConfiguration)
|
||||
networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
|
||||
networkClient.accessToken = try SecretsService(
|
||||
identityID: identityID,
|
||||
keychainService: appEnvironment.keychainService)
|
||||
keychainService: environment.keychainService)
|
||||
.item(.accessToken)
|
||||
|
||||
let observation = appEnvironment.identityDatabase.identityObservation(id: identityID).share()
|
||||
let observation = environment.identityDatabase.identityObservation(id: identityID).share()
|
||||
|
||||
var initialIdentity: Identity?
|
||||
|
||||
|
@ -47,11 +47,15 @@ class IdentityService {
|
|||
extension IdentityService {
|
||||
var isAuthorized: Bool { networkClient.accessToken != nil }
|
||||
|
||||
func updateLastUse() -> AnyPublisher<Void, Error> {
|
||||
environment.identityDatabase.updateLastUsedAt(identityID: identity.id)
|
||||
}
|
||||
|
||||
func verifyCredentials() -> AnyPublisher<Void, Error> {
|
||||
networkClient.request(AccountEndpoint.verifyCredentials)
|
||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||
.map { ($0, $1.identity.id) }
|
||||
.flatMap(appEnvironment.identityDatabase.updateAccount)
|
||||
.flatMap(environment.identityDatabase.updateAccount)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
@ -59,7 +63,7 @@ extension IdentityService {
|
|||
networkClient.request(PreferencesEndpoint.preferences)
|
||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||
.map { ($1.identity.preferences.updated(from: $0), $1.identity.id) }
|
||||
.flatMap(appEnvironment.identityDatabase.updatePreferences)
|
||||
.flatMap(environment.identityDatabase.updatePreferences)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
@ -67,19 +71,19 @@ extension IdentityService {
|
|||
networkClient.request(InstanceEndpoint.instance)
|
||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||
.map { ($0, $1.identity.id) }
|
||||
.flatMap(appEnvironment.identityDatabase.updateInstance)
|
||||
.flatMap(environment.identityDatabase.updateInstance)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func identitiesObservation() -> AnyPublisher<[Identity], Error> {
|
||||
appEnvironment.identityDatabase.identitiesObservation()
|
||||
environment.identityDatabase.identitiesObservation()
|
||||
}
|
||||
|
||||
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> {
|
||||
appEnvironment.identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
|
||||
environment.identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,49 +9,19 @@ class AddIdentityViewModel: ObservableObject {
|
|||
@Published private(set) var loading = false
|
||||
let addedIdentityID: AnyPublisher<UUID, Never>
|
||||
|
||||
private let environment: AppEnvironment
|
||||
private let networkClient: MastodonClient
|
||||
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
|
||||
private let authenticationService: AuthenticationService
|
||||
private let addedIdentityIDInput = PassthroughSubject<UUID, Never>()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
self.networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
|
||||
init(authenticationService: AuthenticationService) {
|
||||
self.authenticationService = authenticationService
|
||||
addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func goTapped() {
|
||||
let identityID = UUID()
|
||||
let instanceURL: URL
|
||||
let redirectURL: URL
|
||||
|
||||
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)
|
||||
Just(urlFieldText)
|
||||
.tryMap { try $0.url() }
|
||||
.flatMap(authenticationService.authenticate(instanceURL:))
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.receive(on: RunLoop.main)
|
||||
.handleEvents(
|
||||
|
@ -61,128 +31,3 @@ class AddIdentityViewModel: ObservableObject {
|
|||
.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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,57 +4,54 @@ import Foundation
|
|||
import Combine
|
||||
|
||||
class RootViewModel: ObservableObject {
|
||||
@Published private(set) var identityID: UUID?
|
||||
private let environment: AppEnvironment
|
||||
@Published private(set) var mainNavigationViewModel: MainNavigationViewModel?
|
||||
|
||||
private let identitiesService: IdentitiesService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
identityID = environment.identityDatabase.mostRecentlyUsedIdentityID
|
||||
init(identitiesService: IdentitiesService) {
|
||||
self.identitiesService = identitiesService
|
||||
|
||||
newIdentitySelected(id: identitiesService.mostRecentlyUsedIdentityID)
|
||||
}
|
||||
}
|
||||
|
||||
extension RootViewModel {
|
||||
func newIdentitySelected(id: UUID) {
|
||||
identityID = id
|
||||
func newIdentitySelected(id: UUID?) {
|
||||
guard let id = id else {
|
||||
mainNavigationViewModel = nil
|
||||
|
||||
environment.identityDatabase
|
||||
.updateLastUsedAt(identityID: id)
|
||||
return
|
||||
}
|
||||
|
||||
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: {})
|
||||
.store(in: &cancellables)
|
||||
|
||||
mainNavigationViewModel = MainNavigationViewModel(identityService: identityService)
|
||||
}
|
||||
|
||||
func deleteIdentity(id: UUID) {
|
||||
environment.identityDatabase.deleteIdentity(id: id)
|
||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||
.tryMap {
|
||||
try SecretsService(
|
||||
identityID: id,
|
||||
keychainService: $1.environment.keychainService)
|
||||
.deleteAllItems()
|
||||
}
|
||||
identitiesService.deleteIdentity(id: id)
|
||||
.sink(receiveCompletion: { _ in }, receiveValue: {})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func addIdentityViewModel() -> AddIdentityViewModel {
|
||||
AddIdentityViewModel(environment: environment)
|
||||
}
|
||||
|
||||
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)
|
||||
AddIdentityViewModel(authenticationService: identitiesService.authenticationService())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ private extension View {
|
|||
#if DEBUG
|
||||
struct AddAccountView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddIdentityView(viewModel: AddIdentityViewModel(environment: .development))
|
||||
AddIdentityView(viewModel: .development)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -12,10 +12,9 @@ struct RootView: View {
|
|||
@StateObject var viewModel: RootViewModel
|
||||
|
||||
var body: some View {
|
||||
if let id = viewModel.identityID,
|
||||
let mainNavigationViewModel = viewModel.mainNavigationViewModel(identityID: id) {
|
||||
if let mainNavigationViewModel = viewModel.mainNavigationViewModel {
|
||||
Self.mainNavigation(mainNavigationViewModel: mainNavigationViewModel)
|
||||
.id(id)
|
||||
.id(UUID())
|
||||
.environmentObject(viewModel)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
|
|
|
@ -8,7 +8,7 @@ import CombineExpectations
|
|||
class AddIdentityViewModelTests: XCTestCase {
|
||||
func testAddIdentity() throws {
|
||||
let environment = AppEnvironment.fresh()
|
||||
let sut = AddIdentityViewModel(environment: environment)
|
||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
|
||||
let addedIDRecorder = sut.addedIdentityID.record()
|
||||
|
||||
sut.urlFieldText = "https://mastodon.social"
|
||||
|
@ -33,7 +33,7 @@ class AddIdentityViewModelTests: XCTestCase {
|
|||
|
||||
func testAddIdentityWithoutScheme() throws {
|
||||
let environment = AppEnvironment.fresh()
|
||||
let sut = AddIdentityViewModel(environment: environment)
|
||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
|
||||
let addedIDRecorder = sut.addedIdentityID.record()
|
||||
|
||||
sut.urlFieldText = "mastodon.social"
|
||||
|
@ -47,7 +47,7 @@ class AddIdentityViewModelTests: XCTestCase {
|
|||
}
|
||||
|
||||
func testInvalidURL() throws {
|
||||
let sut = AddIdentityViewModel(environment: .fresh())
|
||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: .fresh()))
|
||||
let recorder = sut.$alertItem.record()
|
||||
|
||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||
|
@ -62,7 +62,7 @@ class AddIdentityViewModelTests: XCTestCase {
|
|||
|
||||
func testDoesNotAlertCanceledLogin() throws {
|
||||
let environment = AppEnvironment.fresh(webAuthSessionType: CanceledLoginMockWebAuthSession.self)
|
||||
let sut = AddIdentityViewModel(environment: environment)
|
||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
|
||||
let recorder = sut.$alertItem.record()
|
||||
|
||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||
|
|
|
@ -9,8 +9,10 @@ class RootViewModelTests: XCTestCase {
|
|||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
func testAddIdentity() throws {
|
||||
let sut = RootViewModel(environment: .fresh())
|
||||
let recorder = sut.$identityID.record()
|
||||
let environment = AppEnvironment.fresh()
|
||||
let sut = RootViewModel(
|
||||
identitiesService: IdentitiesService(environment: environment))
|
||||
let recorder = sut.$mainNavigationViewModel.record()
|
||||
|
||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||
|
||||
|
@ -23,9 +25,8 @@ class RootViewModelTests: XCTestCase {
|
|||
addIdentityViewModel.urlFieldText = "https://mastodon.social"
|
||||
addIdentityViewModel.goTapped()
|
||||
|
||||
let identityID = try wait(for: recorder.next(), timeout: 1)!
|
||||
let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)!
|
||||
|
||||
XCTAssertNotNil(identityID)
|
||||
XCTAssertNotNil(sut.mainNavigationViewModel(identityID: identityID))
|
||||
XCTAssertNotNil(mainNavigationViewModel)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue