Fix quick connect (#874)
This commit is contained in:
parent
99375e98ff
commit
ad8f4bbefd
|
@ -25,6 +25,8 @@ final class QuickConnectCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeStart() -> some View {
|
func makeStart() -> some View {
|
||||||
QuickConnectView(viewModel: viewModel)
|
QuickConnectView(viewModel: viewModel.quickConnectViewModel, signIn: { authSecret in
|
||||||
|
self.viewModel.send(.signInWithQuickConnect(authSecret: authSecret))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,33 +13,86 @@ import Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import Pulse
|
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
|
@Published
|
||||||
private(set) var publicUsers: [UserDto] = []
|
private(set) var publicUsers: [UserDto] = []
|
||||||
@Published
|
@Published
|
||||||
private(set) var quickConnectCode: String?
|
|
||||||
@Published
|
|
||||||
private(set) var quickConnectEnabled = false
|
private(set) var quickConnectEnabled = false
|
||||||
|
|
||||||
|
private var signInTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
let quickConnectViewModel: QuickConnectViewModel
|
||||||
|
|
||||||
let client: JellyfinClient
|
let client: JellyfinClient
|
||||||
let server: SwiftfinStore.State.Server
|
let server: SwiftfinStore.State.Server
|
||||||
|
|
||||||
private var quickConnectTask: Task<Void, Never>?
|
|
||||||
private var quickConnectTimer: RepeatingTimer?
|
|
||||||
private var quickConnectSecret: String?
|
|
||||||
|
|
||||||
init(server: ServerState) {
|
init(server: ServerState) {
|
||||||
self.client = JellyfinClient(
|
self.client = JellyfinClient(
|
||||||
configuration: .swiftfinConfiguration(url: server.currentURL),
|
configuration: .swiftfinConfiguration(url: server.currentURL),
|
||||||
sessionDelegate: URLSessionProxyDelegate()
|
sessionDelegate: URLSessionProxyDelegate()
|
||||||
)
|
)
|
||||||
self.server = server
|
self.server = server
|
||||||
|
self.quickConnectViewModel = .init(client: client)
|
||||||
super.init()
|
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)
|
let username = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.trimmingCharacters(in: .objectReplacement)
|
.trimmingCharacters(in: .objectReplacement)
|
||||||
let password = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
let password = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
@ -64,71 +117,7 @@ final class UserSignInViewModel: ViewModel {
|
||||||
Notifications[.didSignIn].post()
|
Notifications[.didSignIn].post()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPublicUsers() async throws {
|
private func signIn(quickConnectSecret: String) 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 {
|
|
||||||
let quickConnectPath = Paths.authenticateWithQuickConnect(.init(secret: quickConnectSecret))
|
let quickConnectPath = Paths.authenticateWithQuickConnect(.init(secret: quickConnectSecret))
|
||||||
let response = try await client.send(quickConnectPath)
|
let response = try await client.send(quickConnectPath)
|
||||||
|
|
||||||
|
@ -149,6 +138,15 @@ final class UserSignInViewModel: ViewModel {
|
||||||
Notifications[.didSignIn].post()
|
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
|
@MainActor
|
||||||
private func createLocalUser(response: AuthenticationResult) async throws -> UserState {
|
private func createLocalUser(response: AuthenticationResult) async throws -> UserState {
|
||||||
guard let accessToken = response.accessToken,
|
guard let accessToken = response.accessToken,
|
||||||
|
@ -192,4 +190,15 @@ final class UserSignInViewModel: ViewModel {
|
||||||
|
|
||||||
return user
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,7 +12,6 @@ import Stinsen
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct UserSignInView: View {
|
struct UserSignInView: View {
|
||||||
|
|
||||||
enum FocusedField {
|
enum FocusedField {
|
||||||
case username
|
case username
|
||||||
case password
|
case password
|
||||||
|
@ -27,12 +26,10 @@ struct UserSignInView: View {
|
||||||
@State
|
@State
|
||||||
private var isPresentingQuickConnect: Bool = false
|
private var isPresentingQuickConnect: Bool = false
|
||||||
@State
|
@State
|
||||||
|
private var isPresentingSignInError: Bool = false
|
||||||
|
@State
|
||||||
private var password: String = ""
|
private var password: String = ""
|
||||||
@State
|
@State
|
||||||
private var signInError: Error?
|
|
||||||
@State
|
|
||||||
private var signInTask: Task<Void, Never>?
|
|
||||||
@State
|
|
||||||
private var username: String = ""
|
private var username: String = ""
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -50,22 +47,10 @@ struct UserSignInView: View {
|
||||||
.focused($focusedField, equals: .password)
|
.focused($focusedField, equals: .password)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
let task = Task {
|
viewModel.send(.signInWithUserPass(username: username, password: password))
|
||||||
viewModel.isLoading = true
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await viewModel.signIn(username: username, password: password)
|
|
||||||
} catch {
|
|
||||||
signInError = error
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
signInTask = task
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
if viewModel.isLoading {
|
if case viewModel.state = .signingIn {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,52 +109,16 @@ struct UserSignInView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
var errorText: some View {
|
||||||
private var quickConnect: some View {
|
var text: String?
|
||||||
VStack(alignment: .center) {
|
if case let .error(error) = viewModel.state {
|
||||||
L10n.quickConnect.text
|
text = error.localizedDescription
|
||||||
.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()
|
|
||||||
}
|
}
|
||||||
|
return Text(text ?? .emptyDash)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|
||||||
ImageView(viewModel.userSession.client.fullURL(with: Paths.getSplashscreen()))
|
ImageView(viewModel.userSession.client.fullURL(with: Paths.getSplashscreen()))
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
@ -187,15 +136,29 @@ struct UserSignInView: View {
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
}
|
}
|
||||||
.navigationTitle(L10n.signIn)
|
.navigationTitle(L10n.signIn)
|
||||||
// .alert(item: $viewModel.errorMessage) { _ in
|
.onChange(of: viewModel.state) { _ in
|
||||||
// Alert(
|
// If we encountered the error as we switched from quick connect cover to this view,
|
||||||
// title: Text(viewModel.alertTitle),
|
// it's possible that the alert doesn't show, so wait a little bit
|
||||||
// message: Text(viewModel.errorMessage?.message ?? L10n.unknownError),
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
// dismissButton: .cancel()
|
isPresentingSignInError = true
|
||||||
// )
|
}
|
||||||
// }
|
}
|
||||||
|
.alert(
|
||||||
|
L10n.error,
|
||||||
|
isPresented: $isPresentingSignInError
|
||||||
|
) {
|
||||||
|
Button(L10n.dismiss, role: .cancel)
|
||||||
|
} message: {
|
||||||
|
errorText
|
||||||
|
}
|
||||||
.blurFullScreenCover(isPresented: $isPresentingQuickConnect) {
|
.blurFullScreenCover(isPresented: $isPresentingQuickConnect) {
|
||||||
quickConnect
|
QuickConnectView(
|
||||||
|
viewModel: viewModel.quickConnectViewModel,
|
||||||
|
isPresentingQuickConnect: $isPresentingQuickConnect,
|
||||||
|
signIn: { authSecret in
|
||||||
|
self.viewModel.send(.signInWithQuickConnect(authSecret: authSecret))
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task {
|
Task {
|
||||||
|
@ -204,8 +167,7 @@ struct UserSignInView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
viewModel.isLoading = false
|
viewModel.send(.cancelSignIn)
|
||||||
viewModel.stopQuickConnectAuthCheck()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -802,6 +802,9 @@
|
||||||
E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */; };
|
E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */; };
|
||||||
E43918662AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */; };
|
E43918662AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */; };
|
||||||
E43918672AD5C8310045A18C /* 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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
@ -1369,6 +1372,8 @@
|
||||||
E1FE69A628C29B720021BC93 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
@ -1527,6 +1532,7 @@
|
||||||
BD0BA2292AD6501300306A8D /* VideoPlayerManager */,
|
BD0BA2292AD6501300306A8D /* VideoPlayerManager */,
|
||||||
E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */,
|
E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */,
|
||||||
625CB57B2678CE1000530A6E /* ViewModel.swift */,
|
625CB57B2678CE1000530A6E /* ViewModel.swift */,
|
||||||
|
EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2327,6 +2333,7 @@
|
||||||
E193D546271941C500900D82 /* UserListView.swift */,
|
E193D546271941C500900D82 /* UserListView.swift */,
|
||||||
E193D548271941CC00900D82 /* UserSignInView.swift */,
|
E193D548271941CC00900D82 /* UserSignInView.swift */,
|
||||||
5310694F2684E7EE00CFFDBA /* VideoPlayer */,
|
5310694F2684E7EE00CFFDBA /* VideoPlayer */,
|
||||||
|
EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -3521,6 +3528,7 @@
|
||||||
E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
|
E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
|
||||||
E1DD55382B6EE533007501C0 /* Task.swift in Sources */,
|
E1DD55382B6EE533007501C0 /* Task.swift in Sources */,
|
||||||
E1575EA1293E7B1E001665B1 /* String.swift in Sources */,
|
E1575EA1293E7B1E001665B1 /* String.swift in Sources */,
|
||||||
|
EADD26FD2BAE4A6C002F05DE /* QuickConnectView.swift in Sources */,
|
||||||
E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */,
|
E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */,
|
||||||
E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */,
|
E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */,
|
||||||
E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */,
|
E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */,
|
||||||
|
@ -3610,6 +3618,7 @@
|
||||||
E12376B02A33D6AE001F5B44 /* AboutViewCard.swift in Sources */,
|
E12376B02A33D6AE001F5B44 /* AboutViewCard.swift in Sources */,
|
||||||
E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */,
|
E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */,
|
||||||
E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */,
|
E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */,
|
||||||
|
EA2073A22BAE35A400D8C78F /* QuickConnectViewModel.swift in Sources */,
|
||||||
E1579EA82B97DC1500A31CA1 /* Eventful.swift in Sources */,
|
E1579EA82B97DC1500A31CA1 /* Eventful.swift in Sources */,
|
||||||
E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */,
|
E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */,
|
||||||
E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */,
|
E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */,
|
||||||
|
@ -3838,6 +3847,7 @@
|
||||||
E1E1644128BB301900323B0A /* Array.swift in Sources */,
|
E1E1644128BB301900323B0A /* Array.swift in Sources */,
|
||||||
E18CE0AF28A222240092E7F1 /* PublicUserSignInView.swift in Sources */,
|
E18CE0AF28A222240092E7F1 /* PublicUserSignInView.swift in Sources */,
|
||||||
E129429828F4785200796AC6 /* CaseIterablePicker.swift in Sources */,
|
E129429828F4785200796AC6 /* CaseIterablePicker.swift in Sources */,
|
||||||
|
EA2073A12BAE35A400D8C78F /* QuickConnectViewModel.swift in Sources */,
|
||||||
E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */,
|
E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */,
|
||||||
E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */,
|
E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */,
|
||||||
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */,
|
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */,
|
||||||
|
|
|
@ -9,12 +9,51 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct QuickConnectView: View {
|
struct QuickConnectView: View {
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
private var router: QuickConnectCoordinator.Router
|
private var router: QuickConnectCoordinator.Router
|
||||||
|
|
||||||
@ObservedObject
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
@ -25,27 +64,23 @@ struct QuickConnectView: View {
|
||||||
L10n.quickConnectStep3.text
|
L10n.quickConnectStep3.text
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
|
|
||||||
Text(viewModel.quickConnectCode ?? "------")
|
quickConnectBody
|
||||||
.tracking(10)
|
|
||||||
.font(.largeTitle)
|
|
||||||
.monospacedDigit()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.navigationTitle(L10n.quickConnect)
|
.navigationTitle(L10n.quickConnect)
|
||||||
.onAppear {
|
.onChange(of: viewModel.state) { newState in
|
||||||
Task {
|
if case let .authenticated(secret: secret) = newState {
|
||||||
for await result in viewModel.startQuickConnect() {
|
signIn(secret)
|
||||||
guard let secret = result.secret else { continue }
|
|
||||||
try? await viewModel.signIn(quickConnectSecret: secret)
|
|
||||||
router.dismissCoordinator()
|
router.dismissCoordinator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
viewModel.send(.startQuickConnect)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
viewModel.stopQuickConnectAuthCheck()
|
viewModel.send(.cancelQuickConnect)
|
||||||
}
|
}
|
||||||
.navigationBarCloseButton {
|
.navigationBarCloseButton {
|
||||||
router.dismissCoordinator()
|
router.dismissCoordinator()
|
||||||
|
|
|
@ -10,9 +10,7 @@ import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension UserSignInView {
|
extension UserSignInView {
|
||||||
|
|
||||||
struct PublicUserSignInView: View {
|
struct PublicUserSignInView: View {
|
||||||
|
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var viewModel: UserSignInViewModel
|
var viewModel: UserSignInViewModel
|
||||||
|
|
||||||
|
@ -25,10 +23,8 @@ extension UserSignInView {
|
||||||
DisclosureGroup {
|
DisclosureGroup {
|
||||||
SecureField(L10n.password, text: $password)
|
SecureField(L10n.password, text: $password)
|
||||||
Button {
|
Button {
|
||||||
Task {
|
|
||||||
guard let username = publicUser.name else { return }
|
guard let username = publicUser.name else { return }
|
||||||
try? await viewModel.signIn(username: username, password: password)
|
viewModel.send(.signInWithUserPass(username: username, password: password))
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
L10n.signIn.text
|
L10n.signIn.text
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,6 @@ import Stinsen
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct UserSignInView: View {
|
struct UserSignInView: View {
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
private var router: UserSignInCoordinator.Router
|
private var router: UserSignInCoordinator.Router
|
||||||
|
|
||||||
|
@ -22,10 +21,6 @@ struct UserSignInView: View {
|
||||||
@State
|
@State
|
||||||
private var password: String = ""
|
private var password: String = ""
|
||||||
@State
|
@State
|
||||||
private var signInError: Error?
|
|
||||||
@State
|
|
||||||
private var signInTask: Task<Void, Never>?
|
|
||||||
@State
|
|
||||||
private var username: String = ""
|
private var username: String = ""
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -39,28 +34,15 @@ struct UserSignInView: View {
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.none)
|
.textInputAutocapitalization(.none)
|
||||||
|
|
||||||
if viewModel.isLoading {
|
if case .signingIn = viewModel.state {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
viewModel.isLoading = false
|
viewModel.send(.cancelSignIn)
|
||||||
signInTask?.cancel()
|
|
||||||
} label: {
|
} label: {
|
||||||
L10n.cancel.text
|
L10n.cancel.text
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
let task = Task {
|
viewModel.send(.signInWithUserPass(username: username, password: password))
|
||||||
viewModel.isLoading = true
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await viewModel.signIn(username: username, password: password)
|
|
||||||
} catch {
|
|
||||||
signInError = error
|
|
||||||
isPresentingSignInError = true
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.isLoading = false
|
|
||||||
}
|
|
||||||
signInTask = task
|
|
||||||
} label: {
|
} label: {
|
||||||
L10n.signIn.text
|
L10n.signIn.text
|
||||||
}
|
}
|
||||||
|
@ -104,9 +86,16 @@ struct UserSignInView: View {
|
||||||
.headerProminence(.increased)
|
.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 {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
|
|
||||||
signInSection
|
signInSection
|
||||||
|
|
||||||
if viewModel.quickConnectEnabled {
|
if viewModel.quickConnectEnabled {
|
||||||
|
@ -119,13 +108,22 @@ struct UserSignInView: View {
|
||||||
|
|
||||||
publicUsersSection
|
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(
|
.alert(
|
||||||
L10n.error,
|
L10n.error,
|
||||||
isPresented: $isPresentingSignInError
|
isPresented: $isPresentingSignInError
|
||||||
) {
|
) {
|
||||||
Button(L10n.dismiss, role: .cancel)
|
Button(L10n.dismiss, role: .cancel)
|
||||||
} message: {
|
} message: {
|
||||||
Text(signInError?.localizedDescription ?? .emptyDash)
|
errorText
|
||||||
}
|
}
|
||||||
.navigationTitle(L10n.signIn)
|
.navigationTitle(L10n.signIn)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
@ -135,8 +133,7 @@ struct UserSignInView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
viewModel.isLoading = false
|
viewModel.send(.cancelSignIn)
|
||||||
viewModel.stopQuickConnectAuthCheck()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue