354 lines
11 KiB
Swift
354 lines
11 KiB
Swift
//
|
|
// 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 Combine
|
|
import CoreStore
|
|
import Factory
|
|
import Foundation
|
|
import Get
|
|
import JellyfinAPI
|
|
import KeychainSwift
|
|
import OrderedCollections
|
|
import SwiftUI
|
|
|
|
// TODO: instead of just signing in duplicate user, send event for alert
|
|
// to override existing user access token?
|
|
// - won't require deleting and re-signing in user for password changes
|
|
// - account for local device auth required
|
|
// TODO: ignore NSURLErrorDomain Code=-999 cancelled error on sign in
|
|
// - need to make NSError wrappres anyways
|
|
|
|
// Note: UserDto in StoredValues so that it doesn't need to be passed
|
|
// around along with the user UserState. Was just easy
|
|
|
|
final class UserSignInViewModel: ViewModel, Eventful, Stateful {
|
|
|
|
// MARK: Event
|
|
|
|
enum Event {
|
|
case duplicateUser(UserState)
|
|
case error(JellyfinAPIError)
|
|
case signedIn(UserState)
|
|
}
|
|
|
|
// MARK: Action
|
|
|
|
enum Action: Equatable {
|
|
case getPublicData
|
|
case signIn(username: String, password: String, policy: UserAccessPolicy)
|
|
case signInDuplicate(UserState, replace: Bool)
|
|
case signInQuickConnect(secret: String, policy: UserAccessPolicy)
|
|
case cancel
|
|
}
|
|
|
|
enum BackgroundState: Hashable {
|
|
case gettingPublicData
|
|
}
|
|
|
|
// MARK: State
|
|
|
|
enum State: Hashable {
|
|
case initial
|
|
case signingIn
|
|
}
|
|
|
|
@Published
|
|
var backgroundStates: OrderedSet<BackgroundState> = []
|
|
@Published
|
|
var isQuickConnectEnabled = false
|
|
@Published
|
|
var publicUsers: [UserDto] = []
|
|
@Published
|
|
var serverDisclaimer: String? = nil
|
|
@Published
|
|
var state: State = .initial
|
|
|
|
var events: AnyPublisher<Event, Never> {
|
|
eventSubject
|
|
.receive(on: RunLoop.main)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
let quickConnect: QuickConnect
|
|
let server: ServerState
|
|
|
|
private var eventSubject: PassthroughSubject<Event, Never> = .init()
|
|
private var signInTask: AnyCancellable?
|
|
|
|
init(server: ServerState) {
|
|
self.server = server
|
|
self.quickConnect = QuickConnect(client: server.client)
|
|
|
|
super.init()
|
|
|
|
quickConnect.$state
|
|
.sink { [weak self] state in
|
|
if case let QuickConnect.State.authenticated(secret: secret) = state {
|
|
guard let self else { return }
|
|
|
|
Task {
|
|
await self.send(.signInQuickConnect(secret: secret, policy: StoredValues[.Temp.userAccessPolicy]))
|
|
}
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
func respond(to action: Action) -> State {
|
|
switch action {
|
|
case .getPublicData:
|
|
Task { [weak self] in
|
|
do {
|
|
|
|
await MainActor.run {
|
|
let _ = self?.backgroundStates.append(.gettingPublicData)
|
|
}
|
|
|
|
let isQuickConnectEnabled = try await self?.retrieveQuickConnectEnabled()
|
|
let publicUsers = try await self?.retrievePublicUsers()
|
|
let serverMessage = try await self?.retrieveServerDisclaimer()
|
|
|
|
guard let self else { return }
|
|
|
|
await MainActor.run {
|
|
self.backgroundStates.remove(.gettingPublicData)
|
|
self.isQuickConnectEnabled = isQuickConnectEnabled ?? false
|
|
self.publicUsers = publicUsers ?? []
|
|
self.serverDisclaimer = serverMessage
|
|
}
|
|
} catch {
|
|
self?.backgroundStates.remove(.gettingPublicData)
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
return state
|
|
case let .signIn(username, password, policy):
|
|
signInTask?.cancel()
|
|
|
|
signInTask = Task {
|
|
do {
|
|
let user = try await signIn(username: username, password: password, policy: policy)
|
|
|
|
if isDuplicate(user: user) {
|
|
await MainActor.run {
|
|
// user has same id, but new access token
|
|
self.eventSubject.send(.duplicateUser(user))
|
|
}
|
|
} else {
|
|
try await save(user: user)
|
|
|
|
await MainActor.run {
|
|
self.eventSubject.send(.signedIn(user))
|
|
}
|
|
}
|
|
|
|
await MainActor.run {
|
|
self.state = .initial
|
|
}
|
|
} catch is CancellationError {
|
|
// cancel doesn't matter
|
|
} catch {
|
|
await MainActor.run {
|
|
self.eventSubject.send(.error(.init(error.localizedDescription)))
|
|
self.state = .initial
|
|
}
|
|
}
|
|
}
|
|
.asAnyCancellable()
|
|
|
|
return .signingIn
|
|
case let .signInDuplicate(duplicateUser, replace):
|
|
if replace {
|
|
setNewAccessToken(user: duplicateUser)
|
|
} else {
|
|
// just need the id, even though this has a different
|
|
// access token than stored
|
|
eventSubject.send(.signedIn(duplicateUser))
|
|
}
|
|
|
|
return state
|
|
case let .signInQuickConnect(secret, policy):
|
|
signInTask?.cancel()
|
|
|
|
signInTask = Task {
|
|
do {
|
|
let user = try await signIn(secret: secret, policy: policy)
|
|
|
|
if isDuplicate(user: user) {
|
|
await MainActor.run {
|
|
// user has same id, but new access token
|
|
self.eventSubject.send(.duplicateUser(user))
|
|
}
|
|
} else {
|
|
try await save(user: user)
|
|
|
|
await MainActor.run {
|
|
self.eventSubject.send(.signedIn(user))
|
|
}
|
|
}
|
|
|
|
await MainActor.run {
|
|
self.state = .initial
|
|
}
|
|
} catch is CancellationError {
|
|
// cancel doesn't matter
|
|
} catch {
|
|
await MainActor.run {
|
|
self.eventSubject.send(.error(.init(error.localizedDescription)))
|
|
self.state = .initial
|
|
}
|
|
}
|
|
}
|
|
.asAnyCancellable()
|
|
|
|
return .signingIn
|
|
case .cancel:
|
|
signInTask?.cancel()
|
|
|
|
return .initial
|
|
}
|
|
}
|
|
|
|
private func signIn(username: String, password: String, policy: UserAccessPolicy) async throws -> UserState {
|
|
let username = username
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.trimmingCharacters(in: .objectReplacement)
|
|
|
|
let password = password
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.trimmingCharacters(in: .objectReplacement)
|
|
|
|
let response = try await server.client.signIn(username: username, password: password)
|
|
|
|
guard let accessToken = response.accessToken,
|
|
let userData = response.user,
|
|
let id = userData.id,
|
|
let username = userData.name
|
|
else {
|
|
logger.critical("Missing user data from network call")
|
|
throw JellyfinAPIError("An internal error has occurred")
|
|
}
|
|
|
|
StoredValues[.Temp.userData] = userData
|
|
StoredValues[.Temp.userAccessPolicy] = policy
|
|
|
|
let newState = UserState(
|
|
id: id,
|
|
serverID: server.id,
|
|
username: username
|
|
)
|
|
|
|
newState.accessToken = accessToken
|
|
|
|
return newState
|
|
}
|
|
|
|
private func signIn(secret: String, policy: UserAccessPolicy) async throws -> UserState {
|
|
|
|
let response = try await server.client.signIn(quickConnectSecret: secret)
|
|
|
|
guard let accessToken = response.accessToken,
|
|
let userData = response.user,
|
|
let id = userData.id,
|
|
let username = userData.name
|
|
else {
|
|
logger.critical("Missing user data from network call")
|
|
throw JellyfinAPIError("An internal error has occurred")
|
|
}
|
|
|
|
StoredValues[.Temp.userData] = userData
|
|
StoredValues[.Temp.userAccessPolicy] = policy
|
|
|
|
let newState = UserState(
|
|
id: id,
|
|
serverID: server.id,
|
|
username: username
|
|
)
|
|
|
|
newState.accessToken = accessToken
|
|
|
|
return newState
|
|
}
|
|
|
|
private func isDuplicate(user: UserState) -> Bool {
|
|
let existingUser = try? SwiftfinStore
|
|
.dataStack
|
|
.fetchOne(From<UserModel>().where(\.$id == user.id))
|
|
return existingUser != nil
|
|
}
|
|
|
|
@MainActor
|
|
private func save(user: UserState) async throws {
|
|
|
|
guard let serverModel = try? dataStack.fetchOne(From<ServerModel>().where(\.$id == server.id)) else {
|
|
logger.critical("Unable to find server to save user")
|
|
throw JellyfinAPIError("An internal error has occurred")
|
|
}
|
|
|
|
let user = try dataStack.perform { transaction in
|
|
let newUser = transaction.create(Into<UserModel>())
|
|
|
|
newUser.id = user.id
|
|
newUser.username = user.username
|
|
|
|
let editServer = transaction.edit(serverModel)!
|
|
editServer.users.insert(newUser)
|
|
|
|
return newUser.state
|
|
}
|
|
|
|
user.data = StoredValues[.Temp.userData]
|
|
user.accessPolicy = StoredValues[.Temp.userAccessPolicy]
|
|
|
|
keychain.set(StoredValues[.Temp.userLocalPin], forKey: "\(user.id)-pin")
|
|
user.pinHint = StoredValues[.Temp.userLocalPinHint]
|
|
|
|
// TODO: remove when implemented periodic cleanup elsewhere
|
|
StoredValues[.Temp.userAccessPolicy] = .none
|
|
StoredValues[.Temp.userLocalPin] = ""
|
|
StoredValues[.Temp.userLocalPinHint] = ""
|
|
}
|
|
|
|
private func retrievePublicUsers() async throws -> [UserDto] {
|
|
let request = Paths.getPublicUsers
|
|
let response = try await server.client.send(request)
|
|
|
|
return response.value
|
|
}
|
|
|
|
private func retrieveServerDisclaimer() async throws -> String? {
|
|
let request = Paths.getBrandingOptions
|
|
let response = try await server.client.send(request)
|
|
|
|
guard let disclaimer = response.value.loginDisclaimer, disclaimer.isNotEmpty else { return nil }
|
|
|
|
return disclaimer
|
|
}
|
|
|
|
private func retrieveQuickConnectEnabled() async throws -> Bool {
|
|
let request = Paths.getEnabled
|
|
let response = try await server.client.send(request)
|
|
|
|
let isEnabled = try? JSONDecoder().decode(Bool.self, from: response.value)
|
|
return isEnabled ?? false
|
|
}
|
|
|
|
// server has same id, but new access token
|
|
private func setNewAccessToken(user: UserState) {
|
|
do {
|
|
guard let existingUser = try dataStack.fetchOne(From<UserModel>().where(\.$id == user.id)) else { return }
|
|
existingUser.state.accessToken = user.accessToken
|
|
|
|
eventSubject.send(.signedIn(existingUser.state))
|
|
} catch {
|
|
logger.critical("\(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|