diff --git a/apple/InfoPlists/MacOmnivore.plist b/apple/InfoPlists/MacOmnivore.plist index 7224e9b7f..d18829a1d 100644 --- a/apple/InfoPlists/MacOmnivore.plist +++ b/apple/InfoPlists/MacOmnivore.plist @@ -28,6 +28,14 @@ omnivore + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.267918240109-bdghlau7nsq2480c4l8gdgh6mrarokta + + CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/apple/InfoPlists/Omnivore.plist b/apple/InfoPlists/Omnivore.plist index 947099c90..c9a47cec4 100644 --- a/apple/InfoPlists/Omnivore.plist +++ b/apple/InfoPlists/Omnivore.plist @@ -32,6 +32,14 @@ omnivore + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.267918240109-bdghlau7nsq2480c4l8gdgh6mrarokta + + CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/apple/Omnivore.xcodeproj/project.pbxproj b/apple/Omnivore.xcodeproj/project.pbxproj index 473fe264d..4625c4ea4 100644 --- a/apple/Omnivore.xcodeproj/project.pbxproj +++ b/apple/Omnivore.xcodeproj/project.pbxproj @@ -1244,6 +1244,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -1274,6 +1275,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; @@ -1560,6 +1562,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.Omnivore-Extension"; PRODUCT_NAME = SafariExtension; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -1597,6 +1600,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.Omnivore-Extension"; PRODUCT_NAME = SafariExtension; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; @@ -1664,7 +1668,8 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = Entitlements/ShareExtension.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QJF2XZ86HB; INFOPLIST_FILE = InfoPlists/ShareExtension.plist; @@ -1677,6 +1682,7 @@ MARKETING_VERSION = 1.10.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SWIFT_COMPILATION_MODE = wholemodule; @@ -1746,7 +1752,8 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = Entitlements/ShareExtension.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QJF2XZ86HB; INFOPLIST_FILE = InfoPlists/ShareExtension.plist; @@ -1759,6 +1766,7 @@ MARKETING_VERSION = 1.10.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; diff --git a/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved index fa6f4cbb3..7d1eeae0b 100644 --- a/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/openid/AppAuth-iOS.git", "state" : { - "revision" : "01131d68346c8ae552961c768d583c715fbe1410", - "version" : "1.4.0" + "revision" : "33660c271c961f8ce1084cc13f2ea8195e864f7d", + "version" : "1.5.0" } }, { @@ -72,6 +72,15 @@ "version" : "9.1.2" } }, + { + "identity" : "googlesignin-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleSignIn-iOS", + "state" : { + "revision" : "9450e779619fc184d360c9f7ce61023587f7e1f4", + "version" : "6.2.2" + } + }, { "identity" : "googleutilities", "kind" : "remoteSourceControl", @@ -99,6 +108,15 @@ "version" : "1.7.0" } }, + { + "identity" : "gtmappauth", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GTMAppAuth.git", + "state" : { + "revision" : "b9d1683be336ba8c8d1c6867bafeb056a5399700", + "version" : "1.3.0" + } + }, { "identity" : "intercom-ios", "kind" : "remoteSourceControl", diff --git a/apple/OmnivoreKit/Package.swift b/apple/OmnivoreKit/Package.swift index 6fafc0cb0..f5ef78249 100644 --- a/apple/OmnivoreKit/Package.swift +++ b/apple/OmnivoreKit/Package.swift @@ -32,7 +32,7 @@ let package = Package( .target( name: "Services", dependencies: [ - .product(name: "AppAuth", package: "AppAuth-iOS"), + .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), "Valet", .product(name: "SwiftGraphQL", package: "swift-graphql"), "Models", @@ -63,11 +63,11 @@ var appPackageDependencies: [Target.Dependency] { var dependencies: [Package.Dependency] { var deps: [Package.Dependency] = [ - .package(url: "https://github.com/openid/AppAuth-iOS.git", .upToNextMajor(from: "1.4.0")), .package(url: "https://github.com/Square/Valet", from: "4.1.2"), .package(url: "https://github.com/maticzav/swift-graphql", from: "2.3.1"), .package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.4"), - .package(url: "git@github.com:segmentio/analytics-swift.git", .upToNextMajor(from: "1.0.0")) + .package(url: "git@github.com:segmentio/analytics-swift.git", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2") ] // #if canImport(UIKit) deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", branch: "master")) diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift index 1b3225cfe..1d262edb7 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionScene.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import Models import Services diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift index 7d8fc2a40..cde19d366 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift @@ -1,4 +1,3 @@ -import Combine import CoreData import Models import Services @@ -11,8 +10,6 @@ import Views let item: LinkedItem? @Published var webAppWrapperViewModel: WebAppWrapperViewModel? - var subscriptions = Set() - init(linkedItemObjectID: NSManagedObjectID, dataService: DataService) { if let linkedItem = dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem { self.pdfItem = PDFItem.make(item: linkedItem) diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift index e218f96b2..2b8e71003 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/CreateProfileView.swift @@ -5,7 +5,7 @@ import SwiftUI import Utils import Views -final class CreateProfileViewModel: ObservableObject { +@MainActor final class CreateProfileViewModel: ObservableObject { private(set) var initialUserProfile = UserProfile(username: "", name: "", bio: nil) var isConfigured = false @@ -39,7 +39,9 @@ final class CreateProfileViewModel: ObservableObject { switch profileOrError { case let .left(userProfile): - submitProfile(userProfile: userProfile, authenticator: authenticator) + Task { + await submitProfile(userProfile: userProfile, authenticator: authenticator) + } case let .right(errorMessage): validationErrorMessage = errorMessage } @@ -51,41 +53,37 @@ final class CreateProfileViewModel: ObservableObject { return } - dataService.validateUsernamePublisher(username: username).sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(usernameError) = completion else { return } + Task { + do { + try await dataService.validateUsernamePublisher(username: username) + } catch { + let usernameError = (error as? UsernameAvailabilityError) ?? .unknown switch usernameError { case .tooShort: - self?.potentialUsernameStatus = .tooShort + potentialUsernameStatus = .tooShort case .tooLong: - self?.potentialUsernameStatus = .tooLong + potentialUsernameStatus = .tooLong case .invalidPattern: - self?.potentialUsernameStatus = .invalidPattern + potentialUsernameStatus = .invalidPattern case .nameUnavailable: - self?.potentialUsernameStatus = .unavailable + potentialUsernameStatus = .unavailable case .internalServer, .unknown: - self?.loginError = .unknown + loginError = .unknown case .network: - self?.loginError = .network + loginError = .network } - }, - receiveValue: { [weak self] in - self?.potentialUsernameStatus = .available } - ) - .store(in: &subscriptions) + } } - func submitProfile(userProfile: UserProfile, authenticator: Authenticator) { - authenticator - .createAccount(userProfile: userProfile).sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(loginError) = completion else { return } - self?.loginError = loginError - }, - receiveValue: { _ in } - ) - .store(in: &subscriptions) + func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async { + do { + try await authenticator.createAccount(userProfile: userProfile) + } catch { + if let error = error as? LoginError { + loginError = error + } + } } func configure(profile: UserProfile, dataService: DataService) { diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift index d24298116..3bb561ac5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/NewAppleSignupView.swift @@ -1,27 +1,22 @@ -import Combine import Models import Services import SwiftUI import Utils import Views -final class NewAppleSignupViewModel: ObservableObject { +@MainActor final class NewAppleSignupViewModel: ObservableObject { @Published var loginError: LoginError? - var subscriptions = Set() - init() {} - func submitProfile(userProfile: UserProfile, authenticator: Authenticator) { - authenticator - .createAccount(userProfile: userProfile).sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(loginError) = completion else { return } - self?.loginError = loginError - }, - receiveValue: { _ in } - ) - .store(in: &subscriptions) + func submitProfile(userProfile: UserProfile, authenticator: Authenticator) async { + do { + try await authenticator.createAccount(userProfile: userProfile) + } catch { + if let error = error as? LoginError { + loginError = error + } + } } } @@ -48,7 +43,11 @@ struct NewAppleSignupView: View { VStack { Button( - action: { viewModel.submitProfile(userProfile: userProfile, authenticator: authenticator) }, + action: { + Task { + await viewModel.submitProfile(userProfile: userProfile, authenticator: authenticator) + } + }, label: { Text("Continue") } ) .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) diff --git a/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift index f7d5a496c..863ac8751 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Registration/RegistrationView.swift @@ -1,12 +1,11 @@ import AuthenticationServices -import Combine import Models import Services import SwiftUI import Utils import Views -final class RegistrationViewModel: ObservableObject { +@MainActor final class RegistrationViewModel: ObservableObject { enum RegistrationState { case createProfile(userProfile: UserProfile) case newAppleSignUp(userProfile: UserProfile) @@ -15,12 +14,10 @@ final class RegistrationViewModel: ObservableObject { @Published var loginError: LoginError? @Published var registrationState: RegistrationState? - var subscriptions = Set() - func handleAppleSignInCompletion(result: Result, authenticator: Authenticator) { switch AppleSigninPayload.parse(authResult: result) { case let .success(payload): - handleAppleToken(payload: payload, authenticator: authenticator) + Task { await handleAppleToken(payload: payload, authenticator: authenticator) } case let .failure(error): switch error { case .unauthorized, .unknown: @@ -31,56 +28,48 @@ final class RegistrationViewModel: ObservableObject { } } - private func handleAppleToken(payload: AppleSigninPayload, authenticator: Authenticator) { - authenticator.submitAppleToken(token: payload.token).sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(loginError) = completion else { return } - switch loginError { - case .unauthorized, .unknown: - self?.handleAppleSignUp(authenticator: authenticator, payload: payload) - case .network: - self?.loginError = loginError - } - }, - receiveValue: { _ in } - ) - .store(in: &subscriptions) + private func handleAppleToken(payload: AppleSigninPayload, authenticator: Authenticator) async { + do { + try await authenticator.submitAppleToken(token: payload.token) + } catch { + let submitTokenError = (error as? LoginError) ?? .unknown + switch submitTokenError { + case .unauthorized, .unknown: + await handleAppleSignUp(authenticator: authenticator, payload: payload) + case .network: + loginError = submitTokenError + } + } } - private func handleAppleSignUp(authenticator: Authenticator, payload: AppleSigninPayload) { - authenticator - .createPendingAccountUsingApple(token: payload.token, name: payload.fullName) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(loginError) = completion else { return } - self?.loginError = loginError - }, - receiveValue: { [weak self] userProfile in - if userProfile.name.isEmpty { - self?.registrationState = .createProfile(userProfile: userProfile) - } else { - self?.registrationState = .newAppleSignUp(userProfile: userProfile) - } - } + private func handleAppleSignUp(authenticator: Authenticator, payload: AppleSigninPayload) async { + do { + let pendingUserProfile = try await authenticator.createPendingAccountUsingApple( + token: payload.token, + name: payload.fullName ) - .store(in: &subscriptions) + if pendingUserProfile.name.isEmpty { + registrationState = .createProfile(userProfile: pendingUserProfile) + } else { + registrationState = .newAppleSignUp(userProfile: pendingUserProfile) + } + } catch { + loginError = (error as? LoginError) ?? .unknown + } } - func handleGoogleAuth(authenticator: Authenticator) { - authenticator - .handleGoogleAuth(presentingViewController: presentingViewController()) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(loginError) = completion else { return } - self?.loginError = loginError - }, - receiveValue: { [weak self] isNewAccount in - if isNewAccount { - self?.registrationState = .createProfile(userProfile: UserProfile(username: "", name: "")) - } - } - ) - .store(in: &subscriptions) + func handleGoogleAuth(authenticator: Authenticator) async { + guard let presentingViewController = presentingViewController() else { return } + let googleAuthResponse = await authenticator.handleGoogleAuth(presenting: presentingViewController) + + switch googleAuthResponse { + case let .loginError(error): + loginError = error + case .newOmnivoreUser: + registrationState = .createProfile(userProfile: UserProfile(username: "", name: "")) + case .existingOmnivoreUser: + break + } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index a946c7eb7..77f6c2982 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -97,6 +97,7 @@ struct InnerRootView: View { } } #endif + .onOpenURL { Authenticator.handleGoogleURL(url: $0) } } #if os(iOS) diff --git a/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift b/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift index 4a45a70e3..7ba4f8307 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WelcomeView.swift @@ -1,4 +1,3 @@ -import Combine import Models import Services import SwiftUI @@ -139,7 +138,9 @@ struct WelcomeView: View { if AppKeys.sharedInstance?.iosClientGoogleId != nil { GoogleAuthButton { - viewModel.handleGoogleAuth(authenticator: authenticator) + Task { + await viewModel.handleGoogleAuth(authenticator: authenticator) + } } } } diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift index 468cfa4d2..a0e044364 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AccountCreator.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import Models @@ -6,12 +5,12 @@ public extension Authenticator { func createPendingAccountUsingApple( token: String, name: PersonNameComponents? - ) -> AnyPublisher { + ) async throws -> UserProfile { let params = CreatePendingAccountParams(token: token, provider: .apple, fullName: name) - return createPendingAccount(params: params) + return try await createPendingAccount(params: params) } - func createAccount(userProfile: UserProfile) -> AnyPublisher { + func createAccount(userProfile: UserProfile) async throws { let params = CreateAccountParams( pendingUserToken: pendingUserToken ?? "", userProfile: userProfile @@ -19,36 +18,29 @@ public extension Authenticator { let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() - return networker - .createAccount(params: encodedParams) - .tryMap { [weak self] in - try ValetKey.authCookieString.setValue($0.commentedAuthCookieString) - try ValetKey.authToken.setValue($0.authToken) - self?.pendingUserToken = nil - self?.isLoggedIn = true + do { + let authPayload = try await networker.createAccount(params: encodedParams) + try ValetKey.authCookieString.setValue(authPayload.commentedAuthCookieString) + try ValetKey.authToken.setValue(authPayload.authToken) + DispatchQueue.main.async { + self.pendingUserToken = nil + self.isLoggedIn = true } - .mapError { error in - let serverError = (error as? ServerError) ?? ServerError.unknown - return LoginError.make(serverError: serverError) - } - .eraseToAnyPublisher() + } catch { + let serverError = (error as? ServerError) ?? ServerError.unknown + throw LoginError.make(serverError: serverError) + } } } extension Authenticator { - func createPendingAccount(params: CreatePendingAccountParams) -> AnyPublisher { - let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() - - return networker - .createPendingUser(params: encodedParams) - .tryMap { [weak self] in - self?.pendingUserToken = $0.pendingUserToken - return $0.pendingUserProfile - } - .mapError { error in - let serverError = (error as? ServerError) ?? ServerError.unknown - return LoginError.make(serverError: serverError) - } - .eraseToAnyPublisher() + func createPendingAccount(params: CreatePendingAccountParams) async throws -> UserProfile { + do { + let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() + let pendingUserAuthPayload = try await networker.createPendingUser(params: encodedParams) + return pendingUserAuthPayload.pendingUserProfile + } catch { + throw LoginError.make(serverError: (error as? ServerError) ?? .unknown) + } } } diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AppleAuth.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AppleAuth.swift index 22475dc5e..751c53558 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AppleAuth.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AppleAuth.swift @@ -1,20 +1,18 @@ -import Combine import Foundation import Models public extension Authenticator { - func submitAppleToken(token: String) -> AnyPublisher { - networker - .submitAppleToken(token: token) - .tryMap { [weak self] in - try ValetKey.authCookieString.setValue($0.commentedAuthCookieString) - try ValetKey.authToken.setValue($0.authToken) - self?.isLoggedIn = true + func submitAppleToken(token: String) async throws { + do { + let authPayload = try await networker.submitAppleToken(token: token) + try ValetKey.authCookieString.setValue(authPayload.commentedAuthCookieString) + try ValetKey.authToken.setValue(authPayload.authToken) + DispatchQueue.main.async { + self.isLoggedIn = true } - .mapError { error in - let serverError = (error as? ServerError) ?? ServerError.unknown - return LoginError.make(serverError: serverError) - } - .eraseToAnyPublisher() + } catch { + let serverError = (error as? ServerError) ?? ServerError.unknown + throw LoginError.make(serverError: serverError) + } } } diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift b/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift index a31c56ba3..cd2f0c66b 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/Authenticator.swift @@ -1,6 +1,5 @@ -import AppAuth -import Combine import Foundation +import GoogleSignIn import Models import Utils import WebKit @@ -8,6 +7,10 @@ import WebKit public final class Authenticator: ObservableObject { public static var unregisterIntercomUser: (() -> Void)? + public static func handleGoogleURL(url: URL) { + GIDSignIn.sharedInstance.handle(url) + } + public enum AuthStatus { case loggedOut case pendingUser @@ -15,18 +18,11 @@ public final class Authenticator: ObservableObject { } @Published public internal(set) var isLoggedIn: Bool - @Published public var pendinguserProfile = UserProfile(username: "", name: "", bio: nil) let networker: Networker - var subscriptions = Set() - var currentAuthorizationFlow: OIDExternalUserAgentSession? var pendingUserToken: String? - #if os(macOS) - var authRedirectHandler: OIDRedirectHTTPHandler? - #endif - public init(networker: Networker) { self.networker = networker self.isLoggedIn = ValetKey.authToken.exists diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/GoogleAuth.swift b/apple/OmnivoreKit/Sources/Services/Authentication/GoogleAuth.swift index be2429db2..1302aae7a 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/GoogleAuth.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/GoogleAuth.swift @@ -1,123 +1,73 @@ -import AppAuth -import Combine import Foundation +import GoogleSignIn import Models import Utils -#if os(iOS) - import UIKit +public enum GoogleAuthResponse { + case loginError(error: LoginError) + case newOmnivoreUser + case existingOmnivoreUser +} - public extension Authenticator { - func handleGoogleAuth(presentingViewController: PlatformViewController?) -> AnyPublisher { - Future { [weak self] promise in - guard let self = self, let presenting = presentingViewController else { return } +extension Authenticator { + public func handleGoogleAuth(presenting: PlatformViewController) async -> GoogleAuthResponse { + let idToken = try? await googleSignIn(presenting: presenting) + guard let idToken = idToken else { return .loginError(error: .unauthorized) } - // swiftlint:disable:next line_length - self.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: self.googleAuthRequest(redirectURL: nil), presenting: presenting) { authState, authError in - self.resolveAuthResponse(promise: promise, authState: authState, authError: authError) - } + do { + let authPayload = try await networker.submitGoogleToken(idToken: idToken) + try ValetKey.authCookieString.setValue(authPayload.commentedAuthCookieString) + try ValetKey.authToken.setValue(authPayload.authToken) + DispatchQueue.main.async { + self.isLoggedIn = true } - .eraseToAnyPublisher() - } - } -#endif + return .existingOmnivoreUser + } catch { + let loginError = (error as? LoginError) ?? .unknown -#if os(macOS) - import AppKit - - public extension Authenticator { - func handleGoogleAuth(presentingViewController _: PlatformViewController?) -> AnyPublisher { - authRedirectHandler = OIDRedirectHTTPHandler( - successURL: URL(string: "https://omnivore.app")! - ) - let redirectURL = authRedirectHandler?.startHTTPListener(nil) - let authRequest = googleAuthRequest(redirectURL: redirectURL) - - return Future { [weak self] promise in - guard let self = self else { return } - - // swiftlint:disable:next line_length - self.authRedirectHandler?.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: authRequest) { authState, authError in - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) - self.resolveAuthResponse(promise: promise, authState: authState, authError: authError) - } + switch loginError { + case .unauthorized, .unknown: + return await createPendingUser(idToken: idToken) + case .network: + return .loginError(error: .network) } - .eraseToAnyPublisher() - } - } -#endif - -private extension Authenticator { - func resolveAuthResponse( - promise: @escaping (Result) -> Void, - authState: OIDAuthState?, - authError: Error? - ) { - if let idToken = authState?.lastTokenResponse?.idToken { - Task { - do { - let authPayload = try await networker.submitGoogleToken(idToken: idToken) - try ValetKey.authCookieString.setValue(authPayload.commentedAuthCookieString) - try ValetKey.authToken.setValue(authPayload.authToken) - DispatchQueue.main.async { - self.isLoggedIn = true - } - } catch { - if let error = error as? LoginError { - switch error { - case .unauthorized, .unknown: - self.resolveAuthResponseForAccountCreation(promise: promise, authState: authState, authError: authError) - case .network: - promise(.failure(error)) - } - self.resolveAuthResponseForAccountCreation(promise: promise, authState: authState, authError: authError) - } - } - } - } else { - resolveAuthResponseForAccountCreation(promise: promise, authState: authState, authError: authError) } } - func resolveAuthResponseForAccountCreation( - promise: @escaping (Result) -> Void, - authState: OIDAuthState?, - authError _: Error? - ) { - if let idToken = authState?.lastTokenResponse?.idToken { + func createPendingUser(idToken: String) async -> GoogleAuthResponse { + do { let params = CreatePendingAccountParams(token: idToken, provider: .google, fullName: nil) let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() - - networker - .createPendingUser(params: encodedParams) - .sink { completion in - guard case let .failure(serverError) = completion else { return } - promise(.failure(LoginError.make(serverError: serverError))) - } receiveValue: { [weak self] in - self?.pendingUserToken = $0.pendingUserToken - promise(.success(true)) - } - .store(in: &subscriptions) - } else { - promise(.failure(.unauthorized)) + let pendingUserAuthPayload = try await networker.createPendingUser(params: encodedParams) + pendingUserToken = pendingUserAuthPayload.pendingUserToken + return .newOmnivoreUser + } catch { + let loginError = LoginError.make(serverError: (error as? ServerError) ?? .unknown) + return .loginError(error: loginError) } } - func googleAuthRequest(redirectURL: URL?) -> OIDAuthorizationRequest { - let authEndpoint = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")! - let tokenEndpoint = URL(string: "https://www.googleapis.com/oauth2/v4/token")! - let iosClientGoogleId = AppKeys.sharedInstance?.iosClientGoogleId ?? "" - let scopes = ["profile", "email"] + func googleSignIn(presenting: PlatformViewController) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let clientID = "\(AppKeys.sharedInstance?.iosClientGoogleId ?? "").apps.googleusercontent.com" + GIDSignIn.sharedInstance.signIn( + with: GIDConfiguration(clientID: clientID), + presenting: presenting + ) { user, error in + guard let user = user, error == nil else { + continuation.resume(throwing: LoginError.unauthorized) + return + } - return OIDAuthorizationRequest( - configuration: OIDServiceConfiguration(authorizationEndpoint: authEndpoint, tokenEndpoint: tokenEndpoint), - clientId: "\(iosClientGoogleId).apps.googleusercontent.com", - scopes: scopes, - redirectURL: redirectURL ?? URL( - string: "com.googleusercontent.apps.\(iosClientGoogleId):/oauth2redirect/google" - )!, - responseType: "code", - additionalParameters: nil - ) + user.authentication.do { authentication, error in + guard let idToken = authentication?.idToken, error == nil else { + continuation.resume(throwing: LoginError.unauthorized) + return + } + + continuation.resume(returning: idToken) + } + } + } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index b35582b0d..0eb0d41ca 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -1,4 +1,3 @@ -import Combine import CoreData import CoreImage import Foundation @@ -19,7 +18,6 @@ public final class DataService: ObservableObject { var persistentContainer: PersistentContainer public var backgroundContext: NSManagedObjectContext - var subscriptions = Set() public var viewContext: NSManagedObjectContext { persistentContainer.viewContext diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveArticle.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveArticle.swift deleted file mode 100644 index 2c0766899..000000000 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SaveArticle.swift +++ /dev/null @@ -1,224 +0,0 @@ -import Combine -import Foundation -import Models -import SwiftGraphQL - -public enum SaveArticleStatus { - case succeeeded - case processing(jobId: String) - case failed - - static func make(jobId: String, savingStatus: Enums.ArticleSavingRequestStatus) -> SaveArticleStatus { - switch savingStatus { - case .processing: - return .processing(jobId: jobId) - case .succeeded: - return .succeeeded - case .failed: - return .failed - } - } -} - -public extension Networker { - func articleSaveStatus(jobId: String) -> AnyPublisher { - enum QueryResult { - case saved(status: SaveArticleStatus) - case error(errorCode: Enums.ArticleSavingRequestErrorCode) - } - - let selection = Selection { - try $0.on( - articleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .notFound) }, - articleSavingRequestSuccess: .init { - .saved( - status: try $0.articleSavingRequest( - selection: .init { - SaveArticleStatus.make( - jobId: try $0.id(), - savingStatus: try $0.status() - ) - } - ) - ) - } - ) - } - - let query = Selection.Query { - try $0.articleSavingRequest(id: jobId, selection: selection) - } - - let path = appEnvironment.graphqlPath - let headers = defaultHeaders - - return Deferred { - Future { promise in - send(query, to: path, headers: headers) { result in - switch result { - case let .success(payload): - if let graphqlError = payload.errors { - promise(.failure(.unknown(description: graphqlError.first.debugDescription))) - } - - switch payload.data { - case let .saved(status): - promise(.success(status)) - case let .error(errorCode: errorCode): - switch errorCode { - case .unauthorized: - promise(.failure(.unauthorized)) - case .notFound: - promise(.failure(.badData)) - } - } - case let .failure(error): - promise(.failure(SaveArticleError.make(from: error))) - } - } - } - } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - } -} - -public extension DataService { - // swiftlint:disable:next function_body_length - func saveArticlePublisher( - pageScrapePayload: PageScrapePayload, - uploadFileId: String? - ) -> AnyPublisher { - enum MutationResult { - case saved(created: Bool) - case error(errorCode: Enums.CreateArticleErrorCode) - } - - let preparedDocument: InputObjects.PreparedDocumentInput? = { - if case let .html(html, title, _) = pageScrapePayload.contentType { - return InputObjects.PreparedDocumentInput( - document: html, - pageInfo: InputObjects.PageInfoInput(title: OptionalArgument(title)) - ) - } - return nil - }() - - let input = InputObjects.CreateArticleInput( - preparedDocument: OptionalArgument(preparedDocument), - uploadFileId: uploadFileId != nil ? .present(uploadFileId!) : .null(), - url: pageScrapePayload.url - ) - - let selection = Selection { - try $0.on( - createArticleError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unableToParse) }, - createArticleSuccess: .init { .saved(created: try $0.created()) } - ) - } - - let mutation = Selection.Mutation { - try $0.createArticle(input: input, selection: selection) - } - - let path = appEnvironment.graphqlPath - let headers = networker.defaultHeaders - - return Deferred { - Future { promise in - send(mutation, to: path, headers: headers) { result in - switch result { - case let .success(payload): - if let graphqlError = payload.errors { - promise(.failure(.unknown(description: graphqlError.first.debugDescription))) - } - - switch payload.data { - case .saved: - promise(.success(())) - case let .error(errorCode: errorCode): - switch errorCode { - case .unauthorized: - promise(.failure(.unauthorized)) - default: - promise(.failure(.unknown(description: errorCode.rawValue))) - } - } - case let .failure(error): - promise(.failure(SaveArticleError.make(from: error))) - } - } - } - } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - } - - func saveArticlePublisher(articleURL: URL) -> AnyPublisher { - saveArticlePublisher(articleURLString: articleURL.absoluteString) - } - - func saveArticlePublisher(articleURLString: String) -> AnyPublisher { - enum MutationResult { - case saved(status: SaveArticleStatus) - case error(errorCode: Enums.CreateArticleSavingRequestErrorCode) - } - - let selection = Selection { - try $0.on( - createArticleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .badData) }, - createArticleSavingRequestSuccess: .init { - .saved( - status: try $0.articleSavingRequest( - selection: .init { - SaveArticleStatus.make( - jobId: try $0.id(), - savingStatus: try $0.status() - ) - } - ) - ) - } - ) - } - - let mutation = Selection.Mutation { - try $0.createArticleSavingRequest( - input: InputObjects.CreateArticleSavingRequestInput(url: articleURLString), - selection: selection - ) - } - - let path = appEnvironment.graphqlPath - let headers = networker.defaultHeaders - - return Deferred { - Future { promise in - send(mutation, to: path, headers: headers) { result in - switch result { - case let .success(payload): - if let graphqlError = payload.errors { - promise(.failure(.unknown(description: graphqlError.first.debugDescription))) - } - - switch payload.data { - case let .saved(status): - promise(.success(status)) - case let .error(errorCode: errorCode): - switch errorCode { - case .unauthorized: - promise(.failure(.unauthorized)) - case .badData: - promise(.failure(.badData)) - } - } - case let .failure(error): - promise(.failure(SaveArticleError.make(from: error))) - } - } - } - } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - } -} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePDF.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePDF.swift index 3e9d7787f..b6cf76259 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePDF.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SavePDF.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import Models import SwiftGraphQL diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Networking/Networker.swift b/apple/OmnivoreKit/Sources/Services/DataService/Networking/Networker.swift index 5da9691de..f9d442616 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Networking/Networker.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Networking/Networker.swift @@ -39,7 +39,7 @@ extension Networker { ) do { - let authVerification = try await urlSession.performReq(resource: resource) + let authVerification = try await urlSession.performRequest(resource: resource) return authVerification.authStatus.isAuthenticated } catch { return false diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift index 5bdb206c7..331bac709 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift @@ -1,4 +1,3 @@ -import Combine import Foundation import Models import Utils @@ -37,7 +36,7 @@ extension ServerResponse { struct EmptyResponse: Decodable {} extension URLSession { - func performReq( + func performRequest( resource: ServerResource ) async throws -> ResponseModel { do { @@ -61,30 +60,6 @@ extension URLSession { throw ServerError(serverResponse: serverResponse) } } - - // TODO: remove performRequest - // swiftlint:disable:next line_length - func performRequest(resource: ServerResource) -> AnyPublisher { - let request = resource.urlRequest - - return dataTaskPublisher(for: resource.urlRequest) - .tryMap { data, response -> ResponseModel in - let serverResponse = ServerResponse(data: data, response: response) - NetworkRequestLogger.log(request: request, serverResponse: serverResponse) - - if let decodedValue = resource.decode(serverResponse) { - return decodedValue - } - - throw ServerError(serverResponse: serverResponse) - } - .mapError { error -> ServerError in - let serverResponse = ServerResponse(error: error) - NetworkRequestLogger.log(request: request, serverResponse: serverResponse) - return ServerError(serverResponse: serverResponse) - } - .eraseToAnyPublisher() - } } extension URLRequest { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreateAccountResource.swift b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreateAccountResource.swift index 3743c1875..e144ea65a 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreateAccountResource.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreateAccountResource.swift @@ -1,8 +1,7 @@ -import Combine import Foundation extension Networker { - func createAccount(params: Data) -> AnyPublisher { + func createAccount(params: Data) async throws -> AuthPayload { let urlRequest = URLRequest.create( baseURL: appEnvironment.serverBaseURL, urlPath: "/api/mobile-auth/create-account", @@ -14,9 +13,10 @@ extension Networker { decode: AuthPayload.decode ) - return urlSession - .performRequest(resource: resource) - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() + do { + return try await urlSession.performRequest(resource: resource) + } catch { + throw (error as? ServerError) ?? .unknown + } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreatePendingUserResource.swift b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreatePendingUserResource.swift index 069b87de4..e5f03e97b 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreatePendingUserResource.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreatePendingUserResource.swift @@ -1,9 +1,8 @@ -import Combine import Foundation import Models extension Networker { - func createPendingUser(params: Data) -> AnyPublisher { + func createPendingUser(params: Data) async throws -> PendingUserAuthPayload { let urlRequest = URLRequest.create( baseURL: appEnvironment.serverBaseURL, urlPath: "/api/mobile-auth/sign-up", @@ -15,9 +14,10 @@ extension Networker { decode: PendingUserAuthPayload.decode ) - return urlSession - .performRequest(resource: resource) - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() + do { + return try await urlSession.performRequest(resource: resource) + } catch { + throw (error as? ServerError) ?? .unknown + } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/VerifyAppleTokenResource.swift b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/VerifyAppleTokenResource.swift deleted file mode 100644 index e8a681d52..000000000 --- a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/VerifyAppleTokenResource.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Combine -import Foundation -import Models - -extension Networker { - func submitAppleToken(token: String) -> AnyPublisher { - let params = SignInParams(token: token, provider: .apple) - let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() - - let urlRequest = URLRequest.create( - baseURL: appEnvironment.serverBaseURL, - urlPath: "/api/mobile-auth/sign-in", - requestMethod: .post(params: encodedParams) - ) - - let resource = ServerResource( - urlRequest: urlRequest, - decode: AuthPayload.decode - ) - - return urlSession - .performRequest(resource: resource) - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - } -} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreateGoogleToken.swift b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/VerifyAuthProviderToken.swift similarity index 65% rename from apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreateGoogleToken.swift rename to apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/VerifyAuthProviderToken.swift index df241e3a7..febc2f0cf 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/CreateGoogleToken.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResources/VerifyAuthProviderToken.swift @@ -2,8 +2,17 @@ import Foundation import Models extension Networker { + func submitAppleToken(token: String) async throws -> AuthPayload { + let params = SignInParams(token: token, provider: .apple) + return try await submitSignInParams(params: params) + } + func submitGoogleToken(idToken: String) async throws -> AuthPayload { let params = SignInParams(token: idToken, provider: .google) + return try await submitSignInParams(params: params) + } + + func submitSignInParams(params: SignInParams) async throws -> AuthPayload { let encodedParams = (try? JSONEncoder().encode(params)) ?? Data() let urlRequest = URLRequest.create( @@ -18,7 +27,7 @@ extension Networker { ) do { - return try await urlSession.performReq(resource: resource) + return try await urlSession.performRequest(resource: resource) } catch { if let error = error as? ServerError { throw LoginError.make(serverError: error) diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ValidateUsernameQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ValidateUsernameQuery.swift index 07137b3ac..4d427950f 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ValidateUsernameQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ValidateUsernameQuery.swift @@ -1,30 +1,29 @@ -import Combine import Foundation import Models import SwiftGraphQL public extension DataService { - func validateUsernamePublisher(username: String) -> AnyPublisher { + func validateUsernamePublisher(username: String) async throws { let query = Selection.Query { try $0.validateUsername(username: username) } let path = appEnvironment.graphqlPath - return Deferred { - Future { promise in - send(query, to: path) { result in - switch result { - case let .success(payload): - promise(payload.data ? .success(()) : .failure(.nameUnavailable)) - case let .failure(error): - promise(.failure(UsernameAvailabilityError.make(from: error))) + return try await withCheckedThrowingContinuation { continuation in + send(query, to: path) { result in + switch result { + case let .success(payload): + if payload.data { + continuation.resume() + } else { + continuation.resume(throwing: UsernameAvailabilityError.nameUnavailable) } + case let .failure(error): + continuation.resume(throwing: UsernameAvailabilityError.make(from: error)) } } } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() } } diff --git a/apple/OmnivoreKit/Sources/Views/KeyboardManagement.swift b/apple/OmnivoreKit/Sources/Views/KeyboardManagement.swift deleted file mode 100644 index ca0e42a3a..000000000 --- a/apple/OmnivoreKit/Sources/Views/KeyboardManagement.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Combine -import SwiftUI - -#if !os(macOS) - import UIKit -#endif - -public extension Publishers { - static var keyboardHeight: AnyPublisher { - #if os(iOS) - let willShow = NotificationCenter.default - .publisher(for: UIApplication.keyboardWillShowNotification) - .map(\.keyboardHeight) - .eraseToAnyPublisher() - - let willHide = NotificationCenter.default - .publisher(for: UIApplication.keyboardWillHideNotification) - .map { _ in CGFloat(0) } - .eraseToAnyPublisher() - - return Merge(willShow, willHide) - .eraseToAnyPublisher() - #elseif os(macOS) - Future { $0(.success(CGFloat.zero)) } - .eraseToAnyPublisher() - #endif - } -} - -extension Notification { - var keyboardHeight: CGFloat { - #if os(iOS) - (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect)?.height ?? 0 - #elseif os(macOS) - 0 - #endif - } -} - -struct KeyboardAdaptive: ViewModifier { - @State private var keyboardHeight: CGFloat = 0 - - func body(content: Content) -> some View { - content - .padding(.bottom, keyboardHeight) - .onReceive(Publishers.keyboardHeight) { self.keyboardHeight = $0 } - } -} - -extension View { - func keyboardAdaptive() -> some View { - ModifiedContent(content: self, modifier: KeyboardAdaptive()) - } -} - -extension View { - func hideKeyboard() { - #if os(iOS) - UIApplication.shared - .sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - #endif - } -}