Fix quick connect (#874)

This commit is contained in:
Tony 2024-04-22 21:22:10 -07:00 committed by GitHub
parent 99375e98ff
commit ad8f4bbefd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 466 additions and 190 deletions

View File

@ -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))
})
}
}

View File

@ -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<String, any Error>?
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()
}
}

View File

@ -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<Void, Never>?
let quickConnectViewModel: QuickConnectViewModel
let client: JellyfinClient
let server: SwiftfinStore.State.Server
private var quickConnectTask: Task<Void, Never>?
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<QuickConnectResult> {
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<QuickConnectResult>.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
}
}
}

View File

@ -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)
}
}
}

View File

@ -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<Void, Never>?
@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)
}
}
}

View File

@ -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 = "<group>"; };
E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = "<group>"; };
E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenePhaseChangeModifier.swift; sourceTree = "<group>"; };
EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectViewModel.swift; sourceTree = "<group>"; };
EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -1527,6 +1532,7 @@
BD0BA2292AD6501300306A8D /* VideoPlayerManager */,
E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */,
625CB57B2678CE1000530A6E /* ViewModel.swift */,
EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -2327,6 +2333,7 @@
E193D546271941C500900D82 /* UserListView.swift */,
E193D548271941CC00900D82 /* UserSignInView.swift */,
5310694F2684E7EE00CFFDBA /* VideoPlayer */,
EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -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 */,

View File

@ -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)
.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()

View File

@ -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)
}
viewModel.send(.signInWithUserPass(username: username, password: password))
} label: {
L10n.signIn.text
}

View File

@ -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<Void, Never>?
@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)
}
}
}