Fix quick connect (#874)
This commit is contained in:
parent
99375e98ff
commit
ad8f4bbefd
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue