mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 09:41:00 +00:00
Add anonymous instance, lots of refactoring
This commit is contained in:
parent
3995726a45
commit
96fdbad7b9
16 changed files with 235 additions and 296 deletions
|
@ -23,18 +23,6 @@ let developmentKeychainService: KeychainServiceType = {
|
|||
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 {
|
||||
static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8))
|
||||
}
|
||||
|
@ -69,30 +57,26 @@ extension IdentityDatabase {
|
|||
}
|
||||
|
||||
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(
|
||||
URLSessionConfiguration: .stubbing,
|
||||
identityDatabase: .development,
|
||||
defaults: .development,
|
||||
keychainService: developmentKeychainService,
|
||||
webAuthSessionType: SuccessfulMockWebAuthSession.self)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
@ -57,8 +57,6 @@
|
|||
D052BBC724D749C800A80A7A /* RootViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBC624D749C800A80A7A /* RootViewModelTests.swift */; };
|
||||
D052BBCA24D74C9200A80A7A /* 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 */; };
|
||||
D052BBD224D750CB00A80A7A /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D052BBCC24D750A100A80A7A /* AppEnvironment.swift */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -446,7 +443,6 @@
|
|||
D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */,
|
||||
D052BBCC24D750A100A80A7A /* AppEnvironment.swift */,
|
||||
D0ED1BD624CF94B200B4899C /* Application.swift */,
|
||||
D052BBCE24D750C000A80A7A /* Defaults.swift */,
|
||||
D0666A5324C6C3E500F3F04B /* Emoji.swift */,
|
||||
D0666A4A24C6C37700F3F04B /* Identity.swift */,
|
||||
D0666A4D24C6C39600F3F04B /* Instance.swift */,
|
||||
|
@ -778,7 +774,6 @@
|
|||
D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */,
|
||||
D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */,
|
||||
D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
|
||||
D052BBCF24D750C000A80A7A /* Defaults.swift in Sources */,
|
||||
D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */,
|
||||
D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
|
||||
D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */,
|
||||
|
@ -859,7 +854,6 @@
|
|||
D0159FA624DE98F600E78478 /* NSMutableAttributedString+Extensions.swift in Sources */,
|
||||
D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */,
|
||||
D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */,
|
||||
D052BBD024D750C000A80A7A /* Defaults.swift in Sources */,
|
||||
D0ED1BE424CFA84400B4899C /* MastodonError.swift in Sources */,
|
||||
D0666A6424C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
|
||||
D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */,
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
"go" = "Go";
|
||||
"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";
|
||||
"secondary-navigation.manage-accounts" = "Manage Accounts";
|
||||
"secondary-navigation.preferences" = "Preferences";
|
||||
|
|
|
@ -4,28 +4,27 @@ import SwiftUI
|
|||
|
||||
@main
|
||||
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() {
|
||||
let identityDatabase: IdentityDatabase
|
||||
|
||||
do {
|
||||
try identityDatabase = IdentityDatabase()
|
||||
} catch {
|
||||
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 {
|
||||
WindowGroup {
|
||||
RootView(viewModel: RootViewModel(identitiesService: IdentitiesService(environment: environment)))
|
||||
RootView(
|
||||
viewModel: RootViewModel(identitiesService: IdentitiesService(
|
||||
identityDatabase: identityDatabase,
|
||||
keychainService: keychainServive,
|
||||
environment: environment)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,8 +4,5 @@ import Foundation
|
|||
|
||||
struct AppEnvironment {
|
||||
let URLSessionConfiguration: URLSessionConfiguration
|
||||
let identityDatabase: IdentityDatabase
|
||||
let defaults: Defaults
|
||||
let keychainService: KeychainServiceType
|
||||
let webAuthSessionType: WebAuthSessionType.Type
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -4,25 +4,4 @@ import Foundation
|
|||
|
||||
struct MastodonAPI {
|
||||
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: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,168 +4,109 @@ import Foundation
|
|||
import Combine
|
||||
|
||||
struct AuthenticationService {
|
||||
private let environment: AppEnvironment
|
||||
private let networkClient: MastodonClient
|
||||
private let webAuthSessionType: WebAuthSessionType.Type
|
||||
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
|
||||
webAuthSessionType = environment.webAuthSessionType
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
func authorizeApp(instanceURL: URL) -> AnyPublisher<AppAuthorization, Error> {
|
||||
let endpoint = AppAuthorizationEndpoint.apps(
|
||||
clientName: MastodonAPI.OAuth.clientName,
|
||||
redirectURI: redirectURL.absoluteString,
|
||||
scopes: MastodonAPI.OAuth.scopes,
|
||||
website: nil)
|
||||
clientName: OAuth.clientName,
|
||||
redirectURI: OAuth.callbackURL.absoluteString,
|
||||
scopes: OAuth.scopes,
|
||||
website: OAuth.website)
|
||||
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
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
private extension AuthenticationService {
|
||||
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"
|
||||
static let callbackURL = URL(string: "\(callbackURLScheme)://oauth.callback")!
|
||||
static let website = URL(string: "https://metabolist.com/metatext")!
|
||||
}
|
||||
|
||||
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) }
|
||||
enum OAuthError {
|
||||
case codeNotFound
|
||||
}
|
||||
|
||||
guard let authorizationURL = authorizationURLComponents.url else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
return (appAuthorization, authorizationURL)
|
||||
private func authorizationURL(instanceURL: URL, clientID: String) -> URL? {
|
||||
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
|
||||
return nil
|
||||
}
|
||||
.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 {
|
||||
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) }
|
||||
extension AuthenticationService.OAuthError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .codeNotFound:
|
||||
return NSLocalizedString("oauth.error.code-not-found", comment: "")
|
||||
}
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,12 +6,16 @@ import Combine
|
|||
class IdentitiesService {
|
||||
@Published var mostRecentlyUsedIdentityID: UUID?
|
||||
|
||||
private let identityDatabase: IdentityDatabase
|
||||
private let keychainService: KeychainServiceType
|
||||
private let environment: AppEnvironment
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
init(identityDatabase: IdentityDatabase, keychainService: KeychainServiceType, environment: AppEnvironment) {
|
||||
self.identityDatabase = identityDatabase
|
||||
self.keychainService = keychainService
|
||||
self.environment = environment
|
||||
|
||||
environment.identityDatabase.mostRecentlyUsedIdentityIDObservation()
|
||||
identityDatabase.mostRecentlyUsedIdentityIDObservation()
|
||||
.replaceError(with: nil)
|
||||
.assign(to: &$mostRecentlyUsedIdentityID)
|
||||
}
|
||||
|
@ -19,20 +23,43 @@ class IdentitiesService {
|
|||
|
||||
extension IdentitiesService {
|
||||
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 {
|
||||
AuthenticationService(environment: environment)
|
||||
func createIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Void, Error> {
|
||||
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> {
|
||||
environment.identityDatabase.deleteIdentity(id: id)
|
||||
identityDatabase.deleteIdentity(id: id)
|
||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||
.tryMap { _, welf -> Void in
|
||||
try SecretsService(
|
||||
identityID: id,
|
||||
keychainService: welf.environment.keychainService)
|
||||
keychainService: welf.keychainService)
|
||||
.deleteAllItems()
|
||||
|
||||
return ()
|
||||
|
|
|
@ -7,15 +7,20 @@ class IdentityService {
|
|||
@Published private(set) var identity: Identity
|
||||
let observationErrors: AnyPublisher<Error, Never>
|
||||
|
||||
private let networkClient: MastodonClient
|
||||
private let identityDatabase: IdentityDatabase
|
||||
private let environment: AppEnvironment
|
||||
private let networkClient: MastodonClient
|
||||
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
|
||||
observationErrors = observationErrorsInput.eraseToAnyPublisher()
|
||||
|
||||
let observation = environment.identityDatabase.identityObservation(id: identityID).share()
|
||||
let observation = identityDatabase.identityObservation(id: identityID).share()
|
||||
var initialIdentity: Identity?
|
||||
|
||||
_ = observation.first().sink(
|
||||
|
@ -29,7 +34,7 @@ class IdentityService {
|
|||
networkClient.instanceURL = identity.url
|
||||
networkClient.accessToken = try SecretsService(
|
||||
identityID: identityID,
|
||||
keychainService: environment.keychainService)
|
||||
keychainService: keychainService)
|
||||
.item(.accessToken)
|
||||
|
||||
observation.catch { [weak self] error -> Empty<Identity, Never> in
|
||||
|
@ -45,14 +50,14 @@ extension IdentityService {
|
|||
var isAuthorized: Bool { networkClient.accessToken != nil }
|
||||
|
||||
func updateLastUse() -> AnyPublisher<Void, Error> {
|
||||
environment.identityDatabase.updateLastUsedAt(identityID: identity.id)
|
||||
identityDatabase.updateLastUsedAt(identityID: identity.id)
|
||||
}
|
||||
|
||||
func verifyCredentials() -> AnyPublisher<Void, Error> {
|
||||
networkClient.request(AccountEndpoint.verifyCredentials)
|
||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||
.map { ($0, $1.identity.id) }
|
||||
.flatMap(environment.identityDatabase.updateAccount)
|
||||
.flatMap(identityDatabase.updateAccount)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
@ -60,7 +65,7 @@ extension IdentityService {
|
|||
networkClient.request(PreferencesEndpoint.preferences)
|
||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||
.map { ($1.identity.preferences.updated(from: $0), $1.identity.id) }
|
||||
.flatMap(environment.identityDatabase.updatePreferences)
|
||||
.flatMap(identityDatabase.updatePreferences)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
@ -68,19 +73,19 @@ extension IdentityService {
|
|||
networkClient.request(InstanceEndpoint.instance)
|
||||
.continuingIfWeakReferenceIsStillAlive(to: self)
|
||||
.map { ($0, $1.identity.id) }
|
||||
.flatMap(environment.identityDatabase.updateInstance)
|
||||
.flatMap(identityDatabase.updateInstance)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func identitiesObservation() -> AnyPublisher<[Identity], Error> {
|
||||
environment.identityDatabase.identitiesObservation()
|
||||
identityDatabase.identitiesObservation()
|
||||
}
|
||||
|
||||
func recentIdentitiesObservation() -> AnyPublisher<[Identity], Error> {
|
||||
environment.identityDatabase.recentIdentitiesObservation(excluding: identity.id)
|
||||
identityDatabase.recentIdentitiesObservation(excluding: identity.id)
|
||||
}
|
||||
|
||||
func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher<Void, Error> {
|
||||
environment.identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
|
||||
identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,19 +9,31 @@ class AddIdentityViewModel: ObservableObject {
|
|||
@Published private(set) var loading = false
|
||||
let addedIdentityID: AnyPublisher<UUID, Never>
|
||||
|
||||
private let authenticationService: AuthenticationService
|
||||
private let identitiesService: IdentitiesService
|
||||
private let addedIdentityIDInput = PassthroughSubject<UUID, Never>()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(authenticationService: AuthenticationService) {
|
||||
self.authenticationService = authenticationService
|
||||
init(identitiesService: IdentitiesService) {
|
||||
self.identitiesService = identitiesService
|
||||
addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func goTapped() {
|
||||
Just(urlFieldText)
|
||||
.tryMap { try $0.url() }
|
||||
.flatMap(authenticationService.authenticate(instanceURL:))
|
||||
func logInTapped() {
|
||||
let identityID = UUID()
|
||||
let instanceURL: URL
|
||||
|
||||
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)
|
||||
.receive(on: RunLoop.main)
|
||||
.handleEvents(
|
||||
|
@ -30,4 +42,23 @@ class AddIdentityViewModel: ObservableObject {
|
|||
.sink(receiveValue: addedIdentityIDInput.send)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,6 @@ extension RootViewModel {
|
|||
}
|
||||
|
||||
func addIdentityViewModel() -> AddIdentityViewModel {
|
||||
AddIdentityViewModel(authenticationService: identitiesService.authenticationService())
|
||||
AddIdentityViewModel(identitiesService: identitiesService)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,12 +21,13 @@ struct AddIdentityView: View {
|
|||
if viewModel.loading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button(
|
||||
action: viewModel.goTapped,
|
||||
label: { Text("go") })
|
||||
Button("add-identity.log-in",
|
||||
action: viewModel.logInTapped)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
#endif
|
||||
|
|
|
@ -6,26 +6,21 @@ import CombineExpectations
|
|||
@testable import Metatext
|
||||
|
||||
class AuthenticationServiceTests: XCTestCase {
|
||||
func testAddIdentity() throws {
|
||||
let environment = AppEnvironment.fresh()
|
||||
let sut = AuthenticationService(environment: environment)
|
||||
func testAuthentication() throws {
|
||||
let sut = AuthenticationService(environment: .development)
|
||||
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)
|
||||
let identityRecorder = environment.identityDatabase.identityObservation(id: addedIdentityID).record()
|
||||
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
|
||||
XCTAssertEqual(appAuthorization.clientId, "AUTHORIZATION_CLIENT_ID_STUB_VALUE")
|
||||
XCTAssertEqual(appAuthorization.clientSecret, "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE")
|
||||
|
||||
XCTAssertEqual(addedIdentity.id, addedIdentityID)
|
||||
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
|
||||
let accessTokenRecorder = sut.authenticate(
|
||||
instanceURL: instanceURL,
|
||||
appAuthorization: appAuthorization)
|
||||
.record()
|
||||
let accessToken = try wait(for: accessTokenRecorder.next(), timeout: 1)
|
||||
|
||||
let secretsService = SecretsService(identityID: addedIdentity.id, keychainService: environment.keychainService)
|
||||
|
||||
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")
|
||||
XCTAssertEqual(accessToken.accessToken, "ACCESS_TOKEN_STUB_VALUE")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,43 +7,45 @@ import CombineExpectations
|
|||
|
||||
class AddIdentityViewModelTests: XCTestCase {
|
||||
func testAddIdentity() throws {
|
||||
let environment = AppEnvironment.fresh()
|
||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
|
||||
let identityDatabase = IdentityDatabase.fresh()
|
||||
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
|
||||
let addedIDRecorder = sut.addedIdentityID.record()
|
||||
|
||||
sut.urlFieldText = "https://mastodon.social"
|
||||
sut.goTapped()
|
||||
sut.logInTapped()
|
||||
|
||||
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)
|
||||
|
||||
XCTAssertEqual(addedIdentity.id, addedIdentityID)
|
||||
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
|
||||
}
|
||||
|
||||
func testAddIdentityWithoutScheme() throws {
|
||||
let environment = AppEnvironment.fresh()
|
||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
|
||||
let identityDatabase = IdentityDatabase.fresh()
|
||||
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
|
||||
let addedIDRecorder = sut.addedIdentityID.record()
|
||||
|
||||
sut.urlFieldText = "mastodon.social"
|
||||
sut.goTapped()
|
||||
sut.logInTapped()
|
||||
|
||||
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)
|
||||
|
||||
XCTAssertEqual(addedIdentity.id, addedIdentityID)
|
||||
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
|
||||
}
|
||||
|
||||
func testInvalidURL() throws {
|
||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: .fresh()))
|
||||
let sut = AddIdentityViewModel(identitiesService: .fresh())
|
||||
let recorder = sut.$alertItem.record()
|
||||
|
||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||
|
||||
sut.urlFieldText = "🐘.social"
|
||||
sut.goTapped()
|
||||
sut.logInTapped()
|
||||
|
||||
let alertItem = try wait(for: recorder.next(), timeout: 1)
|
||||
|
||||
|
@ -51,14 +53,20 @@ class AddIdentityViewModelTests: XCTestCase {
|
|||
}
|
||||
|
||||
func testDoesNotAlertCanceledLogin() throws {
|
||||
let environment = AppEnvironment.fresh(webAuthSessionType: CanceledLoginMockWebAuthSession.self)
|
||||
let sut = AddIdentityViewModel(authenticationService: AuthenticationService(environment: environment))
|
||||
let environment = AppEnvironment(
|
||||
URLSessionConfiguration: .stubbing,
|
||||
webAuthSessionType: CanceledLoginMockWebAuthSession.self)
|
||||
let identitiesService = IdentitiesService(
|
||||
identityDatabase: .fresh(),
|
||||
keychainService: MockKeychainService(),
|
||||
environment: environment)
|
||||
let sut = AddIdentityViewModel(identitiesService: identitiesService)
|
||||
let recorder = sut.$alertItem.record()
|
||||
|
||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||
|
||||
sut.urlFieldText = "https://mastodon.social"
|
||||
sut.goTapped()
|
||||
sut.logInTapped()
|
||||
|
||||
try wait(for: recorder.next().inverted, timeout: 1)
|
||||
}
|
||||
|
|
|
@ -9,9 +9,10 @@ class RootViewModelTests: XCTestCase {
|
|||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
func testAddIdentity() throws {
|
||||
let environment = AppEnvironment.fresh()
|
||||
let sut = RootViewModel(
|
||||
identitiesService: IdentitiesService(environment: environment))
|
||||
let sut = RootViewModel(identitiesService: IdentitiesService(
|
||||
identityDatabase: .fresh(),
|
||||
keychainService: MockKeychainService(),
|
||||
environment: .development))
|
||||
let recorder = sut.$mainNavigationViewModel.record()
|
||||
|
||||
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
|
||||
|
@ -23,7 +24,7 @@ class RootViewModelTests: XCTestCase {
|
|||
.store(in: &cancellables)
|
||||
|
||||
addIdentityViewModel.urlFieldText = "https://mastodon.social"
|
||||
addIdentityViewModel.goTapped()
|
||||
addIdentityViewModel.logInTapped()
|
||||
|
||||
let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)!
|
||||
|
||||
|
|
Loading…
Reference in a new issue