Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix quick connect #874

Merged
merged 4 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Shared/Coordinators/QuickConnectCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
}
}
172 changes: 172 additions & 0 deletions Shared/ViewModels/QuickConnectViewModel.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
155 changes: 82 additions & 73 deletions Shared/ViewModels/UserSignInViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
private(set) var publicUsers: [UserDto] = []
var state: State = .initial
@Published
private(set) var quickConnectCode: String?
private(set) var publicUsers: [UserDto] = []
@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)
Expand All @@ -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)

Expand All @@ -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,
Expand Down Expand Up @@ -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
}
}
}
Loading
Loading