diff --git a/.gitignore b/.gitignore index c545112..99f1f9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ xcuserdata/ +*.xcodeproj/xcuserdata diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..780afe7 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,2 @@ +disabled_rules: + identifier_name diff --git a/Development Assets/FakeKeychain.swift b/Development Assets/FakeKeychain.swift new file mode 100644 index 0000000..467f182 --- /dev/null +++ b/Development Assets/FakeKeychain.swift @@ -0,0 +1,19 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +typealias FakeKeychain = [String: Data] + +extension FakeKeychain: KeychainType { + mutating func set(data: Data, forKey key: String) throws { + self[key] = data + } + + mutating func deleteData(key: String) throws { + self[key] = nil + } + + func getData(key: String) throws -> Data? { + self[key] + } +} diff --git a/Development Assets/HTTPStubs.swift b/Development Assets/HTTPStubs.swift new file mode 100644 index 0000000..b18aae3 --- /dev/null +++ b/Development Assets/HTTPStubs.swift @@ -0,0 +1,16 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct HTTPStubs { + static func stub( + request: URLRequest, + target: HTTPTarget? = nil, + userInfo: [String: Any] = [:]) -> HTTPStub? { + guard let url = request.url else { + return nil + } + + return (target as? Stubbing)?.stub(url: url) + } +} diff --git a/Development Assets/Mastodon API Stubs/AccessTokenEndpoint+Stubbing.swift b/Development Assets/Mastodon API Stubs/AccessTokenEndpoint+Stubbing.swift new file mode 100644 index 0000000..ce5516c --- /dev/null +++ b/Development Assets/Mastodon API Stubs/AccessTokenEndpoint+Stubbing.swift @@ -0,0 +1,19 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +extension AccessTokenEndpoint: Stubbing { + func dataString(url: URL) -> String? { + switch self { + case let .oauthToken(_, _, _, _, scopes, _): + return """ + { + "access_token": "ACCESS_TOKEN_STUB_VALUE", + "token_type": "Bearer", + "scope": "\(scopes)", + "created_at": "\(Int(Date().timeIntervalSince1970))" + } + """ + } + } +} diff --git a/Development Assets/Mastodon API Stubs/AppAuthorizationEndpoint+Stubbing.swift b/Development Assets/Mastodon API Stubs/AppAuthorizationEndpoint+Stubbing.swift new file mode 100644 index 0000000..9cb1d6e --- /dev/null +++ b/Development Assets/Mastodon API Stubs/AppAuthorizationEndpoint+Stubbing.swift @@ -0,0 +1,22 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +extension AppAuthorizationEndpoint: Stubbing { + func dataString(url: URL) -> String? { + switch self { + case let .apps(clientName, redirectURI, _, _): + return """ + { + "id": "\(Int.random(in: 100000...999999))", + "name": "\(clientName)", + "website": null, + "redirect_uri": "\(redirectURI)", + "client_id": "AUTHORIZATION_CLIENT_ID_STUB_VALUE", + "client_secret": "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE", + "vapid_key": "AUTHORIZATION_VAPID_KEY_STUB_VALUE" + } + """ + } + } +} diff --git a/Development Assets/Mastodon API Stubs/MastodonTarget+Stubbing.swift b/Development Assets/Mastodon API Stubs/MastodonTarget+Stubbing.swift new file mode 100644 index 0000000..fee53ca --- /dev/null +++ b/Development Assets/Mastodon API Stubs/MastodonTarget+Stubbing.swift @@ -0,0 +1,21 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +extension MastodonTarget: Stubbing { + func stub(url: URL) -> HTTPStub? { + (endpoint as? Stubbing)?.stub(url: url) + } + + func data(url: URL) -> Data? { + (endpoint as? Stubbing)?.data(url: url) + } + + func dataString(url: URL) -> String? { + (endpoint as? Stubbing)?.dataString(url: url) + } + + func statusCode(url: URL) -> Int? { + (endpoint as? Stubbing)?.statusCode(url: url) + } +} diff --git a/Development Assets/Stubbing.swift b/Development Assets/Stubbing.swift new file mode 100644 index 0000000..c0e17e3 --- /dev/null +++ b/Development Assets/Stubbing.swift @@ -0,0 +1,34 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +typealias HTTPStub = Result<(URLResponse, Data), Error> + +protocol Stubbing { + func stub(url: URL) -> HTTPStub? + func data(url: URL) -> Data? + func dataString(url: URL) -> String? + func statusCode(url: URL) -> Int? +} + +extension Stubbing { + func stub(url: URL) -> HTTPStub? { + if let data = data(url: url), + let statusCode = statusCode(url: url), + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil) { + return .success((response, data)) + } + + return nil + } + + func data(url: URL) -> Data? { + dataString(url: url)?.data(using: .utf8) + } + + func statusCode(url: URL) -> Int? { 200 } +} diff --git a/Development Assets/StubbingURLProtocol.swift b/Development Assets/StubbingURLProtocol.swift new file mode 100644 index 0000000..dbfab8d --- /dev/null +++ b/Development Assets/StubbingURLProtocol.swift @@ -0,0 +1,42 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +class StubbingURLProtocol: URLProtocol { + private static var targetsForURLs = [URL: HTTPTarget]() + + class func setTarget(_ target: HTTPTarget, forURL url: URL) { + targetsForURLs[url] = target + } + + override class func canInit(with task: URLSessionTask) -> Bool { + true + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard + let url = request.url, + let stub = HTTPStubs.stub(request: request, target: Self.targetsForURLs[url]) else { + preconditionFailure("Stub for request not found") + } + + switch stub { + case let .success((response, data)): + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + case let .failure(error): + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Development Assets/StubbingWebAuthenticationSession.swift b/Development Assets/StubbingWebAuthenticationSession.swift new file mode 100644 index 0000000..aa34193 --- /dev/null +++ b/Development Assets/StubbingWebAuthenticationSession.swift @@ -0,0 +1,71 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import AuthenticationServices + +class StubbingWebAuthenticationSession: WebAuthenticationSessionType { + let completionHandler: ASWebAuthenticationSession.CompletionHandler + let url: URL + let callbackURLScheme: String? + var presentationContextProvider: ASWebAuthenticationPresentationContextProviding? + + required init( + url URL: URL, + callbackURLScheme: String?, + completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler) { + self.url = URL + self.callbackURLScheme = callbackURLScheme + self.completionHandler = completionHandler + } + + func start() -> Bool { + completionHandler(completionHandlerURL, completionHandlerError) + + return true + } + + var completionHandlerURL: URL? { + nil + } + + var completionHandlerError: Error? { + nil + } +} + +// swiftlint:disable type_name +class SuccessfulStubbingWebAuthenticationSession: StubbingWebAuthenticationSession { +// swiftlint:enable type_name + private let redirectURL: URL + + required init( + url URL: URL, + callbackURLScheme: String?, + completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler) { + redirectURL = Foundation.URL( + string: URLComponents(url: URL, resolvingAgainstBaseURL: true)! + .queryItems!.first(where: { $0.name == "redirect_uri" })!.value!)! + super.init( + url: URL, + callbackURLScheme: callbackURLScheme, + completionHandler: completionHandler) + } + + override var completionHandlerURL: URL? { + var components = URLComponents(url: redirectURL, resolvingAgainstBaseURL: true)! + var queryItems = components.queryItems ?? [] + + queryItems.append(URLQueryItem(name: "code", value: UUID().uuidString)) + components.queryItems = queryItems + + return components.url + } +} + +// swiftlint:disable type_name +class CanceledLoginStubbingWebAuthenticationSession: StubbingWebAuthenticationSession { +// swiftlint:enable type_name + override var completionHandlerError: Error? { + ASWebAuthenticationSessionError(.canceledLogin) + } +} diff --git a/Development Assets/URLSessionConfiguration+Extensions.swift b/Development Assets/URLSessionConfiguration+Extensions.swift new file mode 100644 index 0000000..f7266cc --- /dev/null +++ b/Development Assets/URLSessionConfiguration+Extensions.swift @@ -0,0 +1,13 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +extension URLSessionConfiguration { + static var stubbing: URLSessionConfiguration { + let configuration = Self.default + + configuration.protocolClasses = [StubbingURLProtocol.self] + + return configuration + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 6c7a73e..5208354 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -3,52 +3,185 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 52; objects = { /* Begin PBXBuildFile section */ - D047FAA124C3E21200AF17C5 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FAA024C3E21200AF17C5 /* Tests_iOS.swift */; }; - D047FAAC24C3E21200AF17C5 /* Tests_macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FAAB24C3E21200AF17C5 /* Tests_macOS.swift */; }; D047FAAE24C3E21200AF17C5 /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FA8524C3E21000AF17C5 /* MetatextApp.swift */; }; D047FAAF24C3E21200AF17C5 /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FA8524C3E21000AF17C5 /* MetatextApp.swift */; }; - D047FAB024C3E21200AF17C5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FA8624C3E21000AF17C5 /* ContentView.swift */; }; - D047FAB124C3E21200AF17C5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FA8624C3E21000AF17C5 /* ContentView.swift */; }; D047FAB224C3E21200AF17C5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D047FA8724C3E21200AF17C5 /* Assets.xcassets */; }; D047FAB324C3E21200AF17C5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D047FA8724C3E21200AF17C5 /* Assets.xcassets */; }; + D065F53924D37E5100741304 /* CombineExpectations in Frameworks */ = {isa = PBXBuildFile; productRef = D065F53824D37E5100741304 /* CombineExpectations */; }; + D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065F53A24D3B33A00741304 /* View+Extensions.swift */; }; + D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065F53A24D3B33A00741304 /* View+Extensions.swift */; }; + D065F53E24D3D20300741304 /* InstanceEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065F53D24D3D20300741304 /* InstanceEndpoint.swift */; }; + D065F53F24D3D20300741304 /* InstanceEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065F53D24D3D20300741304 /* InstanceEndpoint.swift */; }; + D0666A4224C6BB7B00F3F04B /* IdentityDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A4124C6BB7B00F3F04B /* IdentityDatabase.swift */; }; + D0666A4324C6BB7B00F3F04B /* IdentityDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A4124C6BB7B00F3F04B /* IdentityDatabase.swift */; }; + D0666A4524C6BC0A00F3F04B /* DatabaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A4424C6BC0A00F3F04B /* DatabaseError.swift */; }; + D0666A4624C6BC0A00F3F04B /* DatabaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A4424C6BC0A00F3F04B /* DatabaseError.swift */; }; + D0666A4924C6C1A300F3F04B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D0666A4824C6C1A300F3F04B /* GRDB */; }; + D0666A4B24C6C37700F3F04B /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A4A24C6C37700F3F04B /* Identity.swift */; }; + D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A4A24C6C37700F3F04B /* Identity.swift */; }; + D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A4D24C6C39600F3F04B /* Instance.swift */; }; + D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A4D24C6C39600F3F04B /* Instance.swift */; }; + D0666A5124C6C3BC00F3F04B /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A5024C6C3BC00F3F04B /* Account.swift */; }; + D0666A5224C6C3BC00F3F04B /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A5024C6C3BC00F3F04B /* Account.swift */; }; + D0666A5424C6C3E500F3F04B /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A5324C6C3E500F3F04B /* Emoji.swift */; }; + D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A5324C6C3E500F3F04B /* Emoji.swift */; }; + D0666A5724C6C63400F3F04B /* MastodonDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A5624C6C63400F3F04B /* MastodonDecoder.swift */; }; + D0666A5824C6C63400F3F04B /* MastodonDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A5624C6C63400F3F04B /* MastodonDecoder.swift */; }; + D0666A5A24C6C64100F3F04B /* MastodonEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A5924C6C64100F3F04B /* MastodonEncoder.swift */; }; + D0666A5B24C6C64100F3F04B /* MastodonEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A5924C6C64100F3F04B /* MastodonEncoder.swift */; }; + D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */; }; + D0666A6424C6DC6C00F3F04B /* AppAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */; }; + D0666A6F24C6DFB300F3F04B /* AccessToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A6E24C6DFB300F3F04B /* AccessToken.swift */; }; + D0666A7024C6DFB300F3F04B /* AccessToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A6E24C6DFB300F3F04B /* AccessToken.swift */; }; + D0666A7224C6E0D300F3F04B /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A7124C6E0D300F3F04B /* Secrets.swift */; }; + D0666A7324C6E0D300F3F04B /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A7124C6E0D300F3F04B /* Secrets.swift */; }; + D0666A7D24C7745A00F3F04B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D0666A7C24C7745A00F3F04B /* GRDB */; }; + D06B491F24D3F7FE00642749 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D06B491E24D3F7FE00642749 /* Localizable.strings */; }; + D06B492024D3FB8000642749 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D06B491E24D3F7FE00642749 /* Localizable.strings */; }; + D074577724D29006004758DB /* StubbingWebAuthenticationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577624D29006004758DB /* StubbingWebAuthenticationSession.swift */; }; + D074577824D29006004758DB /* StubbingWebAuthenticationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577624D29006004758DB /* StubbingWebAuthenticationSession.swift */; }; + D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */; }; + D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */; }; + D081A40524D0F1A8001B016E /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D081A40424D0F1A8001B016E /* String+Extensions.swift */; }; + D081A40624D0F1A8001B016E /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D081A40424D0F1A8001B016E /* String+Extensions.swift */; }; + D0B23F0D24D210E90066F411 /* NSError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */; }; + D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */; }; + D0BEC93824C9632800E864C4 /* SceneViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93724C9632800E864C4 /* SceneViewModel.swift */; }; + D0BEC93924C9632800E864C4 /* SceneViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93724C9632800E864C4 /* SceneViewModel.swift */; }; + D0BEC93B24C96FD500E864C4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93A24C96FD500E864C4 /* ContentView.swift */; }; + D0BEC93C24C96FD500E864C4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC93A24C96FD500E864C4 /* ContentView.swift */; }; + D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94624CA22C400E864C4 /* TimelineViewModel.swift */; }; + D0BEC94824CA22C400E864C4 /* TimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94624CA22C400E864C4 /* TimelineViewModel.swift */; }; + D0BEC94A24CA231200E864C4 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94924CA231200E864C4 /* TimelineView.swift */; }; + D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94924CA231200E864C4 /* TimelineView.swift */; }; + D0BEC94F24CA2B5300E864C4 /* SidebarNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC94E24CA2B5300E864C4 /* SidebarNavigation.swift */; }; + D0BEC95124CA2B7E00E864C4 /* TabNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEC95024CA2B7E00E864C4 /* TabNavigation.swift */; }; + D0C963FB24CC359D003BD330 /* AlertItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FA24CC359D003BD330 /* AlertItem.swift */; }; + D0C963FC24CC359D003BD330 /* AlertItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FA24CC359D003BD330 /* AlertItem.swift */; }; + D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */; }; + D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */; }; + D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB6EF324C5228A00D965FE /* AddIdentityView.swift */; }; + D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB6EF324C5228A00D965FE /* AddIdentityView.swift */; }; + D0DB6F0924C65AC000D965FE /* AddIdentityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB6F0824C65AC000D965FE /* AddIdentityViewModel.swift */; }; + D0DB6F0A24C65AC000D965FE /* AddIdentityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB6F0824C65AC000D965FE /* AddIdentityViewModel.swift */; }; + D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC174524CFEC2000A75C65 /* StubbingURLProtocol.swift */; }; + D0DC174724CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC174524CFEC2000A75C65 /* StubbingURLProtocol.swift */; }; + D0DC174A24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC174924CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift */; }; + D0DC174B24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC174924CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift */; }; + D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC174C24CFF1F100A75C65 /* Stubbing.swift */; }; + D0DC174E24CFF1F100A75C65 /* Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC174C24CFF1F100A75C65 /* Stubbing.swift */; }; + D0DC175224D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC175124D008E300A75C65 /* MastodonTarget+Stubbing.swift */; }; + D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC175124D008E300A75C65 /* MastodonTarget+Stubbing.swift */; }; + D0DC175524D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC175424D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift */; }; + D0DC175624D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC175424D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift */; }; + D0DC175824D0130800A75C65 /* HTTPStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC175724D0130800A75C65 /* HTTPStubs.swift */; }; + D0DC175924D0130800A75C65 /* HTTPStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC175724D0130800A75C65 /* HTTPStubs.swift */; }; + D0DC175B24D0154F00A75C65 /* MastodonAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC175A24D0154F00A75C65 /* MastodonAPI.swift */; }; + D0DC175C24D0154F00A75C65 /* MastodonAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC175A24D0154F00A75C65 /* MastodonAPI.swift */; }; + D0DC175F24D016EA00A75C65 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = D0DC175E24D016EA00A75C65 /* Alamofire */; }; + D0DC176124D0171800A75C65 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = D0DC176024D0171800A75C65 /* Alamofire */; }; + D0DC177424D0B58800A75C65 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC177324D0B58800A75C65 /* Keychain.swift */; }; + D0DC177524D0B58800A75C65 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC177324D0B58800A75C65 /* Keychain.swift */; }; + D0DC177724D0CF2600A75C65 /* FakeKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC177624D0CF2600A75C65 /* FakeKeychain.swift */; }; + D0DC177824D0CF2600A75C65 /* FakeKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC177624D0CF2600A75C65 /* FakeKeychain.swift */; }; + D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; }; + D0ED1BB724CE47F400B4899C /* WebAuthenticationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthenticationSession.swift */; }; + D0ED1BB824CE47F400B4899C /* WebAuthenticationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthenticationSession.swift */; }; + D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BC024CED48800B4899C /* HTTPClient.swift */; }; + D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BC024CED48800B4899C /* HTTPClient.swift */; }; + D0ED1BC424CED54D00B4899C /* HTTPTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BC324CED54D00B4899C /* HTTPTarget.swift */; }; + D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BC324CED54D00B4899C /* HTTPTarget.swift */; }; + D0ED1BCB24CF744200B4899C /* MastodonClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BCA24CF744200B4899C /* MastodonClient.swift */; }; + D0ED1BCC24CF744200B4899C /* MastodonClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BCA24CF744200B4899C /* MastodonClient.swift */; }; + D0ED1BCE24CF768200B4899C /* MastodonEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BCD24CF768200B4899C /* MastodonEndpoint.swift */; }; + D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BCD24CF768200B4899C /* MastodonEndpoint.swift */; }; + D0ED1BD124CF779B00B4899C /* MastodonTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BD024CF779B00B4899C /* MastodonTarget.swift */; }; + D0ED1BD224CF779B00B4899C /* MastodonTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BD024CF779B00B4899C /* MastodonTarget.swift */; }; + D0ED1BD724CF94B200B4899C /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BD624CF94B200B4899C /* Application.swift */; }; + D0ED1BD824CF94B200B4899C /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BD624CF94B200B4899C /* Application.swift */; }; + D0ED1BDA24CF963E00B4899C /* AppAuthorizationEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BD924CF963E00B4899C /* AppAuthorizationEndpoint.swift */; }; + D0ED1BDB24CF963E00B4899C /* AppAuthorizationEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BD924CF963E00B4899C /* AppAuthorizationEndpoint.swift */; }; + D0ED1BDD24CF982600B4899C /* AccessTokenEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BDC24CF982600B4899C /* AccessTokenEndpoint.swift */; }; + D0ED1BDE24CF982600B4899C /* AccessTokenEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BDC24CF982600B4899C /* AccessTokenEndpoint.swift */; }; + D0ED1BE024CF98FB00B4899C /* AccountEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BDF24CF98FB00B4899C /* AccountEndpoint.swift */; }; + D0ED1BE124CF98FB00B4899C /* AccountEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BDF24CF98FB00B4899C /* AccountEndpoint.swift */; }; + D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BE224CFA84400B4899C /* MastodonError.swift */; }; + D0ED1BE424CFA84400B4899C /* MastodonError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BE224CFA84400B4899C /* MastodonError.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - D047FA9D24C3E21200AF17C5 /* PBXContainerItemProxy */ = { + D0666A2624C677B400F3F04B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D047FA8024C3E21000AF17C5 /* Project object */; proxyType = 1; remoteGlobalIDString = D047FA8B24C3E21200AF17C5; remoteInfo = "Metatext (iOS)"; }; - D047FAA824C3E21200AF17C5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D047FA8024C3E21000AF17C5 /* Project object */; - proxyType = 1; - remoteGlobalIDString = D047FA9324C3E21200AF17C5; - remoteInfo = "Metatext (macOS)"; - }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ D047FA8524C3E21000AF17C5 /* MetatextApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetatextApp.swift; sourceTree = ""; }; - D047FA8624C3E21000AF17C5 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; D047FA8724C3E21200AF17C5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; D047FA8F24C3E21200AF17C5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D047FA9424C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; D047FA9624C3E21200AF17C5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D047FA9724C3E21200AF17C5 /* macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = macOS.entitlements; sourceTree = ""; }; - D047FA9C24C3E21200AF17C5 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - D047FAA024C3E21200AF17C5 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = ""; }; - D047FAA224C3E21200AF17C5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - D047FAA724C3E21200AF17C5 /* Tests macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests macOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - D047FAAB24C3E21200AF17C5 /* Tests_macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOS.swift; sourceTree = ""; }; - D047FAAD24C3E21200AF17C5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D065F53A24D3B33A00741304 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; + D065F53D24D3D20300741304 /* InstanceEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceEndpoint.swift; sourceTree = ""; }; + 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 = ""; }; + D0666A4124C6BB7B00F3F04B /* IdentityDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityDatabase.swift; sourceTree = ""; }; + D0666A4424C6BC0A00F3F04B /* DatabaseError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseError.swift; sourceTree = ""; }; + D0666A4A24C6C37700F3F04B /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = ""; }; + D0666A4D24C6C39600F3F04B /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = ""; }; + D0666A5024C6C3BC00F3F04B /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; + D0666A5324C6C3E500F3F04B /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; + D0666A5624C6C63400F3F04B /* MastodonDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonDecoder.swift; sourceTree = ""; }; + D0666A5924C6C64100F3F04B /* MastodonEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonEncoder.swift; sourceTree = ""; }; + D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAuthorization.swift; sourceTree = ""; }; + D0666A6E24C6DFB300F3F04B /* AccessToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessToken.swift; sourceTree = ""; }; + D0666A7124C6E0D300F3F04B /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; + D06B491E24D3F7FE00642749 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; + D074577624D29006004758DB /* StubbingWebAuthenticationSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StubbingWebAuthenticationSession.swift; sourceTree = ""; }; + D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionConfiguration+Extensions.swift"; sourceTree = ""; }; + D081A40424D0F1A8001B016E /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; + D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSError+Extensions.swift"; sourceTree = ""; }; + D0BEC93724C9632800E864C4 /* SceneViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneViewModel.swift; sourceTree = ""; }; + D0BEC93A24C96FD500E864C4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + D0BEC94624CA22C400E864C4 /* TimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewModel.swift; sourceTree = ""; }; + D0BEC94924CA231200E864C4 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; + D0BEC94E24CA2B5300E864C4 /* SidebarNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarNavigation.swift; sourceTree = ""; }; + D0BEC95024CA2B7E00E864C4 /* TabNavigation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabNavigation.swift; sourceTree = ""; }; + D0C963FA24CC359D003BD330 /* AlertItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertItem.swift; sourceTree = ""; }; + D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Extensions.swift"; sourceTree = ""; }; + D0DB6EF324C5228A00D965FE /* AddIdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityView.swift; sourceTree = ""; }; + D0DB6F0824C65AC000D965FE /* AddIdentityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModel.swift; sourceTree = ""; }; + D0DC174524CFEC2000A75C65 /* StubbingURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubbingURLProtocol.swift; sourceTree = ""; }; + D0DC174924CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppAuthorizationEndpoint+Stubbing.swift"; sourceTree = ""; }; + D0DC174C24CFF1F100A75C65 /* Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stubbing.swift; sourceTree = ""; }; + D0DC175124D008E300A75C65 /* MastodonTarget+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonTarget+Stubbing.swift"; sourceTree = ""; }; + D0DC175424D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessTokenEndpoint+Stubbing.swift"; sourceTree = ""; }; + D0DC175724D0130800A75C65 /* HTTPStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStubs.swift; sourceTree = ""; }; + D0DC175A24D0154F00A75C65 /* MastodonAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAPI.swift; sourceTree = ""; }; + D0DC177324D0B58800A75C65 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; + D0DC177624D0CF2600A75C65 /* FakeKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeKeychain.swift; sourceTree = ""; }; + D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = ""; }; + D0ED1BB624CE47F400B4899C /* WebAuthenticationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthenticationSession.swift; sourceTree = ""; }; + D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; + D0ED1BC324CED54D00B4899C /* HTTPTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTarget.swift; sourceTree = ""; }; + D0ED1BCA24CF744200B4899C /* MastodonClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonClient.swift; sourceTree = ""; }; + D0ED1BCD24CF768200B4899C /* MastodonEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonEndpoint.swift; sourceTree = ""; }; + D0ED1BD024CF779B00B4899C /* MastodonTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonTarget.swift; sourceTree = ""; }; + D0ED1BD624CF94B200B4899C /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + D0ED1BD924CF963E00B4899C /* AppAuthorizationEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAuthorizationEndpoint.swift; sourceTree = ""; }; + D0ED1BDC24CF982600B4899C /* AccessTokenEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessTokenEndpoint.swift; sourceTree = ""; }; + D0ED1BDF24CF98FB00B4899C /* AccountEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountEndpoint.swift; sourceTree = ""; }; + D0ED1BE224CFA84400B4899C /* MastodonError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonError.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -56,6 +189,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D0666A4924C6C1A300F3F04B /* GRDB in Frameworks */, + D0DC175F24D016EA00A75C65 /* Alamofire in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -63,20 +198,16 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D0DC176124D0171800A75C65 /* Alamofire in Frameworks */, + D0666A7D24C7745A00F3F04B /* GRDB in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - D047FA9924C3E21200AF17C5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - D047FAA424C3E21200AF17C5 /* Frameworks */ = { + D0666A1E24C677B400F3F04B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D065F53924D37E5100741304 /* CombineExpectations in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -86,21 +217,27 @@ D047FA7F24C3E21000AF17C5 = { isa = PBXGroup; children = ( - D047FA8424C3E21000AF17C5 /* Shared */, + D0ED1BB224CE3A1600B4899C /* Development Assets */, + D0666A7924C7745A00F3F04B /* Frameworks */, D047FA8E24C3E21200AF17C5 /* iOS */, D047FA9524C3E21200AF17C5 /* macOS */, - D047FA9F24C3E21200AF17C5 /* Tests iOS */, - D047FAAA24C3E21200AF17C5 /* Tests macOS */, D047FA8D24C3E21200AF17C5 /* Products */, + D047FA8424C3E21000AF17C5 /* Shared */, + D0666A2224C677B400F3F04B /* Tests */, ); sourceTree = ""; }; D047FA8424C3E21000AF17C5 /* Shared */ = { isa = PBXGroup; children = ( - D047FA8524C3E21000AF17C5 /* MetatextApp.swift */, - D047FA8624C3E21000AF17C5 /* ContentView.swift */, D047FA8724C3E21200AF17C5 /* Assets.xcassets */, + D0DB6F1624C665B400D965FE /* Extensions */, + D06B491D24D3F78A00642749 /* Localizations */, + D047FA8524C3E21000AF17C5 /* MetatextApp.swift */, + D0666A3A24C6B56200F3F04B /* Model */, + D0DB6EFA24C5730600D965FE /* Networking */, + D0DB6EFB24C658E400D965FE /* View Models */, + D0DB6EF024C5224F00D965FE /* Views */, ); path = Shared; sourceTree = ""; @@ -110,8 +247,7 @@ children = ( D047FA8C24C3E21200AF17C5 /* Metatext.app */, D047FA9424C3E21200AF17C5 /* Metatext.app */, - D047FA9C24C3E21200AF17C5 /* Tests iOS.xctest */, - D047FAA724C3E21200AF17C5 /* Tests macOS.xctest */, + D0666A2124C677B400F3F04B /* Tests.xctest */, ); name = Products; sourceTree = ""; @@ -120,6 +256,7 @@ isa = PBXGroup; children = ( D047FA8F24C3E21200AF17C5 /* Info.plist */, + D0BEC95024CA2B7E00E864C4 /* TabNavigation.swift */, ); path = iOS; sourceTree = ""; @@ -129,26 +266,144 @@ children = ( D047FA9624C3E21200AF17C5 /* Info.plist */, D047FA9724C3E21200AF17C5 /* macOS.entitlements */, + D0BEC94E24CA2B5300E864C4 /* SidebarNavigation.swift */, ); path = macOS; sourceTree = ""; }; - D047FA9F24C3E21200AF17C5 /* Tests iOS */ = { + D0666A2224C677B400F3F04B /* Tests */ = { isa = PBXGroup; children = ( - D047FAA024C3E21200AF17C5 /* Tests_iOS.swift */, - D047FAA224C3E21200AF17C5 /* Info.plist */, + D0ED1B6C24CE0EED00B4899C /* View Models */, + D0666A2524C677B400F3F04B /* Info.plist */, ); - path = "Tests iOS"; + path = Tests; sourceTree = ""; }; - D047FAAA24C3E21200AF17C5 /* Tests macOS */ = { + D0666A3A24C6B56200F3F04B /* Model */ = { isa = PBXGroup; children = ( - D047FAAB24C3E21200AF17C5 /* Tests_macOS.swift */, - D047FAAD24C3E21200AF17C5 /* Info.plist */, + D0666A6E24C6DFB300F3F04B /* AccessToken.swift */, + D0666A5024C6C3BC00F3F04B /* Account.swift */, + D0C963FA24CC359D003BD330 /* AlertItem.swift */, + D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */, + D0ED1BD624CF94B200B4899C /* Application.swift */, + D0666A4424C6BC0A00F3F04B /* DatabaseError.swift */, + D0666A5324C6C3E500F3F04B /* Emoji.swift */, + D0666A4A24C6C37700F3F04B /* Identity.swift */, + D0666A4124C6BB7B00F3F04B /* IdentityDatabase.swift */, + D0666A4D24C6C39600F3F04B /* Instance.swift */, + D0DC177324D0B58800A75C65 /* Keychain.swift */, + D0ED1BE224CFA84400B4899C /* MastodonError.swift */, + D0666A7124C6E0D300F3F04B /* Secrets.swift */, ); - path = "Tests macOS"; + path = Model; + sourceTree = ""; + }; + D0666A7924C7745A00F3F04B /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + D06B491D24D3F78A00642749 /* Localizations */ = { + isa = PBXGroup; + children = ( + D06B491E24D3F7FE00642749 /* Localizable.strings */, + ); + path = Localizations; + sourceTree = ""; + }; + D0DB6EF024C5224F00D965FE /* Views */ = { + isa = PBXGroup; + children = ( + D0DB6EF324C5228A00D965FE /* AddIdentityView.swift */, + D0BEC93A24C96FD500E864C4 /* ContentView.swift */, + D0BEC94924CA231200E864C4 /* TimelineView.swift */, + ); + path = Views; + sourceTree = ""; + }; + D0DB6EFA24C5730600D965FE /* Networking */ = { + isa = PBXGroup; + children = ( + D0ED1BC624CF6CE300B4899C /* Mastodon API */, + D0ED1BC324CED54D00B4899C /* HTTPTarget.swift */, + D0ED1BC024CED48800B4899C /* HTTPClient.swift */, + D0666A5624C6C63400F3F04B /* MastodonDecoder.swift */, + D0666A5924C6C64100F3F04B /* MastodonEncoder.swift */, + D0ED1BB624CE47F400B4899C /* WebAuthenticationSession.swift */, + ); + path = Networking; + sourceTree = ""; + }; + D0DB6EFB24C658E400D965FE /* View Models */ = { + isa = PBXGroup; + children = ( + D0DB6F0824C65AC000D965FE /* AddIdentityViewModel.swift */, + D0BEC93724C9632800E864C4 /* SceneViewModel.swift */, + D0BEC94624CA22C400E864C4 /* TimelineViewModel.swift */, + ); + path = "View Models"; + sourceTree = ""; + }; + D0DB6F1624C665B400D965FE /* Extensions */ = { + isa = PBXGroup; + children = ( + D0B23F0C24D210E90066F411 /* NSError+Extensions.swift */, + D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */, + D081A40424D0F1A8001B016E /* String+Extensions.swift */, + D065F53A24D3B33A00741304 /* View+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + D0DC174824CFF13700A75C65 /* Mastodon API Stubs */ = { + isa = PBXGroup; + children = ( + D0DC175424D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift */, + D0DC174924CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift */, + D0DC175124D008E300A75C65 /* MastodonTarget+Stubbing.swift */, + ); + path = "Mastodon API Stubs"; + sourceTree = ""; + }; + D0ED1B6C24CE0EED00B4899C /* View Models */ = { + isa = PBXGroup; + children = ( + D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */, + ); + path = "View Models"; + sourceTree = ""; + }; + D0ED1BB224CE3A1600B4899C /* Development Assets */ = { + isa = PBXGroup; + children = ( + D0DC177624D0CF2600A75C65 /* FakeKeychain.swift */, + D0DC175724D0130800A75C65 /* HTTPStubs.swift */, + D0DC174824CFF13700A75C65 /* Mastodon API Stubs */, + D0DC174C24CFF1F100A75C65 /* Stubbing.swift */, + D0DC174524CFEC2000A75C65 /* StubbingURLProtocol.swift */, + D074577624D29006004758DB /* StubbingWebAuthenticationSession.swift */, + D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */, + ); + path = "Development Assets"; + sourceTree = ""; + }; + D0ED1BC624CF6CE300B4899C /* Mastodon API */ = { + isa = PBXGroup; + children = ( + D0ED1BDC24CF982600B4899C /* AccessTokenEndpoint.swift */, + D0ED1BDF24CF98FB00B4899C /* AccountEndpoint.swift */, + D0ED1BD924CF963E00B4899C /* AppAuthorizationEndpoint.swift */, + D065F53D24D3D20300741304 /* InstanceEndpoint.swift */, + D0DC175A24D0154F00A75C65 /* MastodonAPI.swift */, + D0ED1BCA24CF744200B4899C /* MastodonClient.swift */, + D0ED1BCD24CF768200B4899C /* MastodonEndpoint.swift */, + D0ED1BD024CF779B00B4899C /* MastodonTarget.swift */, + ); + path = "Mastodon API"; sourceTree = ""; }; /* End PBXGroup section */ @@ -161,12 +416,17 @@ D047FA8824C3E21200AF17C5 /* Sources */, D047FA8924C3E21200AF17C5 /* Frameworks */, D047FA8A24C3E21200AF17C5 /* Resources */, + D0666A2E24C67E6700F3F04B /* ShellScript */, ); buildRules = ( ); dependencies = ( ); name = "Metatext (iOS)"; + packageProductDependencies = ( + D0666A4824C6C1A300F3F04B /* GRDB */, + D0DC175E24D016EA00A75C65 /* Alamofire */, + ); productName = "Metatext (iOS)"; productReference = D047FA8C24C3E21200AF17C5 /* Metatext.app */; productType = "com.apple.product-type.application"; @@ -178,51 +438,41 @@ D047FA9024C3E21200AF17C5 /* Sources */, D047FA9124C3E21200AF17C5 /* Frameworks */, D047FA9224C3E21200AF17C5 /* Resources */, + D0666A2F24C67F1400F3F04B /* ShellScript */, ); buildRules = ( ); dependencies = ( ); name = "Metatext (macOS)"; + packageProductDependencies = ( + D0666A7C24C7745A00F3F04B /* GRDB */, + D0DC176024D0171800A75C65 /* Alamofire */, + ); productName = "Metatext (macOS)"; productReference = D047FA9424C3E21200AF17C5 /* Metatext.app */; productType = "com.apple.product-type.application"; }; - D047FA9B24C3E21200AF17C5 /* Tests iOS */ = { + D0666A2024C677B400F3F04B /* Tests */ = { isa = PBXNativeTarget; - buildConfigurationList = D047FABC24C3E21200AF17C5 /* Build configuration list for PBXNativeTarget "Tests iOS" */; + buildConfigurationList = D0666A2824C677B400F3F04B /* Build configuration list for PBXNativeTarget "Tests" */; buildPhases = ( - D047FA9824C3E21200AF17C5 /* Sources */, - D047FA9924C3E21200AF17C5 /* Frameworks */, - D047FA9A24C3E21200AF17C5 /* Resources */, + D0666A1D24C677B400F3F04B /* Sources */, + D0666A1E24C677B400F3F04B /* Frameworks */, + D0666A1F24C677B400F3F04B /* Resources */, ); buildRules = ( ); dependencies = ( - D047FA9E24C3E21200AF17C5 /* PBXTargetDependency */, + D0666A2724C677B400F3F04B /* PBXTargetDependency */, ); - name = "Tests iOS"; - productName = "Tests iOS"; - productReference = D047FA9C24C3E21200AF17C5 /* Tests iOS.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; - D047FAA624C3E21200AF17C5 /* Tests macOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = D047FABF24C3E21200AF17C5 /* Build configuration list for PBXNativeTarget "Tests macOS" */; - buildPhases = ( - D047FAA324C3E21200AF17C5 /* Sources */, - D047FAA424C3E21200AF17C5 /* Frameworks */, - D047FAA524C3E21200AF17C5 /* Resources */, + name = Tests; + packageProductDependencies = ( + D065F53824D37E5100741304 /* CombineExpectations */, ); - buildRules = ( - ); - dependencies = ( - D047FAA924C3E21200AF17C5 /* PBXTargetDependency */, - ); - name = "Tests macOS"; - productName = "Tests macOS"; - productReference = D047FAA724C3E21200AF17C5 /* Tests macOS.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; + productName = "Unit Tests"; + productReference = D0666A2124C677B400F3F04B /* Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ @@ -240,14 +490,11 @@ D047FA9324C3E21200AF17C5 = { CreatedOnToolsVersion = 12.0; }; - D047FA9B24C3E21200AF17C5 = { + D0666A2024C677B400F3F04B = { CreatedOnToolsVersion = 12.0; + LastSwiftMigration = 1200; TestTargetID = D047FA8B24C3E21200AF17C5; }; - D047FAA624C3E21200AF17C5 = { - CreatedOnToolsVersion = 12.0; - TestTargetID = D047FA9324C3E21200AF17C5; - }; }; }; buildConfigurationList = D047FA8324C3E21000AF17C5 /* Build configuration list for PBXProject "Metatext" */; @@ -259,14 +506,18 @@ Base, ); mainGroup = D047FA7F24C3E21000AF17C5; + packageReferences = ( + D0666A4724C6C1A300F3F04B /* XCRemoteSwiftPackageReference "GRDB" */, + D0DC175D24D016EA00A75C65 /* XCRemoteSwiftPackageReference "Alamofire" */, + D065F53724D37E5100741304 /* XCRemoteSwiftPackageReference "CombineExpectations" */, + ); productRefGroup = D047FA8D24C3E21200AF17C5 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( D047FA8B24C3E21200AF17C5 /* Metatext (iOS) */, D047FA9324C3E21200AF17C5 /* Metatext (macOS) */, - D047FA9B24C3E21200AF17C5 /* Tests iOS */, - D047FAA624C3E21200AF17C5 /* Tests macOS */, + D0666A2024C677B400F3F04B /* Tests */, ); }; /* End PBXProject section */ @@ -277,6 +528,7 @@ buildActionMask = 2147483647; files = ( D047FAB224C3E21200AF17C5 /* Assets.xcassets in Resources */, + D06B491F24D3F7FE00642749 /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -285,17 +537,11 @@ buildActionMask = 2147483647; files = ( D047FAB324C3E21200AF17C5 /* Assets.xcassets in Resources */, + D06B492024D3FB8000642749 /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - D047FA9A24C3E21200AF17C5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - D047FAA524C3E21200AF17C5 /* Resources */ = { + D0666A1F24C677B400F3F04B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -304,13 +550,95 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + D0666A2E24C67E6700F3F04B /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + }; + D0666A2F24C67F1400F3F04B /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ D047FA8824C3E21200AF17C5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D047FAB024C3E21200AF17C5 /* ContentView.swift in Sources */, + D0DB6F0924C65AC000D965FE /* AddIdentityViewModel.swift in Sources */, + D0ED1BD724CF94B200B4899C /* Application.swift in Sources */, D047FAAE24C3E21200AF17C5 /* MetatextApp.swift in Sources */, + D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */, + D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */, + D0ED1BDA24CF963E00B4899C /* AppAuthorizationEndpoint.swift in Sources */, + D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */, + D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */, + D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */, + D0DC174A24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */, + D0666A5A24C6C64100F3F04B /* MastodonEncoder.swift in Sources */, + D0666A5124C6C3BC00F3F04B /* Account.swift in Sources */, + D0ED1BE024CF98FB00B4899C /* AccountEndpoint.swift in Sources */, + D081A40524D0F1A8001B016E /* String+Extensions.swift in Sources */, + D0BEC93824C9632800E864C4 /* SceneViewModel.swift in Sources */, + D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */, + D0666A4524C6BC0A00F3F04B /* DatabaseError.swift in Sources */, + D0ED1BDD24CF982600B4899C /* AccessTokenEndpoint.swift in Sources */, + D0666A4B24C6C37700F3F04B /* Identity.swift in Sources */, + D0666A5424C6C3E500F3F04B /* Emoji.swift in Sources */, + D0DC175524D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */, + D0B23F0D24D210E90066F411 /* NSError+Extensions.swift in Sources */, + D0DC175224D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */, + D0666A4224C6BB7B00F3F04B /* IdentityDatabase.swift in Sources */, + D0BEC94A24CA231200E864C4 /* TimelineView.swift in Sources */, + D0BEC93B24C96FD500E864C4 /* ContentView.swift in Sources */, + D0DC175824D0130800A75C65 /* HTTPStubs.swift in Sources */, + D0DC177724D0CF2600A75C65 /* FakeKeychain.swift in Sources */, + D0C963FB24CC359D003BD330 /* AlertItem.swift in Sources */, + D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */, + D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */, + D0666A5724C6C63400F3F04B /* MastodonDecoder.swift in Sources */, + D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */, + D0DC177424D0B58800A75C65 /* Keychain.swift in Sources */, + D074577724D29006004758DB /* StubbingWebAuthenticationSession.swift in Sources */, + D0ED1BCE24CF768200B4899C /* MastodonEndpoint.swift in Sources */, + D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */, + D0ED1BB724CE47F400B4899C /* WebAuthenticationSession.swift in Sources */, + D0666A7224C6E0D300F3F04B /* Secrets.swift in Sources */, + D0BEC95124CA2B7E00E864C4 /* TabNavigation.swift in Sources */, + D0ED1BC424CED54D00B4899C /* HTTPTarget.swift in Sources */, + D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */, + D0DC175B24D0154F00A75C65 /* MastodonAPI.swift in Sources */, + D0ED1BD124CF779B00B4899C /* MastodonTarget.swift in Sources */, + D065F53E24D3D20300741304 /* InstanceEndpoint.swift in Sources */, + D0666A6F24C6DFB300F3F04B /* AccessToken.swift in Sources */, + D0ED1BCB24CF744200B4899C /* MastodonClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -318,39 +646,71 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D047FAB124C3E21200AF17C5 /* ContentView.swift in Sources */, + D0DB6F0A24C65AC000D965FE /* AddIdentityViewModel.swift in Sources */, + D0ED1BD824CF94B200B4899C /* Application.swift in Sources */, D047FAAF24C3E21200AF17C5 /* MetatextApp.swift in Sources */, + D0BEC94824CA22C400E864C4 /* TimelineViewModel.swift in Sources */, + D0666A4F24C6C39600F3F04B /* Instance.swift in Sources */, + D0ED1BDB24CF963E00B4899C /* AppAuthorizationEndpoint.swift in Sources */, + D0ED1BE424CFA84400B4899C /* MastodonError.swift in Sources */, + D0666A6424C6DC6C00F3F04B /* AppAuthorization.swift in Sources */, + D065F53C24D3B33A00741304 /* View+Extensions.swift in Sources */, + D0DC174B24CFF15F00A75C65 /* AppAuthorizationEndpoint+Stubbing.swift in Sources */, + D0666A5B24C6C64100F3F04B /* MastodonEncoder.swift in Sources */, + D0666A5224C6C3BC00F3F04B /* Account.swift in Sources */, + D0ED1BE124CF98FB00B4899C /* AccountEndpoint.swift in Sources */, + D081A40624D0F1A8001B016E /* String+Extensions.swift in Sources */, + D0BEC93924C9632800E864C4 /* SceneViewModel.swift in Sources */, + D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */, + D0666A4624C6BC0A00F3F04B /* DatabaseError.swift in Sources */, + D0ED1BDE24CF982600B4899C /* AccessTokenEndpoint.swift in Sources */, + D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */, + D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */, + D0DC175624D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */, + D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */, + D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */, + D0666A4324C6BB7B00F3F04B /* IdentityDatabase.swift in Sources */, + D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */, + D0BEC93C24C96FD500E864C4 /* ContentView.swift in Sources */, + D0DC175924D0130800A75C65 /* HTTPStubs.swift in Sources */, + D0DC177824D0CF2600A75C65 /* FakeKeychain.swift in Sources */, + D0C963FC24CC359D003BD330 /* AlertItem.swift in Sources */, + D0DC174724CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */, + D0DC174E24CFF1F100A75C65 /* Stubbing.swift in Sources */, + D0666A5824C6C63400F3F04B /* MastodonDecoder.swift in Sources */, + D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */, + D0DC177524D0B58800A75C65 /* Keychain.swift in Sources */, + D074577824D29006004758DB /* StubbingWebAuthenticationSession.swift in Sources */, + D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */, + D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */, + D0ED1BB824CE47F400B4899C /* WebAuthenticationSession.swift in Sources */, + D0BEC94F24CA2B5300E864C4 /* SidebarNavigation.swift in Sources */, + D0666A7324C6E0D300F3F04B /* Secrets.swift in Sources */, + D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */, + D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */, + D0DC175C24D0154F00A75C65 /* MastodonAPI.swift in Sources */, + D0ED1BD224CF779B00B4899C /* MastodonTarget.swift in Sources */, + D065F53F24D3D20300741304 /* InstanceEndpoint.swift in Sources */, + D0666A7024C6DFB300F3F04B /* AccessToken.swift in Sources */, + D0ED1BCC24CF744200B4899C /* MastodonClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - D047FA9824C3E21200AF17C5 /* Sources */ = { + D0666A1D24C677B400F3F04B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D047FAA124C3E21200AF17C5 /* Tests_iOS.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - D047FAA324C3E21200AF17C5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - D047FAAC24C3E21200AF17C5 /* Tests_macOS.swift in Sources */, + D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - D047FA9E24C3E21200AF17C5 /* PBXTargetDependency */ = { + D0666A2724C677B400F3F04B /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D047FA8B24C3E21200AF17C5 /* Metatext (iOS) */; - targetProxy = D047FA9D24C3E21200AF17C5 /* PBXContainerItemProxy */; - }; - D047FAA924C3E21200AF17C5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D047FA9324C3E21200AF17C5 /* Metatext (macOS) */; - targetProxy = D047FAA824C3E21200AF17C5 /* PBXContainerItemProxy */; + targetProxy = D0666A2624C677B400F3F04B /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -472,6 +832,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "Development\\ Assets"; DEVELOPMENT_TEAM = 82HL67AXQ2; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; @@ -494,6 +855,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "Development\\ Assets"; DEVELOPMENT_TEAM = 82HL67AXQ2; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; @@ -519,6 +881,7 @@ CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_ASSET_PATHS = "Development\\ Assets"; DEVELOPMENT_TEAM = 82HL67AXQ2; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -543,6 +906,7 @@ CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_ASSET_PATHS = "Development\\ Assets"; DEVELOPMENT_TEAM = 82HL67AXQ2; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -559,95 +923,54 @@ }; name = Release; }; - D047FABD24C3E21200AF17C5 /* Debug */ = { + D0666A2924C677B400F3F04B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 82HL67AXQ2; - INFOPLIST_FILE = "Tests iOS/Info.plist"; + INFOPLIST_FILE = Tests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.metabolist.Tests-iOS"; + PRODUCT_BUNDLE_IDENTIFIER = "com.metabolist.Unit-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = "Metatext (iOS)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Metatext.app/Metatext"; }; name = Debug; }; - D047FABE24C3E21200AF17C5 /* Release */ = { + D0666A2A24C677B400F3F04B /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 82HL67AXQ2; - INFOPLIST_FILE = "Tests iOS/Info.plist"; + INFOPLIST_FILE = Tests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = "com.metabolist.Tests-iOS"; + PRODUCT_BUNDLE_IDENTIFIER = "com.metabolist.Unit-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = "Metatext (iOS)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Metatext.app/Metatext"; VALIDATE_PRODUCT = YES; }; name = Release; }; - D047FAC024C3E21200AF17C5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 82HL67AXQ2; - INFOPLIST_FILE = "Tests macOS/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@loader_path/../Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 10.16; - PRODUCT_BUNDLE_IDENTIFIER = "com.metabolist.Tests-macOS"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = "Metatext (macOS)"; - }; - name = Debug; - }; - D047FAC124C3E21200AF17C5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = 82HL67AXQ2; - INFOPLIST_FILE = "Tests macOS/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@loader_path/../Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 10.16; - PRODUCT_BUNDLE_IDENTIFIER = "com.metabolist.Tests-macOS"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = "Metatext (macOS)"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -678,25 +1001,71 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - D047FABC24C3E21200AF17C5 /* Build configuration list for PBXNativeTarget "Tests iOS" */ = { + D0666A2824C677B400F3F04B /* Build configuration list for PBXNativeTarget "Tests" */ = { isa = XCConfigurationList; buildConfigurations = ( - D047FABD24C3E21200AF17C5 /* Debug */, - D047FABE24C3E21200AF17C5 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - D047FABF24C3E21200AF17C5 /* Build configuration list for PBXNativeTarget "Tests macOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - D047FAC024C3E21200AF17C5 /* Debug */, - D047FAC124C3E21200AF17C5 /* Release */, + D0666A2924C677B400F3F04B /* Debug */, + D0666A2A24C677B400F3F04B /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D065F53724D37E5100741304 /* XCRemoteSwiftPackageReference "CombineExpectations" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/CombineExpectations"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.5.0; + }; + }; + D0666A4724C6C1A300F3F04B /* XCRemoteSwiftPackageReference "GRDB" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/GRDB.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = "5.0.0-beta.10"; + }; + }; + D0DC175D24D016EA00A75C65 /* XCRemoteSwiftPackageReference "Alamofire" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Alamofire/Alamofire"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.2.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D065F53824D37E5100741304 /* CombineExpectations */ = { + isa = XCSwiftPackageProductDependency; + package = D065F53724D37E5100741304 /* XCRemoteSwiftPackageReference "CombineExpectations" */; + productName = CombineExpectations; + }; + D0666A4824C6C1A300F3F04B /* GRDB */ = { + isa = XCSwiftPackageProductDependency; + package = D0666A4724C6C1A300F3F04B /* XCRemoteSwiftPackageReference "GRDB" */; + productName = GRDB; + }; + D0666A7C24C7745A00F3F04B /* GRDB */ = { + isa = XCSwiftPackageProductDependency; + package = D0666A4724C6C1A300F3F04B /* XCRemoteSwiftPackageReference "GRDB" */; + productName = GRDB; + }; + D0DC175E24D016EA00A75C65 /* Alamofire */ = { + isa = XCSwiftPackageProductDependency; + package = D0DC175D24D016EA00A75C65 /* XCRemoteSwiftPackageReference "Alamofire" */; + productName = Alamofire; + }; + D0DC176024D0171800A75C65 /* Alamofire */ = { + isa = XCSwiftPackageProductDependency; + package = D0DC175D24D016EA00A75C65 /* XCRemoteSwiftPackageReference "Alamofire" */; + productName = Alamofire; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = D047FA8024C3E21000AF17C5 /* Project object */; } diff --git a/Metatext.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Metatext.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..2be41dc --- /dev/null +++ b/Metatext.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,34 @@ +{ + "object": { + "pins": [ + { + "package": "Alamofire", + "repositoryURL": "https://github.com/Alamofire/Alamofire", + "state": { + "branch": null, + "revision": "becd9a729a37bdbef5bc39dc3c702b99f9e3d046", + "version": "5.2.2" + } + }, + { + "package": "CombineExpectations", + "repositoryURL": "https://github.com/groue/CombineExpectations", + "state": { + "branch": null, + "revision": "96d5604151c94b21fbca6877b237e80af9e821dd", + "version": "0.5.0" + } + }, + { + "package": "GRDB", + "repositoryURL": "https://github.com/groue/GRDB.swift", + "state": { + "branch": null, + "revision": "ededd8668abd5a3c4c43cc9ebcfd611082b47f65", + "version": "5.0.0-beta.10" + } + } + ] + }, + "version": 1 +} diff --git a/Metatext.xcodeproj/xcshareddata/xcschemes/Metatext (iOS).xcscheme b/Metatext.xcodeproj/xcshareddata/xcschemes/Metatext (iOS).xcscheme new file mode 100644 index 0000000..4ec1ccf --- /dev/null +++ b/Metatext.xcodeproj/xcshareddata/xcschemes/Metatext (iOS).xcscheme @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Metatext.xcodeproj/xcuserdata/justin.xcuserdatad/xcschemes/xcschememanagement.plist b/Metatext.xcodeproj/xcuserdata/justin.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index c168434..0000000 --- a/Metatext.xcodeproj/xcuserdata/justin.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,19 +0,0 @@ - - - - - SchemeUserState - - Metatext (iOS).xcscheme_^#shared#^_ - - orderHint - 0 - - Metatext (macOS).xcscheme_^#shared#^_ - - orderHint - 1 - - - - diff --git a/Shared/Extensions/NSError+Extensions.swift b/Shared/Extensions/NSError+Extensions.swift new file mode 100644 index 0000000..0ffe8aa --- /dev/null +++ b/Shared/Extensions/NSError+Extensions.swift @@ -0,0 +1,15 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +extension NSError { + convenience init(status: OSStatus) { + var userInfo: [String: Any]? + + if let errorMessage = SecCopyErrorMessageString(status, nil) { + userInfo = [NSLocalizedDescriptionKey: errorMessage] + } + + self.init(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: userInfo) + } +} diff --git a/Shared/Extensions/Publisher+Extensions.swift b/Shared/Extensions/Publisher+Extensions.swift new file mode 100644 index 0000000..a52c111 --- /dev/null +++ b/Shared/Extensions/Publisher+Extensions.swift @@ -0,0 +1,19 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine + +extension Publisher { + func assignErrorsToAlertItem( + to keyPath: ReferenceWritableKeyPath, + on object: Root) -> AnyPublisher { + self.catch { error -> AnyPublisher in + DispatchQueue.main.async { + object[keyPath: keyPath] = AlertItem(error: error) + } + + return Empty().eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} diff --git a/Shared/Extensions/String+Extensions.swift b/Shared/Extensions/String+Extensions.swift new file mode 100644 index 0000000..655b806 --- /dev/null +++ b/Shared/Extensions/String+Extensions.swift @@ -0,0 +1,21 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +extension String { + private static let colonDoubleSlash = "://" + + func url(scheme: String = "https") throws -> URL { + let url: URL? + + if hasPrefix(scheme + Self.colonDoubleSlash) { + url = URL(string: self) + } else { + url = URL(string: scheme + Self.colonDoubleSlash + self) + } + + guard let validURL = url else { throw URLError(.badURL) } + + return validURL + } +} diff --git a/Shared/Extensions/View+Extensions.swift b/Shared/Extensions/View+Extensions.swift new file mode 100644 index 0000000..29cf5d3 --- /dev/null +++ b/Shared/Extensions/View+Extensions.swift @@ -0,0 +1,12 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import SwiftUI + +extension View { + func alertItem(_ alertItem: Binding) -> some View { + alert(item: alertItem) { + Alert(title: Text($0.error.localizedDescription)) + } + } +} diff --git a/Shared/Localizations/Localizable.strings b/Shared/Localizations/Localizable.strings new file mode 100644 index 0000000..244d6d9 --- /dev/null +++ b/Shared/Localizations/Localizable.strings @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +"go" = "Go"; +"add-identity.instance-url" = "Instance URL"; +"oauth.error.code-not-found" = "OAuth error: code not found"; diff --git a/Shared/MetatextApp.swift b/Shared/MetatextApp.swift index c19edaa..63b49e8 100644 --- a/Shared/MetatextApp.swift +++ b/Shared/MetatextApp.swift @@ -4,9 +4,25 @@ import SwiftUI @main struct MetatextApp: App { + private let identityDatabase: IdentityDatabase + private let secrets = Secrets(keychain: Keychain(service: "com.metabolist.metatext")) + + init() { + do { + try identityDatabase = IdentityDatabase() + } catch { + fatalError("Failed to initialize identity database") + } + } + var body: some Scene { WindowGroup { ContentView() + .environmentObject( + SceneViewModel( + networkClient: MastodonClient(), + identityDatabase: identityDatabase, + secrets: secrets)) } } } diff --git a/Shared/Model/AccessToken.swift b/Shared/Model/AccessToken.swift new file mode 100644 index 0000000..4d0feec --- /dev/null +++ b/Shared/Model/AccessToken.swift @@ -0,0 +1,9 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct AccessToken: Codable { + let scope: String + let tokenType: String + let accessToken: String +} diff --git a/Shared/Model/Account.swift b/Shared/Model/Account.swift new file mode 100644 index 0000000..c0ff892 --- /dev/null +++ b/Shared/Model/Account.swift @@ -0,0 +1,32 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct Account: Codable, Hashable { + struct Field: Codable, Hashable { + let name: String + let value: String + let verifiedAt: Date? + } + + let id: String + let username: String + let acct: String + let displayName: String + let locked: Bool + let createdAt: Date + let followersCount: Int + let followingCount: Int + let statusesCount: Int + let note: String + let url: URL + let avatar: URL + let avatarStatic: URL + let header: URL + let headerStatic: URL + let fields: [Field] + let emojis: [Emoji] + let bot: Bool? + let moved: Bool? + let discoverable: Bool? +} diff --git a/Shared/Model/AlertItem.swift b/Shared/Model/AlertItem.swift new file mode 100644 index 0000000..43c3155 --- /dev/null +++ b/Shared/Model/AlertItem.swift @@ -0,0 +1,8 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct AlertItem: Identifiable { + let id = UUID() + let error: Error +} diff --git a/Shared/Model/AppAuthorization.swift b/Shared/Model/AppAuthorization.swift new file mode 100644 index 0000000..82f34cd --- /dev/null +++ b/Shared/Model/AppAuthorization.swift @@ -0,0 +1,13 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct AppAuthorization: Codable { + let id: String + let clientId: String + let clientSecret: String + let name: String + let redirectUri: String + let website: String? + let vapidKey: String? +} diff --git a/Shared/Model/Application.swift b/Shared/Model/Application.swift new file mode 100644 index 0000000..d42d9dc --- /dev/null +++ b/Shared/Model/Application.swift @@ -0,0 +1,8 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct Application: Codable { + let name: String + let website: String +} diff --git a/Shared/Model/DatabaseError.swift b/Shared/Model/DatabaseError.swift new file mode 100644 index 0000000..ae20fce --- /dev/null +++ b/Shared/Model/DatabaseError.swift @@ -0,0 +1,7 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +enum DatabaseError: Error { + case documentsDirectoryNotFound +} diff --git a/Shared/Model/Emoji.swift b/Shared/Model/Emoji.swift new file mode 100644 index 0000000..65fe288 --- /dev/null +++ b/Shared/Model/Emoji.swift @@ -0,0 +1,10 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct Emoji: Codable, Hashable { + let shortcode: String + let staticUrl: URL + let url: URL + let visibleInPicker: Bool +} diff --git a/Shared/Model/Identity.swift b/Shared/Model/Identity.swift new file mode 100644 index 0000000..c1523b9 --- /dev/null +++ b/Shared/Model/Identity.swift @@ -0,0 +1,40 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct Identity: Codable, Hashable { + let id: String + let url: URL + let instance: Identity.Instance? + let account: Identity.Account? +} + +extension Identity { + struct Instance: Codable, Hashable { + let uri: String + let streamingAPI: URL + let title: String + let thumbnail: URL? + } + + struct Account: Codable, Hashable { + let id: String + let identityID: String + let username: String + let url: URL + let avatar: URL + let avatarStatic: URL + let header: URL + let headerStatic: URL + } +} + +extension Identity { + var handle: String { + if let account = account, let host = account.url.host { + return account.url.lastPathComponent + "@" + host + } + + return instance?.title ?? url.host ?? url.absoluteString + } +} diff --git a/Shared/Model/IdentityDatabase.swift b/Shared/Model/IdentityDatabase.swift new file mode 100644 index 0000000..4e6e30a --- /dev/null +++ b/Shared/Model/IdentityDatabase.swift @@ -0,0 +1,163 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine +import GRDB + +struct IdentityDatabase { + private let databaseQueue: DatabaseQueue + + init(inMemory: Bool = false) throws { + guard + let documentsDirectory = NSSearchPathForDirectoriesInDomains( + .documentDirectory, + .userDomainMask, true) + .first + else { throw DatabaseError.documentsDirectoryNotFound } + + if inMemory { + databaseQueue = DatabaseQueue() + } else { + databaseQueue = try DatabaseQueue(path: "\(documentsDirectory)/IdentityDatabase.sqlite3") + } + + try Self.migrate(databaseQueue) + } +} + +extension IdentityDatabase { + func createIdentity(id: String, url: URL) -> AnyPublisher { + databaseQueue.writePublisher { + try StoredIdentity(id: id, url: url, instanceURI: nil).save($0) + + return Identity(id: id, url: url, instance: nil, account: nil) + } + .eraseToAnyPublisher() + } + + func updateInstance(_ instance: Instance, forIdentityID identityID: String) -> AnyPublisher { + databaseQueue.writePublisher { + try Identity.Instance( + uri: instance.uri, + streamingAPI: instance.urls.streamingApi, + title: instance.title, + thumbnail: instance.thumbnail) + .save($0) + try StoredIdentity + .filter(Column("id") == identityID) + .updateAll($0, Column("instanceURI").set(to: instance.uri)) + + return try Self.fetchIdentity(id: identityID, db: $0) + } + .eraseToAnyPublisher() + } + + func updateAccount(_ account: Account, forIdentityID identityID: String) -> AnyPublisher { + databaseQueue.writePublisher { + try Identity.Account( + id: account.id, + identityID: identityID, + username: account.username, + url: account.url, + avatar: account.avatar, + avatarStatic: account.avatarStatic, + header: account.header, + headerStatic: account.headerStatic) + .save($0) + + return try Self.fetchIdentity(id: identityID, db: $0) + } + .eraseToAnyPublisher() + } + + func identity(id: String) throws -> Identity? { + try databaseQueue.read { try Self.fetchIdentity(id: id, db: $0) } + } +} + +private extension IdentityDatabase { + private static func migrate(_ writer: DatabaseWriter) throws { + var migrator = DatabaseMigrator() + + migrator.registerMigration("createIdentities") { db in + try db.create(table: "instance", ifNotExists: true) { t in + t.column("uri", .text).notNull().primaryKey(onConflict: .replace) + t.column("streamingAPI", .text) + t.column("title", .text) + t.column("thumbnail", .text) + } + + try db.create(table: "storedIdentity", ifNotExists: true) { t in + t.column("id", .text).notNull().primaryKey(onConflict: .replace) + t.column("url", .text).notNull() + t.column("instanceURI", .text) + .indexed() + .references("instance", column: "uri") + } + + try db.create(table: "account", ifNotExists: true) { t in + t.column("id", .text).notNull().primaryKey(onConflict: .replace) + t.column("identityID", .text) + .notNull() + .indexed() + .references("storedIdentity", column: "id", onDelete: .cascade) + t.column("username", .text).notNull() + t.column("url", .text).notNull() + t.column("avatar", .text).notNull() + t.column("avatarStatic", .text).notNull() + t.column("header", .text).notNull() + t.column("headerStatic", .text).notNull() + } + } + + try migrator.migrate(writer) + } + + private static func fetchIdentity(id: String, db: Database) throws -> Identity? { + if let result = try StoredIdentity + .filter(Column("id") == id) + .including(optional: StoredIdentity.instance) + .including(optional: StoredIdentity.account) + .asRequest(of: IdentityResult.self) + .fetchOne(db) { + return Identity(result: result) + } + + return nil + } +} + +private struct StoredIdentity: Codable, Hashable, TableRecord, FetchableRecord, PersistableRecord { + let id: String + let url: URL + let instanceURI: String? +} + +extension StoredIdentity { + static let instance = belongsTo(Identity.Instance.self, key: "instance") + static let account = hasOne(Identity.Account.self, key: "account") + + var instance: QueryInterfaceRequest { + request(for: Self.instance) + } + + var account: QueryInterfaceRequest { + request(for: Self.account) + } +} + +private struct IdentityResult: Codable, Hashable, FetchableRecord { + let identity: StoredIdentity + let instance: Identity.Instance? + let account: Identity.Account? +} + +private extension Identity { + init(result: IdentityResult) { + self.init(id: result.identity.id, url: result.identity.url, instance: result.instance, account: result.account) + } +} + +extension Identity.Instance: TableRecord, FetchableRecord, PersistableRecord {} + +extension Identity.Account: TableRecord, FetchableRecord, PersistableRecord {} diff --git a/Shared/Model/Instance.swift b/Shared/Model/Instance.swift new file mode 100644 index 0000000..b095af6 --- /dev/null +++ b/Shared/Model/Instance.swift @@ -0,0 +1,30 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct Instance: Codable, Hashable { + struct URLs: Codable, Hashable { + let streamingApi: URL + } + + struct Stats: Codable, Hashable { + let userCount: Int + let statusCount: Int + let domainCount: Int + } + + let uri: String + let title: String + let description: String + let shortDescription: String? + let email: String + let version: String + let languages: [String] + let registrations: Bool? + let approvalRequired: Bool? + let invitesEnabled: Bool? + let urls: URLs + let stats: Stats + let thumbnail: URL? + let contactAccount: Account? +} diff --git a/Shared/Model/Keychain.swift b/Shared/Model/Keychain.swift new file mode 100644 index 0000000..196fdfd --- /dev/null +++ b/Shared/Model/Keychain.swift @@ -0,0 +1,64 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +protocol KeychainType { + mutating func set(data: Data, forKey key: String) throws + mutating func deleteData(key: String) throws + func getData(key: String) throws -> Data? +} + +struct Keychain { + let service: String +} + +extension Keychain: KeychainType { + mutating func set(data: Data, forKey key: String) throws { + var query = queryDictionary(key: key) + + query[kSecValueData as String] = data + + let status = SecItemAdd(query as CFDictionary, nil) + + if status != errSecSuccess { + throw NSError(status: status) + } + } + + mutating func deleteData(key: String) throws { + let status = SecItemDelete(queryDictionary(key: key) as CFDictionary) + + if status != errSecSuccess { + throw NSError(status: status) + } + } + + func getData(key: String) throws -> Data? { + var result: AnyObject? + var query = queryDictionary(key: key) + + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnData as String] = kCFBooleanTrue + + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + return result as? Data + case errSecItemNotFound: + return nil + default: + throw NSError(status: status) + } + } +} + +private extension Keychain { + private func queryDictionary(key: String) -> [String: Any] { + [ + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecClass as String: kSecClassGenericPassword + ] + } +} diff --git a/Shared/Model/MastodonError.swift b/Shared/Model/MastodonError.swift new file mode 100644 index 0000000..5db85b0 --- /dev/null +++ b/Shared/Model/MastodonError.swift @@ -0,0 +1,11 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct MastodonError: Error, Codable { + let error: String +} + +extension MastodonError: LocalizedError { + var errorDescription: String? { error } +} diff --git a/Shared/Model/Secrets.swift b/Shared/Model/Secrets.swift new file mode 100644 index 0000000..248ef8d --- /dev/null +++ b/Shared/Model/Secrets.swift @@ -0,0 +1,70 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +protocol SecretsStorable { + var dataStoredInSecrets: Data { get } + static func fromDataStoredInSecrets(_ data: Data) throws -> Self +} + +enum SecretsStorableError: Error { + case conversionFromDataStoredInSecrets(Data) +} + +class Secrets { + private var keychain: KeychainType + + init(keychain: KeychainType) { + self.keychain = keychain + } +} + +extension Secrets { + enum Item: String { + case clientID = "client-id" + case clientSecret = "client-secret" + case accessToken = "access-token" + } +} + +extension Secrets { + func set(_ data: SecretsStorable, forItem item: Item, forIdentityID identityID: String) throws { + try keychain.set(data: data.dataStoredInSecrets, forKey: Self.key(item: item, identityID: identityID)) + } + + func item(_ item: Item, forIdentityID identityID: String) throws -> T? { + guard let data = try keychain.getData(key: Self.key(item: item, identityID: identityID)) else { return nil } + + return try T.fromDataStoredInSecrets(data) + } + + func delete(_ item: Item, forIdentityID identityID: String) throws { + try keychain.deleteData(key: Self.key(item: item, identityID: identityID)) + } +} + +private extension Secrets { + static func key(item: Item, identityID: String) -> String { + identityID + "." + item.rawValue + } +} + +extension Data: SecretsStorable { + var dataStoredInSecrets: Data { self } + + static func fromDataStoredInSecrets(_ data: Data) throws -> Data { + data + } +} + +extension String: SecretsStorable { + var dataStoredInSecrets: Data { Data(utf8) } + + static func fromDataStoredInSecrets(_ data: Data) throws -> String { + guard let string = String(data: data, encoding: .utf8) else { + throw SecretsStorableError.conversionFromDataStoredInSecrets(data) + } + + return string + } +} diff --git a/Shared/Networking/HTTPClient.swift b/Shared/Networking/HTTPClient.swift new file mode 100644 index 0000000..d07c349 --- /dev/null +++ b/Shared/Networking/HTTPClient.swift @@ -0,0 +1,57 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine +import Alamofire + +class HTTPClient { + private let session: Session + private let decoder: DataDecoder + + init( + configuration: URLSessionConfiguration = URLSessionConfiguration.af.default, + decoder: DataDecoder = JSONDecoder()) { + self.session = Session(configuration: configuration) + self.decoder = decoder + } + + func request(_ target: T) -> AnyPublisher { + requestPublisher(target).value().mapError { $0 as Error }.eraseToAnyPublisher() + } + + func request( + _ target: T, + decodeErrorsAs errorType: E.Type) -> AnyPublisher { + let decoder = self.decoder + + return requestPublisher(target) + .tryMap { response -> T.ResultType in + switch response.result { + case let .success(decoded): return decoded + case let .failure(error): + if + let data = response.data, + let decodedError = try? decoder.decode(E.self, from: data) { + throw decodedError + } + + throw error + } + } + .eraseToAnyPublisher() + } +} + +private extension HTTPClient { + private func requestPublisher(_ target: T) -> DataResponsePublisher { + #if DEBUG + if let url = try? target.asURLRequest().url { + StubbingURLProtocol.setTarget(target, forURL: url) + } + #endif + + return session.request(target) + .validate() + .publishDecodable(type: T.ResultType.self, decoder: decoder) + } +} diff --git a/Shared/Networking/HTTPTarget.swift b/Shared/Networking/HTTPTarget.swift new file mode 100644 index 0000000..80001ca --- /dev/null +++ b/Shared/Networking/HTTPTarget.swift @@ -0,0 +1,29 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Alamofire + +protocol HTTPTarget: URLRequestConvertible { + var baseURL: URL { get } + var pathComponents: [String] { get } + var method: HTTPMethod { get } + var encoding: ParameterEncoding { get } + var parameters: [String: Any]? { get } + var headers: HTTPHeaders? { get } +} + +extension HTTPTarget { + func asURLRequest() throws -> URLRequest { + var url = baseURL + + for pathComponent in pathComponents { + url.appendPathComponent(pathComponent) + } + + return try encoding.encode(try URLRequest(url: url, method: method, headers: headers), with: parameters) + } +} + +protocol DecodableTarget: HTTPTarget { + associatedtype ResultType: Decodable +} diff --git a/Shared/Networking/Mastodon API/AccessTokenEndpoint.swift b/Shared/Networking/Mastodon API/AccessTokenEndpoint.swift new file mode 100644 index 0000000..e075766 --- /dev/null +++ b/Shared/Networking/Mastodon API/AccessTokenEndpoint.swift @@ -0,0 +1,45 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Alamofire + +enum AccessTokenEndpoint { + case oauthToken( + clientID: String, + clientSecret: String, + code: String, + grantType: String, + scopes: String, + redirectURI: String + ) +} + +extension AccessTokenEndpoint: MastodonEndpoint { + typealias ResultType = AccessToken + + var context: [String] { [] } + + var pathComponentsInContext: [String] { + ["oauth", "token"] + } + + var method: HTTPMethod { + switch self { + case .oauthToken: return .post + } + } + + var parameters: [String: Any]? { + switch self { + case let .oauthToken(clientID, clientSecret, code, grantType, scopes, redirectURI): + return [ + "client_id": clientID, + "client_secret": clientSecret, + "code": code, + "grant_type": grantType, + "scope": scopes, + "redirect_uri": redirectURI + ] + } + } +} diff --git a/Shared/Networking/Mastodon API/AccountEndpoint.swift b/Shared/Networking/Mastodon API/AccountEndpoint.swift new file mode 100644 index 0000000..fa8eecc --- /dev/null +++ b/Shared/Networking/Mastodon API/AccountEndpoint.swift @@ -0,0 +1,28 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Alamofire + +enum AccountEndpoint { + case verifyCredentials +} + +extension AccountEndpoint: MastodonEndpoint { + typealias ResultType = Account + + var context: [String] { + defaultContext + ["accounts"] + } + + var pathComponentsInContext: [String] { + switch self { + case .verifyCredentials: return ["verify_credentials"] + } + } + + var method: HTTPMethod { + switch self { + case .verifyCredentials: return .get + } + } +} diff --git a/Shared/Networking/Mastodon API/AppAuthorizationEndpoint.swift b/Shared/Networking/Mastodon API/AppAuthorizationEndpoint.swift new file mode 100644 index 0000000..e4d33f2 --- /dev/null +++ b/Shared/Networking/Mastodon API/AppAuthorizationEndpoint.swift @@ -0,0 +1,39 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Alamofire + +enum AppAuthorizationEndpoint { + case apps(clientName: String, redirectURI: String, scopes: String, website: URL?) +} + +extension AppAuthorizationEndpoint: MastodonEndpoint { + typealias ResultType = AppAuthorization + + var pathComponentsInContext: [String] { + switch self { + case .apps: return ["apps"] + } + } + + var method: HTTPMethod { + switch self { + case .apps: return .post + } + } + + var parameters: [String: Any]? { + switch self { + case let .apps(clientName, redirectURI, scopes, website): + var params = [ + "client_name": clientName, + "redirect_uris": redirectURI, + "scopes": scopes + ] + + params["website"] = website?.absoluteString + + return params + } + } +} diff --git a/Shared/Networking/Mastodon API/InstanceEndpoint.swift b/Shared/Networking/Mastodon API/InstanceEndpoint.swift new file mode 100644 index 0000000..fb05817 --- /dev/null +++ b/Shared/Networking/Mastodon API/InstanceEndpoint.swift @@ -0,0 +1,24 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Alamofire + +enum InstanceEndpoint { + case instance +} + +extension InstanceEndpoint: MastodonEndpoint { + typealias ResultType = Instance + + var pathComponentsInContext: [String] { + switch self { + case .instance: return ["instance"] + } + } + + var method: HTTPMethod { + switch self { + case .instance: return .get + } + } +} diff --git a/Shared/Networking/Mastodon API/MastodonAPI.swift b/Shared/Networking/Mastodon API/MastodonAPI.swift new file mode 100644 index 0000000..e30698e --- /dev/null +++ b/Shared/Networking/Mastodon API/MastodonAPI.swift @@ -0,0 +1,28 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +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: "") + } + } +} diff --git a/Shared/Networking/Mastodon API/MastodonClient.swift b/Shared/Networking/Mastodon API/MastodonClient.swift new file mode 100644 index 0000000..cea9df7 --- /dev/null +++ b/Shared/Networking/Mastodon API/MastodonClient.swift @@ -0,0 +1,30 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine +import Alamofire + +class MastodonClient: HTTPClient { + var instanceURL: URL? + var accessToken: String? + + init(configuration: URLSessionConfiguration = URLSessionConfiguration.af.default) { + super.init(configuration: configuration, decoder: MastodonDecoder()) + } + + override func request(_ target: T) -> AnyPublisher { + super.request(target, decodeErrorsAs: MastodonError.self) + } +} + +extension MastodonClient { + func request(_ endpoint: E) -> AnyPublisher { + guard let instanceURL = instanceURL else { + return Fail(error: URLError(.badURL)).eraseToAnyPublisher() + } + + return super.request( + MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: accessToken), + decodeErrorsAs: MastodonError.self) + } +} diff --git a/Shared/Networking/Mastodon API/MastodonEndpoint.swift b/Shared/Networking/Mastodon API/MastodonEndpoint.swift new file mode 100644 index 0000000..dbd97f5 --- /dev/null +++ b/Shared/Networking/Mastodon API/MastodonEndpoint.swift @@ -0,0 +1,42 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Alamofire + +protocol MastodonEndpoint { + associatedtype ResultType: Decodable + var APIVersion: String { get } + var context: [String] { get } + var pathComponentsInContext: [String] { get } + var method: HTTPMethod { get } + var encoding: ParameterEncoding { get } + var parameters: [String: Any]? { get } + var headers: HTTPHeaders? { get } +} + +extension MastodonEndpoint { + var defaultContext: [String] { + ["api", APIVersion] + } + + var APIVersion: String { "v1" } + + var context: [String] { + defaultContext + } + + var pathComponents: [String] { + context + pathComponentsInContext + } + + var encoding: ParameterEncoding { + switch method { + case .get: return URLEncoding.default + default: return JSONEncoding.default + } + } + + var parameters: [String: Any]? { nil } + + var headers: HTTPHeaders? { nil } +} diff --git a/Shared/Networking/Mastodon API/MastodonTarget.swift b/Shared/Networking/Mastodon API/MastodonTarget.swift new file mode 100644 index 0000000..f1c2cdf --- /dev/null +++ b/Shared/Networking/Mastodon API/MastodonTarget.swift @@ -0,0 +1,36 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Alamofire + +struct MastodonTarget { + let baseURL: URL + let endpoint: E + let accessToken: String? +} + +extension MastodonTarget: DecodableTarget { + typealias ResultType = E.ResultType + + var pathComponents: [String] { endpoint.pathComponents } + + var method: HTTPMethod { endpoint.method } + + var encoding: ParameterEncoding { endpoint.encoding } + + var parameters: [String: Any]? { endpoint.parameters } + + var headers: HTTPHeaders? { + var headers = endpoint.headers + + if let accessToken = accessToken { + if headers == nil { + headers = HTTPHeaders() + } + + headers?.add(.authorization(bearerToken: accessToken)) + } + + return headers + } +} diff --git a/Shared/Networking/MastodonDecoder.swift b/Shared/Networking/MastodonDecoder.swift new file mode 100644 index 0000000..fd1fbaa --- /dev/null +++ b/Shared/Networking/MastodonDecoder.swift @@ -0,0 +1,15 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +class MastodonDecoder: JSONDecoder { + override init() { + super.init() + + let dateFormatter = DateFormatter() + + dateFormatter.dateFormat = MastodonAPI.dateFormat + dateDecodingStrategy = .formatted(dateFormatter) + keyDecodingStrategy = .convertFromSnakeCase + } +} diff --git a/Shared/Networking/MastodonEncoder.swift b/Shared/Networking/MastodonEncoder.swift new file mode 100644 index 0000000..c07f0e6 --- /dev/null +++ b/Shared/Networking/MastodonEncoder.swift @@ -0,0 +1,16 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +class MastodonEncoder: JSONEncoder { + override init() { + super.init() + + let dateFormatter = DateFormatter() + + dateFormatter.dateFormat = MastodonAPI.dateFormat + dateEncodingStrategy = .formatted(dateFormatter) + keyEncodingStrategy = .convertToSnakeCase + outputFormatting = .sortedKeys + } +} diff --git a/Shared/Networking/WebAuthenticationSession.swift b/Shared/Networking/WebAuthenticationSession.swift new file mode 100644 index 0000000..c2861d6 --- /dev/null +++ b/Shared/Networking/WebAuthenticationSession.swift @@ -0,0 +1,38 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import AuthenticationServices +import Combine + +protocol WebAuthenticationSessionType: AnyObject { + init(url URL: URL, + callbackURLScheme: String?, + completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler) + var presentationContextProvider: ASWebAuthenticationPresentationContextProviding? { get set } + @discardableResult func start() -> Bool +} + +extension ASWebAuthenticationSession: WebAuthenticationSessionType {} + +extension WebAuthenticationSessionType { + static func publisher( + url: URL, + callbackURLScheme: String?, + presentationContextProvider: ASWebAuthenticationPresentationContextProviding) -> AnyPublisher { + Future { promise in + let webAuthenticationSession = Self( + url: url, + callbackURLScheme: callbackURLScheme) { oauthCallbackURL, error in + if let error = error { + return promise(.failure(error)) + } + + return promise(.success(oauthCallbackURL)) + } + + webAuthenticationSession.presentationContextProvider = presentationContextProvider + webAuthenticationSession.start() + } + .eraseToAnyPublisher() + } +} diff --git a/Shared/View Models/AddIdentityViewModel.swift b/Shared/View Models/AddIdentityViewModel.swift new file mode 100644 index 0000000..32ec2b5 --- /dev/null +++ b/Shared/View Models/AddIdentityViewModel.swift @@ -0,0 +1,208 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine +import AuthenticationServices + +class AddIdentityViewModel: ObservableObject { + @Published var urlFieldText = "" + @Published var alertItem: AlertItem? + @Published private(set) var loading = false + private(set) var addedIdentity: AnyPublisher + + private let networkClient: HTTPClient + private let identityDatabase: IdentityDatabase + private let secrets: Secrets + private let webAuthenticationSessionType: WebAuthenticationSessionType.Type + private let webAuthenticationSessionContextProvider = WebAuthenticationSessionContextProvider() + private let addedIdentityInput = PassthroughSubject() + private var cancellables = Set() + + init( + networkClient: HTTPClient, + identityDatabase: IdentityDatabase, + secrets: Secrets, + webAuthenticationSessionType: WebAuthenticationSessionType.Type = ASWebAuthenticationSession.self) { + self.networkClient = networkClient + self.identityDatabase = identityDatabase + self.secrets = secrets + self.webAuthenticationSessionType = webAuthenticationSessionType + addedIdentity = addedIdentityInput.eraseToAnyPublisher() + } + + func goTapped() { + let identityID = UUID().uuidString + let instanceURL: URL + let redirectURL: URL + + do { + instanceURL = try urlFieldText.url() + redirectURL = try identityID.url(scheme: MastodonAPI.OAuth.callbackURLScheme) + } catch { + alertItem = AlertItem(error: error) + + return + } + + authorizeApp( + identityID: identityID, + instanceURL: instanceURL, + redirectURL: redirectURL, + secrets: secrets) + .authenticationURL(instanceURL: instanceURL, redirectURL: redirectURL) + .authenticate( + webAuthenticationSessionType: webAuthenticationSessionType, + contextProvider: webAuthenticationSessionContextProvider, + callbackURLScheme: MastodonAPI.OAuth.callbackURLScheme) + .extractCode() + .requestAccessToken( + networkClient: networkClient, + identityID: identityID, + instanceURL: instanceURL) + .createIdentity( + id: identityID, + instanceURL: instanceURL, + identityDatabase: identityDatabase, + secrets: secrets) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .handleEvents( + receiveSubscription: { [weak self] _ in self?.loading = true }, + receiveCompletion: { [weak self] _ in self?.loading = false }) + .sink(receiveValue: addedIdentityInput.send) + .store(in: &cancellables) + } +} + +private extension AddIdentityViewModel { + private class WebAuthenticationSessionContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + ASPresentationAnchor() + } + } + + private func authorizeApp( + identityID: String, + instanceURL: URL, + redirectURL: URL, + secrets: Secrets) -> AnyPublisher { + 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 { + try secrets.set($0.clientId, forItem: .clientID, forIdentityID: identityID) + try secrets.set($0.clientSecret, forItem: .clientSecret, forIdentityID: identityID) + + 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( + webAuthenticationSessionType: WebAuthenticationSessionType.Type, + contextProvider: ASWebAuthenticationPresentationContextProviding, + callbackURLScheme: String) -> AnyPublisher<(AppAuthorization, URL), Error> { + flatMap { appAuthorization, url in + webAuthenticationSessionType.publisher( + url: url, + callbackURLScheme: callbackURLScheme, + presentationContextProvider: contextProvider) + .tryCatch { error -> AnyPublisher in + if (error as? ASWebAuthenticationSessionError)?.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) { + // swiftlint:disable large_tuple + func extractCode() -> AnyPublisher<(AppAuthorization, URL, String), Error> { + tryMap { appAuthorization, url -> (AppAuthorization, URL, 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, url, code) + } + .eraseToAnyPublisher() + } + // swiftlint:enable large_tuple +} + +private extension Publisher where Output == (AppAuthorization, URL, String), Failure == Error { + func requestAccessToken( + networkClient: HTTPClient, + identityID: String, + instanceURL: URL) -> AnyPublisher { + flatMap { appAuthorization, url, code -> AnyPublisher in + let endpoint = AccessTokenEndpoint.oauthToken( + clientID: appAuthorization.clientId, + clientSecret: appAuthorization.clientSecret, + code: code, + grantType: MastodonAPI.OAuth.grantType, + scopes: MastodonAPI.OAuth.scopes, + redirectURI: url.absoluteString) + let target = MastodonTarget(baseURL: instanceURL, endpoint: endpoint, accessToken: nil) + + return networkClient.request(target) + } + .eraseToAnyPublisher() + } +} + +private extension Publisher where Output == AccessToken { + func createIdentity( + id: String, + instanceURL: URL, + identityDatabase: IdentityDatabase, + secrets: Secrets) -> AnyPublisher { + tryMap { accessToken -> (String, URL) in + try secrets.set(accessToken.accessToken, forItem: .accessToken, forIdentityID: id) + + return (id, instanceURL) + } + .flatMap(identityDatabase.createIdentity) + .eraseToAnyPublisher() + } +} diff --git a/Shared/View Models/SceneViewModel.swift b/Shared/View Models/SceneViewModel.swift new file mode 100644 index 0000000..129ae89 --- /dev/null +++ b/Shared/View Models/SceneViewModel.swift @@ -0,0 +1,127 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import Combine + +class SceneViewModel: ObservableObject { + @Published private(set) var identity: Identity? { + didSet { + if let identity = identity { + recentIdentityID = identity.id + networkClient.instanceURL = identity.url + + do { + networkClient.accessToken = try secrets.item(.accessToken, forIdentityID: identity.id) + } catch { + alertItem = AlertItem(error: error) + } + } + } + } + + @Published var alertItem: AlertItem? + var selectedTopLevelNavigation: TopLevelNavigation? = .timelines + + private let networkClient: MastodonClient + private let identityDatabase: IdentityDatabase + private let secrets: Secrets + private let userDefaults: UserDefaults + private var cancellables = Set() + + init(networkClient: MastodonClient, + identityDatabase: IdentityDatabase, + secrets: Secrets, + userDefaults: UserDefaults = .standard) { + self.networkClient = networkClient + self.identityDatabase = identityDatabase + self.secrets = secrets + self.userDefaults = userDefaults + + if let recentIdentityID = recentIdentityID { + identity = try? identityDatabase.identity(id: recentIdentityID) + refreshIdentity() + } + } +} + +extension SceneViewModel { + func refreshIdentity() { + guard let identity = identity else { return } + + if networkClient.accessToken != nil { + networkClient.request(AccountEndpoint.verifyCredentials) + .map { ($0, identity.id) } + .flatMap(identityDatabase.updateAccount) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .assign(to: \.identity, on: self) + .store(in: &cancellables) + } + + networkClient.request(InstanceEndpoint.instance) + .map { ($0, identity.id) } + .flatMap(identityDatabase.updateInstance) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .assign(to: \.identity, on: self) + .store(in: &cancellables) + } + + func addIdentityViewModel() -> AddIdentityViewModel { + let addAccountViewModel = AddIdentityViewModel( + networkClient: networkClient, + identityDatabase: identityDatabase, + secrets: secrets) + + addAccountViewModel.addedIdentity + .sink(receiveValue: addIdentity(_:)) + .store(in: &cancellables) + + return addAccountViewModel + } +} + +private extension SceneViewModel { + private static let recentIdentityIDKey = "recentIdentityID" + + private var recentIdentityID: String? { + get { userDefaults.value(forKey: Self.recentIdentityIDKey) as? String } + set { userDefaults.set(newValue, forKey: Self.recentIdentityIDKey) } + } + + private func addIdentity(_ identity: Identity) { + self.identity = identity + refreshIdentity() + } +} + +extension SceneViewModel { + enum TopLevelNavigation: CaseIterable { + case timelines + case search + case notifications + case messages + } +} + +extension SceneViewModel.TopLevelNavigation { + var title: String { + switch self { + case .timelines: return "Timelines" + case .search: return "Search" + case .notifications: return "Notifications" + case .messages: return "Messages" + } + } + + var systemImageName: String { + switch self { + case .timelines: return "house" + case .search: return "magnifyingglass" + case .notifications: return "bell" + case .messages: return "envelope" + } + } +} + +extension SceneViewModel.TopLevelNavigation: Identifiable { + var id: Self { self } +} diff --git a/Shared/View Models/TimelineViewModel.swift b/Shared/View Models/TimelineViewModel.swift new file mode 100644 index 0000000..0072ee7 --- /dev/null +++ b/Shared/View Models/TimelineViewModel.swift @@ -0,0 +1,7 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +class TimelineViewModel: ObservableObject { + +} diff --git a/Shared/Views/AddIdentityView.swift b/Shared/Views/AddIdentityView.swift new file mode 100644 index 0000000..a3d0240 --- /dev/null +++ b/Shared/Views/AddIdentityView.swift @@ -0,0 +1,48 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI + +struct AddIdentityView: View { + @StateObject var viewModel: AddIdentityViewModel + + var body: some View { + Form { + #if os(iOS) + urlTextField + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.URL) + #else + urlTextField + #endif + Group { + if viewModel.loading { + ProgressView() + } else { + Button( + action: viewModel.goTapped, + label: { Text("go") }) + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + .alertItem($viewModel.alertItem) + } +} + +extension AddIdentityView { + private var urlTextField: some View { + TextField("add-identity.instance-url", text: $viewModel.urlFieldText) + } +} + +struct AddAccountView_Previews: PreviewProvider { + static var previews: some View { + AddIdentityView(viewModel: AddIdentityViewModel( + networkClient: MastodonClient(configuration: .stubbing), + // swiftlint:disable force_try + identityDatabase: try! IdentityDatabase(inMemory: true), + // swiftlint:enable force_try + secrets: Secrets(keychain: FakeKeychain()))) + } +} diff --git a/Shared/Views/ContentView.swift b/Shared/Views/ContentView.swift new file mode 100644 index 0000000..4dfa7b2 --- /dev/null +++ b/Shared/Views/ContentView.swift @@ -0,0 +1,42 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var sceneViewModel: SceneViewModel + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + if sceneViewModel.identity != nil { + mainNavigation + .onChange(of: scenePhase) { + if case .active = $0 { + sceneViewModel.refreshIdentity() + } + } + .alertItem($sceneViewModel.alertItem) + } else { + addIdentity + } + } +} + +private extension ContentView { + private var mainNavigation: some View { + #if os(macOS) + return SidebarNavigation().frame(minWidth: 900, maxWidth: .infinity, minHeight: 500, maxHeight: .infinity) + #else + return TabNavigation() + #endif + } + + private var addIdentity: some View { + AddIdentityView(viewModel: sceneViewModel.addIdentityViewModel()) + } +} + +//struct ContentView_Previews: PreviewProvider { +// static var previews: some View { +// ContentView() +// } +//} diff --git a/Shared/ContentView.swift b/Shared/Views/TimelineView.swift similarity index 52% rename from Shared/ContentView.swift rename to Shared/Views/TimelineView.swift index e69633f..c5e76a2 100644 --- a/Shared/ContentView.swift +++ b/Shared/Views/TimelineView.swift @@ -2,14 +2,14 @@ import SwiftUI -struct ContentView: View { +struct TimelineView: View { var body: some View { - Text("Hello, world!").padding() + Text("Time of my life") } } -struct ContentView_Previews: PreviewProvider { +struct TimelineView_Previews: PreviewProvider { static var previews: some View { - ContentView() + TimelineView() } } diff --git a/Tests iOS/Tests_iOS.swift b/Tests iOS/Tests_iOS.swift deleted file mode 100644 index 49fd460..0000000 --- a/Tests iOS/Tests_iOS.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import XCTest - -class Tests_iOS: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use recording to get started writing UI tests. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } - } -} diff --git a/Tests macOS/Info.plist b/Tests macOS/Info.plist deleted file mode 100644 index 64d65ca..0000000 --- a/Tests macOS/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/Tests macOS/Tests_macOS.swift b/Tests macOS/Tests_macOS.swift deleted file mode 100644 index 3f49b42..0000000 --- a/Tests macOS/Tests_macOS.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import XCTest - -class Tests_macOS: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use recording to get started writing UI tests. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } - } -} diff --git a/Tests iOS/Info.plist b/Tests/Info.plist similarity index 100% rename from Tests iOS/Info.plist rename to Tests/Info.plist diff --git a/Tests/View Models/AddIdentityViewModelTests.swift b/Tests/View Models/AddIdentityViewModelTests.swift new file mode 100644 index 0000000..c05fcfd --- /dev/null +++ b/Tests/View Models/AddIdentityViewModelTests.swift @@ -0,0 +1,78 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import XCTest +import Combine +import CombineExpectations +@testable import Metatext + +class AddIdentityViewModelTests: XCTestCase { + var networkClient: MastodonClient! + var identityDatabase: IdentityDatabase! + var secrets: Secrets! + var cancellables = Set() + + override func setUpWithError() throws { + networkClient = MastodonClient(configuration: .stubbing) + identityDatabase = try IdentityDatabase(inMemory: true) + secrets = Secrets(keychain: FakeKeychain()) + } + + func testAddIdentity() throws { + let sut = AddIdentityViewModel( + networkClient: networkClient, + identityDatabase: identityDatabase, + secrets: secrets, + webAuthenticationSessionType: SuccessfulStubbingWebAuthenticationSession.self) + let recorder = sut.addedIdentity.record() + + sut.urlFieldText = "https://mastodon.social" + sut.goTapped() + + let addedIdentity = try wait(for: recorder.next(), timeout: 1) + + XCTAssertEqual(try identityDatabase.identity(id: addedIdentity.id), addedIdentity) + XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) + XCTAssertEqual( + try secrets.item(.clientID, forIdentityID: addedIdentity.id) as String?, + "AUTHORIZATION_CLIENT_ID_STUB_VALUE") + XCTAssertEqual( + try secrets.item(.clientSecret, forIdentityID: addedIdentity.id) as String?, + "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE") + XCTAssertEqual( + try secrets.item(.accessToken, forIdentityID: addedIdentity.id) as String?, + "ACCESS_TOKEN_STUB_VALUE") + } + + func testAddIdentityWithoutScheme() throws { + let sut = AddIdentityViewModel( + networkClient: networkClient, + identityDatabase: identityDatabase, + secrets: secrets, + webAuthenticationSessionType: SuccessfulStubbingWebAuthenticationSession.self) + let recorder = sut.addedIdentity.record() + + sut.urlFieldText = "mastodon.social" + sut.goTapped() + + let addedIdentity = try wait(for: recorder.next(), timeout: 1) + + XCTAssertEqual(try identityDatabase.identity(id: addedIdentity.id), addedIdentity) + XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) + } + + func testInvalidURL() throws { + let sut = AddIdentityViewModel( + networkClient: networkClient, + identityDatabase: identityDatabase, + secrets: secrets, + webAuthenticationSessionType: SuccessfulStubbingWebAuthenticationSession.self) + let recorder = sut.$alertItem.dropFirst().record() + + sut.urlFieldText = "🐘.social" + sut.goTapped() + + let alertItem = try wait(for: recorder.next(), timeout: 1) + + XCTAssertEqual((alertItem?.error as? URLError)?.code, URLError.badURL) + } +} diff --git a/iOS/TabNavigation.swift b/iOS/TabNavigation.swift new file mode 100644 index 0000000..2e6eda3 --- /dev/null +++ b/iOS/TabNavigation.swift @@ -0,0 +1,43 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI + +struct TabNavigation: View { + @EnvironmentObject var sceneViewModel: SceneViewModel + + var body: some View { + TabView(selection: $sceneViewModel.selectedTopLevelNavigation) { + ForEach(SceneViewModel.TopLevelNavigation.allCases) { topLevelNavigation in + NavigationView { + view(topLevelNavigation: topLevelNavigation) + } + .tabItem { + Label(topLevelNavigation.title, systemImage: topLevelNavigation.systemImageName) + .accessibility(label: Text(topLevelNavigation.title)) + } + .tag(topLevelNavigation) + } + } + } +} + +private extension TabNavigation { + func view(topLevelNavigation: SceneViewModel.TopLevelNavigation) -> some View { + Group { + switch topLevelNavigation { + case .timelines: + TimelineView() + .navigationBarTitle(sceneViewModel.identity?.handle ?? "", displayMode: .inline) + default: Text(topLevelNavigation.title) + } + } + } +} + +// MARK: Preview + +struct TabNavigation_Previews: PreviewProvider { + static var previews: some View { + TabNavigation() + } +} diff --git a/macOS/SidebarNavigation.swift b/macOS/SidebarNavigation.swift new file mode 100644 index 0000000..9edcbfb --- /dev/null +++ b/macOS/SidebarNavigation.swift @@ -0,0 +1,47 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI + +struct SidebarNavigation: View { + @EnvironmentObject var sceneViewModel: SceneViewModel + + var sidebar: some View { + List(selection: $sceneViewModel.selectedTopLevelNavigation) { + ForEach(SceneViewModel.TopLevelNavigation.allCases) { topLevelNavigation in + NavigationLink(destination: view(topLevelNavigation: topLevelNavigation)) { + Label(topLevelNavigation.title, systemImage: topLevelNavigation.systemImageName) + } + .accessibility(label: Text(topLevelNavigation.title)) + .tag(topLevelNavigation) + } + } + .listStyle(SidebarListStyle()) + } + + var body: some View { + NavigationView { + sidebar + .frame(minWidth: 100, idealWidth: 150, maxWidth: 200, maxHeight: .infinity) + Text("Content") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +private extension SidebarNavigation { + func view(topLevelNavigation: SceneViewModel.TopLevelNavigation) -> some View { + Group { + switch topLevelNavigation { + case .timelines: + TimelineView() + default: Text(topLevelNavigation.title) + } + } + } +} + +struct SidebarNavigation_Previews: PreviewProvider { + static var previews: some View { + SidebarNavigation() + } +} diff --git a/macOS/macOS.entitlements b/macOS/macOS.entitlements index f2ef3ae..625af03 100644 --- a/macOS/macOS.entitlements +++ b/macOS/macOS.entitlements @@ -2,9 +2,11 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client +