From 17692cec435d865ec65b18627c51aa153ab21aa7 Mon Sep 17 00:00:00 2001 From: Tony Date: Tue, 10 Oct 2023 18:07:01 -0700 Subject: [PATCH 1/4] fix quick connect --- Shared/ViewModels/UserSignInViewModel.swift | 60 +++++++++++++++++---- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift index 15d9402c0..a149feacd 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -25,10 +25,27 @@ final class UserSignInViewModel: ViewModel { let client: JellyfinClient let server: SwiftfinStore.State.Server - private var quickConnectTask: Task? - private var quickConnectTimer: RepeatingTimer? + private var quickConnectMonitorTask: Task? + // We want to enforce that only one monitoring task runs at a time, done by this ID. + // While cancelling a task is preferable to assigning IDs, cancelling has proven + // unreliable and unpredictable. + private var quickConnectMonitorTaskID: UUID? + // We want to signal to the monitor task when we're ready to poll for authentication. + // Without this, we may encounter a race condition where the monitor expects + // the secret before it's been fetched, and exits prematurely. + private var quickConnectStatus: QuickConnectStatus = .neutral + // If, for whatever reason, the monitor task isn't stopped correctly, we don't want + // to let it silently run forever. + private let quickConnectMaxRetries = 200 private var quickConnectSecret: String? + enum QuickConnectStatus { + case neutral + case fetchingSecret + case fetchingSecretFailed + case awaitingAuthentication + } + init(server: ServerState) { self.client = JellyfinClient( configuration: .swiftfinConfiguration(url: server.currentURL), @@ -85,47 +102,70 @@ final class UserSignInViewModel: ViewModel { } func startQuickConnect() -> AsyncStream { + quickConnectStatus = .fetchingSecret + Task { let initiatePath = Paths.initiate let response = try? await client.send(initiatePath) - guard let response else { return } + guard let response else { + // TODO: Handle this directly or surface the error + quickConnectStatus = .fetchingSecretFailed + return + } await MainActor.run { quickConnectSecret = response.value.secret quickConnectCode = response.value.code + quickConnectStatus = .awaitingAuthentication } } + let taskID = UUID() + quickConnectMonitorTaskID = taskID + return .init { continuation in - checkAuthStatus(continuation: continuation) + checkAuthStatus(continuation: continuation, id: taskID) } } - private func checkAuthStatus(continuation: AsyncStream.Continuation) { - + private func checkAuthStatus(continuation: AsyncStream.Continuation, id: UUID, tries: Int = 0) { let task = Task { - guard let quickConnectSecret else { return } + // Don't race into failure while we're fetching the secret. + while quickConnectStatus == .fetchingSecret { + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + + guard let quickConnectSecret, quickConnectStatus == .awaitingAuthentication, quickConnectMonitorTaskID == id else { return } + + if tries > quickConnectMaxRetries { + logger.warning("Hit max retries while using quick connect, did `checkAuthStatus` keep running after signing in?") + stopQuickConnectAuthCheck() + 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) + quickConnectStatus = .neutral + stopQuickConnectAuthCheck() return } try? await Task.sleep(nanoseconds: 5_000_000_000) - checkAuthStatus(continuation: continuation) + checkAuthStatus(continuation: continuation, id: id, tries: tries + 1) } - self.quickConnectTask = task + self.quickConnectMonitorTask = task } func stopQuickConnectAuthCheck() { - self.quickConnectTask?.cancel() + self.quickConnectMonitorTaskID = nil } func signIn(quickConnectSecret: String) async throws { From 01ccca7fe3c9fe4e96e60ac0ab5fec2a7671ba4d Mon Sep 17 00:00:00 2001 From: Tony Date: Thu, 9 Nov 2023 13:24:27 -0800 Subject: [PATCH 2/4] clean up + docs --- Shared/ViewModels/UserSignInViewModel.swift | 223 +++++++++++--------- Swiftfin tvOS/Views/UserSignInView.swift | 50 ++++- Swiftfin/Views/QuickConnectView.swift | 64 ++++-- 3 files changed, 209 insertions(+), 128 deletions(-) diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift index a149feacd..c08c0b5f0 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -14,38 +14,12 @@ import JellyfinAPI import Pulse final class UserSignInViewModel: ViewModel { - @Published private(set) var publicUsers: [UserDto] = [] - @Published - private(set) var quickConnectCode: String? - @Published - private(set) var quickConnectEnabled = false let client: JellyfinClient let server: SwiftfinStore.State.Server - private var quickConnectMonitorTask: Task? - // We want to enforce that only one monitoring task runs at a time, done by this ID. - // While cancelling a task is preferable to assigning IDs, cancelling has proven - // unreliable and unpredictable. - private var quickConnectMonitorTaskID: UUID? - // We want to signal to the monitor task when we're ready to poll for authentication. - // Without this, we may encounter a race condition where the monitor expects - // the secret before it's been fetched, and exits prematurely. - private var quickConnectStatus: QuickConnectStatus = .neutral - // If, for whatever reason, the monitor task isn't stopped correctly, we don't want - // to let it silently run forever. - private let quickConnectMaxRetries = 200 - private var quickConnectSecret: String? - - enum QuickConnectStatus { - case neutral - case fetchingSecret - case fetchingSecretFailed - case awaitingAuthentication - } - init(server: ServerState) { self.client = JellyfinClient( configuration: .swiftfinConfiguration(url: server.currentURL), @@ -56,7 +30,6 @@ final class UserSignInViewModel: ViewModel { } func signIn(username: String, password: String) async throws { - let username = username.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .objectReplacement) let password = password.trimmingCharacters(in: .whitespacesAndNewlines) @@ -90,43 +63,117 @@ final class UserSignInViewModel: ViewModel { } } - 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) + @MainActor + private func createLocalUser(response: AuthenticationResult) async throws -> UserState { + guard let accessToken = response.accessToken, + let username = response.user?.name, + let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") } - await MainActor.run { - quickConnectEnabled = isEnabled ?? false + if let existingUser = try? SwiftfinStore.dataStack.fetchOne( + From(), + [Where( + "id == %@", + id + )] + ) { + throw SwiftfinStore.Error.existingUser(existingUser.state) + } + + guard let storedServer = try? SwiftfinStore.dataStack.fetchOne( + From(), + [ + Where( + "id == %@", + server.id + ), + ] + ) + else { fatalError("No stored server associated with given state server?") } + + let user = try SwiftfinStore.dataStack.perform { transaction in + let newUser = transaction.create(Into()) + + newUser.accessToken = accessToken + newUser.appleTVID = "" + newUser.id = id + newUser.username = username + + let editServer = transaction.edit(storedServer)! + editServer.users.insert(newUser) + + return newUser.state } + + return user + } + + // MARK: - Quick Connect + + /// The typical quick connect lifecycle is as follows: + /// 1. User clicks quick connect + /// 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 + /// 5. Authentication poll succeeds with another secret, use secret to log in + + @Published + private(set) var quickConnectEnabled = false + /// We want to tell the monitor task when we're ready to poll for authentication. Without this, we may encounter + /// a race condition where the monitor task expects the secret before it's fetched and exits prematurely. + @Published + private(set) var quickConnectStatus: QuickConnectStatus? + + private let quickConnectPollTimeoutSeconds: UInt64 = 5 + + /// We don't use Timer.scheduledTimer because checking quick connect status is async. We only want to start the next + /// poll when the current has finished. + private var quickConnectPollTask: Task? + /// When two quick connect tasks are started for whatever reason, cancelling the repeating task above seems to fail + /// and the attempted cancelled task seems to continue to spawn more repeating tasks. We ensure only a single + /// poll task continues to live with this ID. + private var quickConnectPollTaskID: UUID? + /// If, for whatever reason, the monitor task keeps going, we don't want to let it silently run forever. + private let quickConnectMaxRetries = 200 + + enum QuickConnectStatus { + case fetchingSecret + // After secret and code is fetched from the server, store it in the associated value as: + // (secret, code) + case awaitingAuthentication(String, String) + // Store the error and surface it to user if possible + case fetchingSecretFailed(Error?) } func startQuickConnect() -> AsyncStream { + logger.debug("Attempting to start quick connect...") quickConnectStatus = .fetchingSecret Task { - let initiatePath = Paths.initiate - let response = try? await client.send(initiatePath) - - guard let response else { - // TODO: Handle this directly or surface the error - quickConnectStatus = .fetchingSecretFailed - return - } - - await MainActor.run { - quickConnectSecret = response.value.secret - quickConnectCode = response.value.code - quickConnectStatus = .awaitingAuthentication + do { + let response = try await client.send(initiatePath) + + guard let secret = response.value.secret, + let code = response.value.code + else { + // TODO: Create an error & display it in QuickConnectView (iOS)/UserSignInView (tvOS) + quickConnectStatus = .fetchingSecretFailed(nil) + return + } + + await MainActor.run { + quickConnectStatus = .awaitingAuthentication(secret, code) + } + + } catch { + quickConnectStatus = .fetchingSecretFailed(error) } } let taskID = UUID() - quickConnectMonitorTaskID = taskID + quickConnectPollTaskID = taskID return .init { continuation in - checkAuthStatus(continuation: continuation, id: taskID) } } @@ -134,11 +181,14 @@ final class UserSignInViewModel: ViewModel { private func checkAuthStatus(continuation: AsyncStream.Continuation, id: UUID, tries: Int = 0) { let task = Task { // Don't race into failure while we're fetching the secret. - while quickConnectStatus == .fetchingSecret { + while case .fetchingSecret = quickConnectStatus { try? await Task.sleep(nanoseconds: 1_000_000_000) } - guard let quickConnectSecret, quickConnectStatus == .awaitingAuthentication, quickConnectMonitorTaskID == id else { return } + logger.debug("Attempting to poll for quick connect auth on taskID \(id)") + + guard case let .awaitingAuthentication(quickConnectSecret, _) = quickConnectStatus, + quickConnectPollTaskID == id else { return } if tries > quickConnectMaxRetries { logger.warning("Hit max retries while using quick connect, did `checkAuthStatus` keep running after signing in?") @@ -151,21 +201,38 @@ final class UserSignInViewModel: ViewModel { if let responseValue = response?.value, responseValue.isAuthenticated ?? false { continuation.yield(responseValue) - quickConnectStatus = .neutral - stopQuickConnectAuthCheck() + + await MainActor.run { + stopQuickConnectAuthCheck() + } + return } - try? await Task.sleep(nanoseconds: 5_000_000_000) + try? await Task.sleep(nanoseconds: 1_000_000_000 * quickConnectPollTimeoutSeconds) checkAuthStatus(continuation: continuation, id: id, tries: tries + 1) } - self.quickConnectMonitorTask = task + quickConnectPollTask = task } func stopQuickConnectAuthCheck() { - self.quickConnectMonitorTaskID = nil + logger.debug("Stopping quick connect") + + quickConnectStatus = nil + quickConnectPollTaskID = nil + } + + 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 signIn(quickConnectSecret: String) async throws { @@ -188,48 +255,4 @@ final class UserSignInViewModel: ViewModel { Container.userSession.reset() Notifications[.didSignIn].post() } - - @MainActor - private func createLocalUser(response: AuthenticationResult) async throws -> UserState { - guard let accessToken = response.accessToken, - let username = response.user?.name, - let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") } - - if let existingUser = try? SwiftfinStore.dataStack.fetchOne( - From(), - [Where( - "id == %@", - id - )] - ) { - throw SwiftfinStore.Error.existingUser(existingUser.state) - } - - guard let storedServer = try? SwiftfinStore.dataStack.fetchOne( - From(), - [ - Where( - "id == %@", - server.id - ), - ] - ) - else { fatalError("No stored server associated with given state server?") } - - let user = try SwiftfinStore.dataStack.perform { transaction in - let newUser = transaction.create(Into()) - - newUser.accessToken = accessToken - newUser.appleTVID = "" - newUser.id = id - newUser.username = username - - let editServer = transaction.edit(storedServer)! - editServer.users.insert(newUser) - - return newUser.state - } - - return user - } } diff --git a/Swiftfin tvOS/Views/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView.swift index 39b3ea383..eb91c00cb 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 @@ -130,13 +129,8 @@ struct UserSignInView: View { } } - @ViewBuilder - private var quickConnect: some View { - VStack(alignment: .center) { - L10n.quickConnect.text - .font(.title3) - .fontWeight(.semibold) - + func quickConnectWaitingAuthentication(quickConnectCode: String) -> some View { + Group { VStack(alignment: .leading, spacing: 20) { L10n.quickConnectStep1.text @@ -146,11 +140,48 @@ struct UserSignInView: View { } .padding(.vertical) - Text(viewModel.quickConnectCode ?? "------") + 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.quickConnectStatus { + case let .awaitingAuthentication(_, code): + quickConnectWaitingAuthentication(quickConnectCode: code) + case nil, .fetchingSecret: + quickConnectLoading + case .fetchingSecretFailed: + quickConnectFailed + } + } + + @ViewBuilder + private var quickConnect: some View { + VStack(alignment: .center) { + L10n.quickConnect.text + .font(.title3) + .fontWeight(.semibold) + + quickConnectBody + .padding(.bottom) Button { isPresentingQuickConnect = false @@ -175,7 +206,6 @@ struct UserSignInView: View { var body: some View { ZStack { - ImageView(viewModel.userSession.client.fullURL(with: Paths.getSplashscreen())) .ignoresSafeArea() diff --git a/Swiftfin/Views/QuickConnectView.swift b/Swiftfin/Views/QuickConnectView.swift index 580416835..27f45cf3d 100644 --- a/Swiftfin/Views/QuickConnectView.swift +++ b/Swiftfin/Views/QuickConnectView.swift @@ -9,14 +9,13 @@ import SwiftUI struct QuickConnectView: View { - @EnvironmentObject private var router: QuickConnectCoordinator.Router @ObservedObject var viewModel: UserSignInViewModel - var body: some View { + func quickConnectWaitingAuthentication(quickConnectCode: String) -> some View { VStack(alignment: .leading, spacing: 20) { L10n.quickConnectStep1.text @@ -25,7 +24,7 @@ struct QuickConnectView: View { L10n.quickConnectStep3.text .padding(.bottom) - Text(viewModel.quickConnectCode ?? "------") + Text(quickConnectCode) .tracking(10) .font(.largeTitle) .monospacedDigit() @@ -33,22 +32,51 @@ struct QuickConnectView: View { 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() - } - } - } - .onDisappear { - viewModel.stopQuickConnectAuthCheck() + } + + var quickConnectFailed: some View { + Label { + Text("Failed to retrieve quick connect code") + } icon: { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) } - .navigationBarCloseButton { - router.dismissCoordinator() + } + + var quickConnectLoading: some View { + ProgressView() + } + + @ViewBuilder + var quickConnectBody: some View { + switch viewModel.quickConnectStatus { + case let .awaitingAuthentication(_, code): + quickConnectWaitingAuthentication(quickConnectCode: code) + case nil, .fetchingSecret: + quickConnectLoading + case .fetchingSecretFailed: + quickConnectFailed } } + + var body: some View { + quickConnectBody + .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() + } + } + } + .onDisappear { + viewModel.stopQuickConnectAuthCheck() + } + .navigationCloseButton { + router.dismissCoordinator() + } + } } From 78d2ea49c2cdc5d4fc47d6db87f74f10b744a53c Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 1 Mar 2024 14:47:47 -0800 Subject: [PATCH 3/4] cleaner quickconnect implementation; address comments --- Shared/ViewModels/UserSignInViewModel.swift | 166 ++++++++++---------- Swiftfin tvOS/Views/QuickConnectView.swift | 87 ++++++++++ Swiftfin tvOS/Views/UserSignInView.swift | 77 +-------- Swiftfin/Views/QuickConnectView.swift | 74 ++++----- 4 files changed, 209 insertions(+), 195 deletions(-) create mode 100644 Swiftfin tvOS/Views/QuickConnectView.swift diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift index c08c0b5f0..1f2c581d7 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -118,124 +118,124 @@ final class UserSignInViewModel: ViewModel { @Published private(set) var quickConnectEnabled = false - /// We want to tell the monitor task when we're ready to poll for authentication. Without this, we may encounter - /// a race condition where the monitor task expects the secret before it's fetched and exits prematurely. + /// To maintain logic within this view model, we expose this property to track the status during quick connect execution. @Published private(set) var quickConnectStatus: QuickConnectStatus? + /// How often to poll quick connect auth private let quickConnectPollTimeoutSeconds: UInt64 = 5 - /// We don't use Timer.scheduledTimer because checking quick connect status is async. We only want to start the next - /// poll when the current has finished. - private var quickConnectPollTask: Task? - /// When two quick connect tasks are started for whatever reason, cancelling the repeating task above seems to fail - /// and the attempted cancelled task seems to continue to spawn more repeating tasks. We ensure only a single - /// poll task continues to live with this ID. - private var quickConnectPollTaskID: UUID? - /// If, for whatever reason, the monitor task keeps going, we don't want to let it silently run forever. - private let quickConnectMaxRetries = 200 + private var quickConnectPollTask: Task? enum QuickConnectStatus { case fetchingSecret - // After secret and code is fetched from the server, store it in the associated value as: - // (secret, code) - case awaitingAuthentication(String, String) + case awaitingAuthentication(code: String) // Store the error and surface it to user if possible - case fetchingSecretFailed(Error?) + case error(Error) + case authorized } - func startQuickConnect() -> AsyncStream { - logger.debug("Attempting to start quick connect...") - quickConnectStatus = .fetchingSecret - - Task { - let initiatePath = Paths.initiate - do { - let response = try await client.send(initiatePath) - - guard let secret = response.value.secret, - let code = response.value.code - else { - // TODO: Create an error & display it in QuickConnectView (iOS)/UserSignInView (tvOS) - quickConnectStatus = .fetchingSecretFailed(nil) - return - } - - await MainActor.run { - quickConnectStatus = .awaitingAuthentication(secret, code) - } - - } catch { - quickConnectStatus = .fetchingSecretFailed(error) - } - } - - let taskID = UUID() - quickConnectPollTaskID = taskID - - return .init { continuation in - checkAuthStatus(continuation: continuation, id: taskID) - } + enum QuickConnectError: Error { + case fetchSecretFailed + case pollingFailed } - private func checkAuthStatus(continuation: AsyncStream.Continuation, id: UUID, tries: Int = 0) { - let task = Task { - // Don't race into failure while we're fetching the secret. - while case .fetchingSecret = quickConnectStatus { - try? await Task.sleep(nanoseconds: 1_000_000_000) + /// Signs in with quick connect. Returns whether sign in was successful. + func signInWithQuickConnect() async -> Bool { + do { + await MainActor.run { + quickConnectStatus = .fetchingSecret } + let (initiateSecret, code) = try await startQuickConnect() - logger.debug("Attempting to poll for quick connect auth on taskID \(id)") + await MainActor.run { + quickConnectStatus = .awaitingAuthentication(code: code) + } + let authSecret = try await pollForAuthSecret(initialSecret: initiateSecret) - guard case let .awaitingAuthentication(quickConnectSecret, _) = quickConnectStatus, - quickConnectPollTaskID == id else { return } + try await signIn(quickConnectSecret: authSecret) + await MainActor.run { + quickConnectStatus = .authorized + } - if tries > quickConnectMaxRetries { - logger.warning("Hit max retries while using quick connect, did `checkAuthStatus` keep running after signing in?") - stopQuickConnectAuthCheck() - return + return true + } catch { + await MainActor.run { + quickConnectStatus = .error(error) } + return false + } + } - let connectPath = Paths.connect(secret: quickConnectSecret) - let response = try? await client.send(connectPath) + 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) - if let responseValue = response?.value, responseValue.isAuthenticated ?? false { - continuation.yield(responseValue) + await MainActor.run { + quickConnectEnabled = isEnabled ?? false + } + } - await MainActor.run { - stopQuickConnectAuthCheck() - } + /// 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...") - return - } + let initiatePath = Paths.initiate + let response = try await client.send(initiatePath) - try? await Task.sleep(nanoseconds: 1_000_000_000 * quickConnectPollTimeoutSeconds) + guard let secret = response.value.secret, + let code = response.value.code + else { + throw QuickConnectError.fetchSecretFailed + } + + return (secret, code) + } - checkAuthStatus(continuation: continuation, id: id, tries: tries + 1) + private func pollForAuthSecret(initialSecret: String) async throws -> String { + let task = Task { + var authSecret: String? + repeat { + authSecret = try await checkAuth(initialSecret: initialSecret) + try await Task.sleep(nanoseconds: 1_000_000_000 * quickConnectPollTimeoutSeconds) + } while authSecret == nil + return authSecret! } quickConnectPollTask = task + return try await task.result.get() } - func stopQuickConnectAuthCheck() { - logger.debug("Stopping quick connect") + private func checkAuth(initialSecret: String) async throws -> String? { + logger.debug("Attempting to poll for quick connect auth") - quickConnectStatus = nil - quickConnectPollTaskID = nil + 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 + } } - 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) + func stopQuickConnectAuthCheck() { + logger.debug("Stopping quick connect") - await MainActor.run { - quickConnectEnabled = isEnabled ?? false - } + quickConnectStatus = nil + quickConnectPollTask?.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) diff --git a/Swiftfin tvOS/Views/QuickConnectView.swift b/Swiftfin tvOS/Views/QuickConnectView.swift new file mode 100644 index 000000000..5948cf5a8 --- /dev/null +++ b/Swiftfin tvOS/Views/QuickConnectView.swift @@ -0,0 +1,87 @@ +// +// 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: UserSignInViewModel + @Binding + var isPresentingQuickConnect: Bool + + 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.quickConnectStatus { + case let .awaitingAuthentication(code): + quickConnectWaitingAuthentication(quickConnectCode: code) + case nil, .fetchingSecret, .authorized: + 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) + } + .onAppear { + Task { + await viewModel.signInWithQuickConnect() + } + } + .onDisappear { + viewModel.stopQuickConnectAuthCheck() + } + } +} diff --git a/Swiftfin tvOS/Views/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView.swift index eb91c00cb..b4193d604 100644 --- a/Swiftfin tvOS/Views/UserSignInView.swift +++ b/Swiftfin tvOS/Views/UserSignInView.swift @@ -129,81 +129,6 @@ struct UserSignInView: View { } } - func quickConnectWaitingAuthentication(quickConnectCode: String) -> some View { - Group { - VStack(alignment: .leading, spacing: 20) { - L10n.quickConnectStep1.text - - L10n.quickConnectStep2.text - - L10n.quickConnectStep3.text - } - .padding(.vertical) - - 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.quickConnectStatus { - case let .awaitingAuthentication(_, code): - quickConnectWaitingAuthentication(quickConnectCode: code) - case nil, .fetchingSecret: - quickConnectLoading - case .fetchingSecretFailed: - quickConnectFailed - } - } - - @ViewBuilder - private var quickConnect: some View { - VStack(alignment: .center) { - L10n.quickConnect.text - .font(.title3) - .fontWeight(.semibold) - - quickConnectBody - .padding(.bottom) - - 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 body: some View { ZStack { ImageView(viewModel.userSession.client.fullURL(with: Paths.getSplashscreen())) @@ -231,7 +156,7 @@ struct UserSignInView: View { // ) // } .blurFullScreenCover(isPresented: $isPresentingQuickConnect) { - quickConnect + QuickConnectView(viewModel: viewModel, isPresentingQuickConnect: $isPresentingQuickConnect) } .onAppear { Task { diff --git a/Swiftfin/Views/QuickConnectView.swift b/Swiftfin/Views/QuickConnectView.swift index 27f45cf3d..bf55544d8 100644 --- a/Swiftfin/Views/QuickConnectView.swift +++ b/Swiftfin/Views/QuickConnectView.swift @@ -16,22 +16,11 @@ struct QuickConnectView: View { var viewModel: UserSignInViewModel func quickConnectWaitingAuthentication(quickConnectCode: String) -> some View { - VStack(alignment: .leading, spacing: 20) { - L10n.quickConnectStep1.text - - L10n.quickConnectStep2.text - - L10n.quickConnectStep3.text - .padding(.bottom) - - Text(quickConnectCode) - .tracking(10) - .font(.largeTitle) - .monospacedDigit() - .frame(maxWidth: .infinity) - - Spacer() - } + Text(quickConnectCode) + .tracking(10) + .font(.largeTitle) + .monospacedDigit() + .frame(maxWidth: .infinity) } var quickConnectFailed: some View { @@ -44,39 +33,52 @@ struct QuickConnectView: View { } var quickConnectLoading: some View { - ProgressView() + HStack { + Spacer() + ProgressView() + Spacer() + } } @ViewBuilder var quickConnectBody: some View { switch viewModel.quickConnectStatus { - case let .awaitingAuthentication(_, code): + case let .awaitingAuthentication(code): quickConnectWaitingAuthentication(quickConnectCode: code) - case nil, .fetchingSecret: + case nil, .fetchingSecret, .authorized: quickConnectLoading - case .fetchingSecretFailed: + case .error: quickConnectFailed } } var body: some View { - quickConnectBody - .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() - } + VStack(alignment: .leading, spacing: 20) { + L10n.quickConnectStep1.text + + L10n.quickConnectStep2.text + + L10n.quickConnectStep3.text + .padding(.bottom) + + quickConnectBody + + Spacer() + } + .padding(.horizontal) + .navigationTitle(L10n.quickConnect) + .onAppear { + Task { + if await viewModel.signInWithQuickConnect() { + router.dismissCoordinator() } } - .onDisappear { - viewModel.stopQuickConnectAuthCheck() - } - .navigationCloseButton { - router.dismissCoordinator() - } + } + .onDisappear { + viewModel.stopQuickConnectAuthCheck() + } + .navigationCloseButton { + router.dismissCoordinator() + } } } From 3de74c76ba5650ffb91c50e05a6364357864309f Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 22 Mar 2024 18:12:51 -0700 Subject: [PATCH 4/4] use stateful implementation --- .../QuickConnectCoordinator.swift | 4 +- Shared/ViewModels/QuickConnectViewModel.swift | 172 +++++++++++++ Shared/ViewModels/UserSignInViewModel.swift | 226 +++++++----------- Swiftfin tvOS/Views/QuickConnectView.swift | 20 +- Swiftfin tvOS/Views/UserSignInView.swift | 63 ++--- Swiftfin.xcodeproj/project.pbxproj | 10 + Swiftfin/Views/QuickConnectView.swift | 25 +- .../Components/PublicUserSignInView.swift | 8 +- .../Views/UserSignInView/UserSignInView.swift | 47 ++-- 9 files changed, 358 insertions(+), 217 deletions(-) create mode 100644 Shared/ViewModels/QuickConnectViewModel.swift diff --git a/Shared/Coordinators/QuickConnectCoordinator.swift b/Shared/Coordinators/QuickConnectCoordinator.swift index e13bd19fc..ad1eccc46 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 000000000..8147f8e14 --- /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 1f2c581d7..4240aa996 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -13,9 +13,39 @@ 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 quickConnectEnabled = false + + private var signInTask: Task? + + let quickConnectViewModel: QuickConnectViewModel let client: JellyfinClient let server: SwiftfinStore.State.Server @@ -26,10 +56,43 @@ final class UserSignInViewModel: ViewModel { 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) @@ -54,6 +117,27 @@ final class UserSignInViewModel: ViewModel { Notifications[.didSignIn].post() } + private func signIn(quickConnectSecret: String) async throws { + let quickConnectPath = Paths.authenticateWithQuickConnect(.init(secret: quickConnectSecret)) + let response = try await client.send(quickConnectPath) + + let user: UserState + + do { + user = try await createLocalUser(response: response.value) + } catch { + if case let SwiftfinStore.Error.existingUser(existingUser) = error { + user = existingUser + } else { + throw error + } + } + + Defaults[.lastServerUserID] = user.id + Container.userSession.reset() + Notifications[.didSignIn].post() + } + func getPublicUsers() async throws { let publicUsersPath = Paths.getPublicUsers let response = try await client.send(publicUsersPath) @@ -107,66 +191,6 @@ final class UserSignInViewModel: ViewModel { return user } - // MARK: - Quick Connect - - /// The typical quick connect lifecycle is as follows: - /// 1. User clicks quick connect - /// 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 - /// 5. Authentication poll succeeds with another secret, use secret to log in - - @Published - private(set) var quickConnectEnabled = false - /// To maintain logic within this view model, we expose this property to track the status during quick connect execution. - @Published - private(set) var quickConnectStatus: QuickConnectStatus? - - /// How often to poll quick connect auth - private let quickConnectPollTimeoutSeconds: UInt64 = 5 - - private var quickConnectPollTask: Task? - - enum QuickConnectStatus { - case fetchingSecret - case awaitingAuthentication(code: String) - // Store the error and surface it to user if possible - case error(Error) - case authorized - } - - enum QuickConnectError: Error { - case fetchSecretFailed - case pollingFailed - } - - /// Signs in with quick connect. Returns whether sign in was successful. - func signInWithQuickConnect() async -> Bool { - do { - await MainActor.run { - quickConnectStatus = .fetchingSecret - } - let (initiateSecret, code) = try await startQuickConnect() - - await MainActor.run { - quickConnectStatus = .awaitingAuthentication(code: code) - } - let authSecret = try await pollForAuthSecret(initialSecret: initiateSecret) - - try await signIn(quickConnectSecret: authSecret) - await MainActor.run { - quickConnectStatus = .authorized - } - - return true - } catch { - await MainActor.run { - quickConnectStatus = .error(error) - } - return false - } - } - func checkQuickConnect() async throws { let quickConnectEnabledPath = Paths.getEnabled let response = try await client.send(quickConnectEnabledPath) @@ -177,82 +201,4 @@ final class UserSignInViewModel: ViewModel { quickConnectEnabled = isEnabled ?? false } } - - /// 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? - repeat { - authSecret = try await checkAuth(initialSecret: initialSecret) - try await Task.sleep(nanoseconds: 1_000_000_000 * quickConnectPollTimeoutSeconds) - } while authSecret == nil - 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 - } - } - - func stopQuickConnectAuthCheck() { - logger.debug("Stopping quick connect") - - quickConnectStatus = nil - quickConnectPollTask?.cancel() - } - - private func signIn(quickConnectSecret: String) async throws { - let quickConnectPath = Paths.authenticateWithQuickConnect(.init(secret: quickConnectSecret)) - let response = try await client.send(quickConnectPath) - - let user: UserState - - do { - user = try await createLocalUser(response: response.value) - } catch { - if case let SwiftfinStore.Error.existingUser(existingUser) = error { - user = existingUser - } else { - throw error - } - } - - Defaults[.lastServerUserID] = user.id - Container.userSession.reset() - Notifications[.didSignIn].post() - } } diff --git a/Swiftfin tvOS/Views/QuickConnectView.swift b/Swiftfin tvOS/Views/QuickConnectView.swift index 5948cf5a8..2fa067030 100644 --- a/Swiftfin tvOS/Views/QuickConnectView.swift +++ b/Swiftfin tvOS/Views/QuickConnectView.swift @@ -10,9 +10,11 @@ import SwiftUI struct QuickConnectView: View { @ObservedObject - var viewModel: UserSignInViewModel + 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) @@ -37,10 +39,10 @@ struct QuickConnectView: View { @ViewBuilder var quickConnectBody: some View { - switch viewModel.quickConnectStatus { + switch viewModel.state { case let .awaitingAuthentication(code): quickConnectWaitingAuthentication(quickConnectCode: code) - case nil, .fetchingSecret, .authorized: + case .initial, .fetchingSecret, .authenticated: quickConnectLoading case .error: quickConnectFailed @@ -75,13 +77,17 @@ struct QuickConnectView: View { } .buttonStyle(.plain) } - .onAppear { - Task { - await viewModel.signInWithQuickConnect() + .onChange(of: viewModel.state) { newState in + if case let .authenticated(secret: secret) = newState { + signIn(secret) + isPresentingQuickConnect = false } } + .onAppear { + viewModel.send(.startQuickConnect) + } .onDisappear { - viewModel.stopQuickConnectAuthCheck() + viewModel.send(.cancelQuickConnect) } } } diff --git a/Swiftfin tvOS/Views/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView.swift index b4193d604..783a1173e 100644 --- a/Swiftfin tvOS/Views/UserSignInView.swift +++ b/Swiftfin tvOS/Views/UserSignInView.swift @@ -26,11 +26,9 @@ struct UserSignInView: View { @State private var isPresentingQuickConnect: Bool = false @State - private var password: String = "" - @State - private var signInError: Error? + private var isPresentingSignInError: Bool = false @State - private var signInTask: Task? + private var password: String = "" @State private var username: String = "" @@ -49,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() } @@ -129,6 +115,14 @@ struct UserSignInView: View { } } + 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())) @@ -148,15 +142,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) { - QuickConnectView(viewModel: viewModel, isPresentingQuickConnect: $isPresentingQuickConnect) + QuickConnectView( + viewModel: viewModel.quickConnectViewModel, + isPresentingQuickConnect: $isPresentingQuickConnect, + signIn: { authSecret in + self.viewModel.send(.signInWithQuickConnect(authSecret: authSecret)) + } + ) } .onAppear { Task { @@ -165,8 +173,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 72d794e0a..9c9e47826 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -759,6 +759,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 */ @@ -1292,6 +1295,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 */ @@ -1441,6 +1446,7 @@ BD0BA2292AD6501300306A8D /* VideoPlayerManager */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, + EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -2070,6 +2076,7 @@ E193D546271941C500900D82 /* UserListView.swift */, E193D548271941CC00900D82 /* UserSignInView.swift */, 5310694F2684E7EE00CFFDBA /* VideoPlayer */, + EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */, ); path = Views; sourceTree = ""; @@ -3228,6 +3235,7 @@ E1575E6A293E77B5001665B1 /* RoundedCorner.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 */, @@ -3314,6 +3322,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 */, @@ -3528,6 +3537,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 bf55544d8..5756090df 100644 --- a/Swiftfin/Views/QuickConnectView.swift +++ b/Swiftfin/Views/QuickConnectView.swift @@ -13,7 +13,10 @@ struct QuickConnectView: View { 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) @@ -42,10 +45,10 @@ struct QuickConnectView: View { @ViewBuilder var quickConnectBody: some View { - switch viewModel.quickConnectStatus { + switch viewModel.state { case let .awaitingAuthentication(code): quickConnectWaitingAuthentication(quickConnectCode: code) - case nil, .fetchingSecret, .authorized: + case .initial, .fetchingSecret, .authenticated: quickConnectLoading case .error: quickConnectFailed @@ -67,17 +70,19 @@ struct QuickConnectView: View { } .padding(.horizontal) .navigationTitle(L10n.quickConnect) - .onAppear { - Task { - if await viewModel.signInWithQuickConnect() { - 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) } - .navigationCloseButton { + .navigationBarCloseButton { router.dismissCoordinator() } } diff --git a/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift b/Swiftfin/Views/UserSignInView/Components/PublicUserSignInView.swift index 620de6349..44e98c5b8 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 cda18a4ab..6addcf6d2 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 { .disableAutocorrection(true) .autocapitalization(.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) } } }