From ad8f4bbefd0d242a8a379b9e95a7e9883b85a934 Mon Sep 17 00:00:00 2001 From: Tony Date: Mon, 22 Apr 2024 21:22:10 -0700 Subject: [PATCH] Fix quick connect (#874) --- .../QuickConnectCoordinator.swift | 4 +- Shared/ViewModels/QuickConnectViewModel.swift | 172 ++++++++++++++++++ Shared/ViewModels/UserSignInViewModel.swift | 155 ++++++++-------- Swiftfin tvOS/Views/QuickConnectView.swift | 93 ++++++++++ Swiftfin tvOS/Views/UserSignInView.swift | 102 ++++------- Swiftfin.xcodeproj/project.pbxproj | 10 + Swiftfin/Views/QuickConnectView.swift | 65 +++++-- .../Components/PublicUserSignInView.swift | 8 +- .../Views/UserSignInView/UserSignInView.swift | 47 +++-- 9 files changed, 466 insertions(+), 190 deletions(-) create mode 100644 Shared/ViewModels/QuickConnectViewModel.swift create mode 100644 Swiftfin tvOS/Views/QuickConnectView.swift diff --git a/Shared/Coordinators/QuickConnectCoordinator.swift b/Shared/Coordinators/QuickConnectCoordinator.swift index e13bd19f..ad1eccc4 100644 --- a/Shared/Coordinators/QuickConnectCoordinator.swift +++ b/Shared/Coordinators/QuickConnectCoordinator.swift @@ -25,6 +25,8 @@ final class QuickConnectCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { - QuickConnectView(viewModel: viewModel) + QuickConnectView(viewModel: viewModel.quickConnectViewModel, signIn: { authSecret in + self.viewModel.send(.signInWithQuickConnect(authSecret: authSecret)) + }) } } diff --git a/Shared/ViewModels/QuickConnectViewModel.swift b/Shared/ViewModels/QuickConnectViewModel.swift new file mode 100644 index 00000000..8147f8e1 --- /dev/null +++ b/Shared/ViewModels/QuickConnectViewModel.swift @@ -0,0 +1,172 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CoreStore +import Defaults +import Factory +import Foundation +import JellyfinAPI +import Pulse + +/// Handles getting and exposing quick connect code and related states and polling for authentication secret and +/// exposing it to a consumer. +/// __Does not handle using the authentication secret itself to sign in.__ +final class QuickConnectViewModel: ViewModel, Stateful { + // MARK: Action + + enum Action { + case startQuickConnect + case cancelQuickConnect + } + + // MARK: State + + // The typical quick connect lifecycle is as follows: + enum State: Equatable { + // 0. User has not interacted with quick connect + case initial + // 1. User clicks quick connect + case fetchingSecret + // 2. We fetch a secret and code from the server + // 3. Display the code to user, poll for authentication from server using secret + // 4. User enters code to the server + case awaitingAuthentication(code: String) + // 5. Authentication poll succeeds with another secret. A consumer uses this secret to sign in. + // In particular, the responsibility to consume this secret and handle any errors and state changes + // is deferred to the consumer. + case authenticated(secret: String) + // Store the error and surface it to user if possible + case error(QuickConnectError) + } + + // TODO: Consider giving these errors a message and using it in the QuickConnectViews + enum QuickConnectError: Error { + case fetchSecretFailed + case pollingFailed + case unknown + } + + @Published + var state: State = .initial + + let client: JellyfinClient + + /// How often to poll quick connect auth + private let quickConnectPollTimeoutSeconds: Int = 5 + private let quickConnectMaxRetries: Int = 200 + + private var quickConnectPollTask: Task? + + init(client: JellyfinClient) { + self.client = client + super.init() + } + + func respond(to action: Action) -> State { + switch action { + case .startQuickConnect: + Task { + await fetchAuthCode() + } + return .fetchingSecret + case .cancelQuickConnect: + stopQuickConnectAuthCheck() + return .initial + } + } + + /// Retrieves sign in secret, and stores it in the state for a consumer to use. + private func fetchAuthCode() async { + do { + await MainActor.run { + state = .fetchingSecret + } + let (initiateSecret, code) = try await startQuickConnect() + + await MainActor.run { + state = .awaitingAuthentication(code: code) + } + let authSecret = try await pollForAuthSecret(initialSecret: initiateSecret) + + await MainActor.run { + state = .authenticated(secret: authSecret) + } + } catch let error as QuickConnectError { + await MainActor.run { + state = .error(error) + } + } catch { + await MainActor.run { + state = .error(.unknown) + } + } + } + + /// Gets secret and code to start quick connect authorization flow. + private func startQuickConnect() async throws -> (secret: String, code: String) { + logger.debug("Attempting to start quick connect...") + + let initiatePath = Paths.initiate + let response = try await client.send(initiatePath) + + guard let secret = response.value.secret, + let code = response.value.code + else { + throw QuickConnectError.fetchSecretFailed + } + + return (secret, code) + } + + private func pollForAuthSecret(initialSecret: String) async throws -> String { + let task = Task { + var authSecret: String? + for _ in 1 ... quickConnectMaxRetries { + authSecret = try await checkAuth(initialSecret: initialSecret) + if authSecret != nil { break } + + try await Task.sleep(nanoseconds: UInt64(1_000_000_000 * quickConnectPollTimeoutSeconds)) + } + guard let authSecret = authSecret else { + logger.warning("Hit max retries while using quick connect, did the `pollForAuthSecret` task keep running after signing in?") + throw QuickConnectError.pollingFailed + } + return authSecret + } + + quickConnectPollTask = task + return try await task.result.get() + } + + private func checkAuth(initialSecret: String) async throws -> String? { + logger.debug("Attempting to poll for quick connect auth") + + let connectPath = Paths.connect(secret: initialSecret) + do { + let response = try await client.send(connectPath) + + guard response.value.isAuthenticated ?? false else { + return nil + } + guard let authSecret = response.value.secret else { + logger.debug("Quick connect response was authorized but secret missing") + throw QuickConnectError.pollingFailed + } + return authSecret + } catch { + throw QuickConnectError.pollingFailed + } + } + + private func stopQuickConnectAuthCheck() { + logger.debug("Stopping quick connect") + + state = .initial + quickConnectPollTask?.cancel() + } +} diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift index 15d9402c..4240aa99 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -13,33 +13,86 @@ import Foundation import JellyfinAPI import Pulse -final class UserSignInViewModel: ViewModel { +final class UserSignInViewModel: ViewModel, Stateful { + // MARK: Action + enum Action { + case signInWithUserPass(username: String, password: String) + case signInWithQuickConnect(authSecret: String) + case cancelSignIn + } + + // MARK: State + + enum State: Equatable { + case initial + case signingIn + case signedIn + case error(SignInError) + } + + // TODO: Add more detailed errors + enum SignInError: Error { + case unknown + } + + @Published + var state: State = .initial @Published private(set) var publicUsers: [UserDto] = [] @Published - private(set) var quickConnectCode: String? - @Published private(set) var quickConnectEnabled = false + private var signInTask: Task? + + let quickConnectViewModel: QuickConnectViewModel + let client: JellyfinClient let server: SwiftfinStore.State.Server - private var quickConnectTask: Task? - private var quickConnectTimer: RepeatingTimer? - private var quickConnectSecret: String? - init(server: ServerState) { self.client = JellyfinClient( configuration: .swiftfinConfiguration(url: server.currentURL), sessionDelegate: URLSessionProxyDelegate() ) self.server = server + self.quickConnectViewModel = .init(client: client) super.init() } - func signIn(username: String, password: String) async throws { + func respond(to action: Action) -> State { + switch action { + case let .signInWithUserPass(username, password): + guard state != .signingIn else { return .signingIn } + Task { + do { + try await signIn(username: username, password: password) + } catch { + await MainActor.run { + state = .error(.unknown) + } + } + } + return .signingIn + case let .signInWithQuickConnect(authSecret): + guard state != .signingIn else { return .signingIn } + Task { + do { + try await signIn(quickConnectSecret: authSecret) + } catch { + await MainActor.run { + state = .error(.unknown) + } + } + } + return .signingIn + case .cancelSignIn: + self.signInTask?.cancel() + return .initial + } + } + private func signIn(username: String, password: String) async throws { let username = username.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .objectReplacement) let password = password.trimmingCharacters(in: .whitespacesAndNewlines) @@ -64,71 +117,7 @@ final class UserSignInViewModel: ViewModel { Notifications[.didSignIn].post() } - func getPublicUsers() async throws { - let publicUsersPath = Paths.getPublicUsers - let response = try await client.send(publicUsersPath) - - await MainActor.run { - publicUsers = response.value - } - } - - func checkQuickConnect() async throws { - let quickConnectEnabledPath = Paths.getEnabled - let response = try await client.send(quickConnectEnabledPath) - let decoder = JSONDecoder() - let isEnabled = try? decoder.decode(Bool.self, from: response.value) - - await MainActor.run { - quickConnectEnabled = isEnabled ?? false - } - } - - func startQuickConnect() -> AsyncStream { - Task { - - let initiatePath = Paths.initiate - let response = try? await client.send(initiatePath) - - guard let response else { return } - - await MainActor.run { - quickConnectSecret = response.value.secret - quickConnectCode = response.value.code - } - } - - return .init { continuation in - - checkAuthStatus(continuation: continuation) - } - } - - private func checkAuthStatus(continuation: AsyncStream.Continuation) { - - let task = Task { - guard let quickConnectSecret else { return } - let connectPath = Paths.connect(secret: quickConnectSecret) - let response = try? await client.send(connectPath) - - if let responseValue = response?.value, responseValue.isAuthenticated ?? false { - continuation.yield(responseValue) - return - } - - try? await Task.sleep(nanoseconds: 5_000_000_000) - - checkAuthStatus(continuation: continuation) - } - - self.quickConnectTask = task - } - - func stopQuickConnectAuthCheck() { - self.quickConnectTask?.cancel() - } - - func signIn(quickConnectSecret: String) async throws { + private func signIn(quickConnectSecret: String) async throws { let quickConnectPath = Paths.authenticateWithQuickConnect(.init(secret: quickConnectSecret)) let response = try await client.send(quickConnectPath) @@ -149,6 +138,15 @@ final class UserSignInViewModel: ViewModel { Notifications[.didSignIn].post() } + func getPublicUsers() async throws { + let publicUsersPath = Paths.getPublicUsers + let response = try await client.send(publicUsersPath) + + await MainActor.run { + publicUsers = response.value + } + } + @MainActor private func createLocalUser(response: AuthenticationResult) async throws -> UserState { guard let accessToken = response.accessToken, @@ -192,4 +190,15 @@ final class UserSignInViewModel: ViewModel { return user } + + func checkQuickConnect() async throws { + let quickConnectEnabledPath = Paths.getEnabled + let response = try await client.send(quickConnectEnabledPath) + let decoder = JSONDecoder() + let isEnabled = try? decoder.decode(Bool.self, from: response.value) + + await MainActor.run { + quickConnectEnabled = isEnabled ?? false + } + } } diff --git a/Swiftfin tvOS/Views/QuickConnectView.swift b/Swiftfin tvOS/Views/QuickConnectView.swift new file mode 100644 index 00000000..2fa06703 --- /dev/null +++ b/Swiftfin tvOS/Views/QuickConnectView.swift @@ -0,0 +1,93 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct QuickConnectView: View { + @ObservedObject + var viewModel: QuickConnectViewModel + @Binding + var isPresentingQuickConnect: Bool + // Once the auth secret is fetched, run this and dismiss this view + var signIn: @MainActor (_: String) -> Void + + func quickConnectWaitingAuthentication(quickConnectCode: String) -> some View { + Text(quickConnectCode) + .tracking(10) + .font(.title) + .monospacedDigit() + .frame(maxWidth: .infinity) + } + + var quickConnectFailed: some View { + Label { + Text("Failed to retrieve quick connect code") + } icon: { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + } + } + + var quickConnectLoading: some View { + ProgressView() + } + + @ViewBuilder + var quickConnectBody: some View { + switch viewModel.state { + case let .awaitingAuthentication(code): + quickConnectWaitingAuthentication(quickConnectCode: code) + case .initial, .fetchingSecret, .authenticated: + quickConnectLoading + case .error: + quickConnectFailed + } + } + + var body: some View { + VStack(alignment: .center) { + L10n.quickConnect.text + .font(.title3) + .fontWeight(.semibold) + + Group { + VStack(alignment: .leading, spacing: 20) { + L10n.quickConnectStep1.text + + L10n.quickConnectStep2.text + + L10n.quickConnectStep3.text + } + .padding(.vertical) + + quickConnectBody + } + .padding(.bottom) + + Button { + isPresentingQuickConnect = false + } label: { + L10n.close.text + .frame(width: 400, height: 75) + } + .buttonStyle(.plain) + } + .onChange(of: viewModel.state) { newState in + if case let .authenticated(secret: secret) = newState { + signIn(secret) + isPresentingQuickConnect = false + } + } + .onAppear { + viewModel.send(.startQuickConnect) + } + .onDisappear { + viewModel.send(.cancelQuickConnect) + } + } +} diff --git a/Swiftfin tvOS/Views/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView.swift index af7a0eca..71685abb 100644 --- a/Swiftfin tvOS/Views/UserSignInView.swift +++ b/Swiftfin tvOS/Views/UserSignInView.swift @@ -12,7 +12,6 @@ import Stinsen import SwiftUI struct UserSignInView: View { - enum FocusedField { case username case password @@ -27,12 +26,10 @@ struct UserSignInView: View { @State private var isPresentingQuickConnect: Bool = false @State + private var isPresentingSignInError: Bool = false + @State private var password: String = "" @State - private var signInError: Error? - @State - private var signInTask: Task? - @State private var username: String = "" @ViewBuilder @@ -50,22 +47,10 @@ struct UserSignInView: View { .focused($focusedField, equals: .password) Button { - let task = Task { - viewModel.isLoading = true - - do { - try await viewModel.signIn(username: username, password: password) - } catch { - signInError = error - } - - viewModel.isLoading = false - } - - signInTask = task + viewModel.send(.signInWithUserPass(username: username, password: password)) } label: { HStack { - if viewModel.isLoading { + if case viewModel.state = .signingIn { ProgressView() } @@ -124,52 +109,16 @@ struct UserSignInView: View { } } - @ViewBuilder - private var quickConnect: some View { - VStack(alignment: .center) { - L10n.quickConnect.text - .font(.title3) - .fontWeight(.semibold) - - VStack(alignment: .leading, spacing: 20) { - L10n.quickConnectStep1.text - - L10n.quickConnectStep2.text - - L10n.quickConnectStep3.text - } - .padding(.vertical) - - Text(viewModel.quickConnectCode ?? "------") - .tracking(10) - .font(.title) - .monospacedDigit() - .frame(maxWidth: .infinity) - - Button { - isPresentingQuickConnect = false - } label: { - L10n.close.text - .frame(width: 400, height: 75) - } - .buttonStyle(.plain) - } - .onAppear { - Task { - for await result in viewModel.startQuickConnect() { - guard let secret = result.secret else { continue } - try? await viewModel.signIn(quickConnectSecret: secret) - } - } - } - .onDisappear { - viewModel.stopQuickConnectAuthCheck() + var errorText: some View { + var text: String? + if case let .error(error) = viewModel.state { + text = error.localizedDescription } + return Text(text ?? .emptyDash) } var body: some View { ZStack { - ImageView(viewModel.userSession.client.fullURL(with: Paths.getSplashscreen())) .ignoresSafeArea() @@ -187,15 +136,29 @@ struct UserSignInView: View { .edgesIgnoringSafeArea(.bottom) } .navigationTitle(L10n.signIn) -// .alert(item: $viewModel.errorMessage) { _ in -// Alert( -// title: Text(viewModel.alertTitle), -// message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), -// dismissButton: .cancel() -// ) -// } + .onChange(of: viewModel.state) { _ in + // If we encountered the error as we switched from quick connect cover to this view, + // it's possible that the alert doesn't show, so wait a little bit + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isPresentingSignInError = true + } + } + .alert( + L10n.error, + isPresented: $isPresentingSignInError + ) { + Button(L10n.dismiss, role: .cancel) + } message: { + errorText + } .blurFullScreenCover(isPresented: $isPresentingQuickConnect) { - quickConnect + QuickConnectView( + viewModel: viewModel.quickConnectViewModel, + isPresentingQuickConnect: $isPresentingQuickConnect, + signIn: { authSecret in + self.viewModel.send(.signInWithQuickConnect(authSecret: authSecret)) + } + ) } .onAppear { Task { @@ -204,8 +167,7 @@ struct UserSignInView: View { } } .onDisappear { - viewModel.isLoading = false - viewModel.stopQuickConnectAuthCheck() + viewModel.send(.cancelSignIn) } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 83f557a3..81d14987 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -802,6 +802,9 @@ E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */; }; E43918662AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */; }; E43918672AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */; }; + EA2073A12BAE35A400D8C78F /* QuickConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */; }; + EA2073A22BAE35A400D8C78F /* QuickConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */; }; + EADD26FD2BAE4A6C002F05DE /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -1369,6 +1372,8 @@ E1FE69A628C29B720021BC93 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenePhaseChangeModifier.swift; sourceTree = ""; }; + EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectViewModel.swift; sourceTree = ""; }; + EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1527,6 +1532,7 @@ BD0BA2292AD6501300306A8D /* VideoPlayerManager */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, + EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -2327,6 +2333,7 @@ E193D546271941C500900D82 /* UserListView.swift */, E193D548271941CC00900D82 /* UserSignInView.swift */, 5310694F2684E7EE00CFFDBA /* VideoPlayer */, + EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */, ); path = Views; sourceTree = ""; @@ -3521,6 +3528,7 @@ E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E1DD55382B6EE533007501C0 /* Task.swift in Sources */, E1575EA1293E7B1E001665B1 /* String.swift in Sources */, + EADD26FD2BAE4A6C002F05DE /* QuickConnectView.swift in Sources */, E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */, E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */, E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */, @@ -3610,6 +3618,7 @@ E12376B02A33D6AE001F5B44 /* AboutViewCard.swift in Sources */, E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */, E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */, + EA2073A22BAE35A400D8C78F /* QuickConnectViewModel.swift in Sources */, E1579EA82B97DC1500A31CA1 /* Eventful.swift in Sources */, E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */, E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */, @@ -3838,6 +3847,7 @@ E1E1644128BB301900323B0A /* Array.swift in Sources */, E18CE0AF28A222240092E7F1 /* PublicUserSignInView.swift in Sources */, E129429828F4785200796AC6 /* CaseIterablePicker.swift in Sources */, + EA2073A12BAE35A400D8C78F /* QuickConnectViewModel.swift in Sources */, E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */, E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, diff --git a/Swiftfin/Views/QuickConnectView.swift b/Swiftfin/Views/QuickConnectView.swift index 58041683..5756090d 100644 --- a/Swiftfin/Views/QuickConnectView.swift +++ b/Swiftfin/Views/QuickConnectView.swift @@ -9,12 +9,51 @@ import SwiftUI struct QuickConnectView: View { - @EnvironmentObject private var router: QuickConnectCoordinator.Router @ObservedObject - var viewModel: UserSignInViewModel + var viewModel: QuickConnectViewModel + + // Once the auth secret is fetched, run this and dismiss this view + var signIn: @MainActor (_: String) -> Void + + func quickConnectWaitingAuthentication(quickConnectCode: String) -> some View { + Text(quickConnectCode) + .tracking(10) + .font(.largeTitle) + .monospacedDigit() + .frame(maxWidth: .infinity) + } + + var quickConnectFailed: some View { + Label { + Text("Failed to retrieve quick connect code") + } icon: { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + } + } + + var quickConnectLoading: some View { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + + @ViewBuilder + var quickConnectBody: some View { + switch viewModel.state { + case let .awaitingAuthentication(code): + quickConnectWaitingAuthentication(quickConnectCode: code) + case .initial, .fetchingSecret, .authenticated: + quickConnectLoading + case .error: + quickConnectFailed + } + } var body: some View { VStack(alignment: .leading, spacing: 20) { @@ -25,27 +64,23 @@ struct QuickConnectView: View { L10n.quickConnectStep3.text .padding(.bottom) - Text(viewModel.quickConnectCode ?? "------") - .tracking(10) - .font(.largeTitle) - .monospacedDigit() - .frame(maxWidth: .infinity) + quickConnectBody Spacer() } .padding(.horizontal) .navigationTitle(L10n.quickConnect) - .onAppear { - Task { - for await result in viewModel.startQuickConnect() { - guard let secret = result.secret else { continue } - try? await viewModel.signIn(quickConnectSecret: secret) - router.dismissCoordinator() - } + .onChange(of: viewModel.state) { newState in + if case let .authenticated(secret: secret) = newState { + signIn(secret) + router.dismissCoordinator() } } + .onAppear { + viewModel.send(.startQuickConnect) + } .onDisappear { - viewModel.stopQuickConnectAuthCheck() + viewModel.send(.cancelQuickConnect) } .navigationBarCloseButton { router.dismissCoordinator() diff --git a/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift b/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift index 620de634..44e98c5b 100644 --- a/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift +++ b/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift @@ -10,9 +10,7 @@ import JellyfinAPI import SwiftUI extension UserSignInView { - struct PublicUserSignInView: View { - @ObservedObject var viewModel: UserSignInViewModel @@ -25,10 +23,8 @@ extension UserSignInView { DisclosureGroup { SecureField(L10n.password, text: $password) Button { - Task { - guard let username = publicUser.name else { return } - try? await viewModel.signIn(username: username, password: password) - } + guard let username = publicUser.name else { return } + viewModel.send(.signInWithUserPass(username: username, password: password)) } label: { L10n.signIn.text } diff --git a/Swiftfin/Views/UserSignInView/UserSignInView.swift b/Swiftfin/Views/UserSignInView/UserSignInView.swift index 40fd454e..6cb512a1 100644 --- a/Swiftfin/Views/UserSignInView/UserSignInView.swift +++ b/Swiftfin/Views/UserSignInView/UserSignInView.swift @@ -10,7 +10,6 @@ import Stinsen import SwiftUI struct UserSignInView: View { - @EnvironmentObject private var router: UserSignInCoordinator.Router @@ -22,10 +21,6 @@ struct UserSignInView: View { @State private var password: String = "" @State - private var signInError: Error? - @State - private var signInTask: Task? - @State private var username: String = "" @ViewBuilder @@ -39,28 +34,15 @@ struct UserSignInView: View { .autocorrectionDisabled() .textInputAutocapitalization(.none) - if viewModel.isLoading { + if case .signingIn = viewModel.state { Button(role: .destructive) { - viewModel.isLoading = false - signInTask?.cancel() + viewModel.send(.cancelSignIn) } label: { L10n.cancel.text } } else { Button { - let task = Task { - viewModel.isLoading = true - - do { - try await viewModel.signIn(username: username, password: password) - } catch { - signInError = error - isPresentingSignInError = true - } - - viewModel.isLoading = false - } - signInTask = task + viewModel.send(.signInWithUserPass(username: username, password: password)) } label: { L10n.signIn.text } @@ -104,9 +86,16 @@ struct UserSignInView: View { .headerProminence(.increased) } + var errorText: some View { + var text: String? + if case let .error(error) = viewModel.state { + text = error.localizedDescription + } + return Text(text ?? .emptyDash) + } + var body: some View { List { - signInSection if viewModel.quickConnectEnabled { @@ -119,13 +108,22 @@ struct UserSignInView: View { publicUsersSection } + .onChange(of: viewModel.state) { newState in + if case .error = newState { + // If we encountered the error as we switched from quick connect navigation to this view, + // it's possible that the alert doesn't show, so wait a little bit + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + isPresentingSignInError = true + } + } + } .alert( L10n.error, isPresented: $isPresentingSignInError ) { Button(L10n.dismiss, role: .cancel) } message: { - Text(signInError?.localizedDescription ?? .emptyDash) + errorText } .navigationTitle(L10n.signIn) .onAppear { @@ -135,8 +133,7 @@ struct UserSignInView: View { } } .onDisappear { - viewModel.isLoading = false - viewModel.stopQuickConnectAuthCheck() + viewModel.send(.cancelSignIn) } } }