Add Quick Connect sign in to tvOS (v2) (#487)

This commit is contained in:
David Ullmer 2022-07-18 15:52:13 +02:00 committed by GitHub
parent 30cb980ed3
commit ed519744f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 493 additions and 354 deletions

View File

@ -89,7 +89,7 @@ final class MainCoordinator: NavigationCoordinatable {
else { fatalError("Need to have new current login state server") }
guard SessionManager.main.currentLogin != nil else { return }
if newCurrentServerState.id == SessionManager.main.currentLogin.server.id {
SessionManager.main.loginUser(server: newCurrentServerState, user: SessionManager.main.currentLogin.user)
SessionManager.main.signInUser(server: newCurrentServerState, user: SessionManager.main.currentLogin.user)
}
}

View File

@ -270,6 +270,14 @@ internal enum L10n {
internal static var quickConnectCode: String { return L10n.tr("Localizable", "quickConnectCode") }
/// Invalid Quick Connect code
internal static var quickConnectInvalidError: String { return L10n.tr("Localizable", "quickConnectInvalidError") }
/// Note: Quick Connect not enabled
internal static var quickConnectNotEnabled: String { return L10n.tr("Localizable", "quickConnectNotEnabled") }
/// 1. Open the Jellyfin app on your phone or web browser and sign in with your account
internal static var quickConnectStep1: String { return L10n.tr("Localizable", "quickConnectStep1") }
/// 2. Open the user menu and go to the Quick Connect page
internal static var quickConnectStep2: String { return L10n.tr("Localizable", "quickConnectStep2") }
/// 3. Enter the following code:
internal static var quickConnectStep3: String { return L10n.tr("Localizable", "quickConnectStep3") }
/// Authorizing Quick Connect successful. Please continue on your other device.
internal static var quickConnectSuccessMessage: String { return L10n.tr("Localizable", "quickConnectSuccessMessage") }
/// Rated

View File

@ -22,7 +22,7 @@ final class SessionManager {
// MARK: currentLogin
private(set) var currentLogin: CurrentLogin!
fileprivate(set) var currentLogin: CurrentLogin!
// MARK: main
@ -31,6 +31,8 @@ final class SessionManager {
// MARK: init
private init() {
setAuthHeader(with: "")
if let lastUserID = Defaults[.lastServerUserID],
let user = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(),
@ -195,86 +197,31 @@ final class SessionManager {
.eraseToAnyPublisher()
}
// MARK: loginUser publisher
// MARK: signInUser publisher
// Logs in a user with an associated server, storing if successful
func loginUser(
func signInUser(
server: SwiftfinStore.State.Server,
username: String,
password: String
) -> AnyPublisher<SwiftfinStore.State.User, Error> {
setAuthHeader(with: "")
JellyfinAPIAPI.basePath = server.currentURI
return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password))
.tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in
guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") }
let transaction = SwiftfinStore.dataStack.beginUnsafe()
let newUser = transaction.create(Into<SwiftfinStore.Models.StoredUser>())
guard let username = response.user?.name,
let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") }
newUser.username = username
newUser.id = id
newUser.appleTVID = ""
// Check for existing user on device
if let existingUser = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>(
"id == %@",
newUser.id
)]
) {
throw SwiftfinStore.Error.existingUser(existingUser.state)
.processAuthenticationRequest(with: self, server: server)
}
let newAccessToken = transaction.create(Into<SwiftfinStore.Models.StoredAccessToken>())
newAccessToken.value = accessToken
newUser.accessToken = newAccessToken
// Logs in a user with an associated server, storing if successful
func signInUser(server: SwiftfinStore.State.Server, quickConnectSecret: String) -> AnyPublisher<SwiftfinStore.State.User, Error> {
JellyfinAPIAPI.basePath = server.currentURI
guard let userServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[
Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
),
]
)
else { fatalError("No stored server associated with given state server?") }
guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") }
editUserServer.users.insert(newUser)
return (editUserServer, newUser, transaction)
}
.handleEvents(receiveOutput: { [unowned self] server, user, transaction in
setAuthHeader(with: user.accessToken?.value ?? "")
try? transaction.commitAndWait()
// Fetch for the right queue
let currentServer = SwiftfinStore.dataStack.fetchExisting(server)!
let currentUser = SwiftfinStore.dataStack.fetchExisting(user)!
Defaults[.lastServerUserID] = user.id
currentLogin = (server: currentServer.state, user: currentUser.state)
Notifications[.didSignIn].post()
})
.map { _, user, _ in
user.state
}
.eraseToAnyPublisher()
return UserAPI.authenticateWithQuickConnect(authenticateWithQuickConnectRequest: .init(secret: quickConnectSecret))
.processAuthenticationRequest(with: self, server: server)
}
// MARK: loginUser
// MARK: signInUser
func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) {
func signInUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) {
JellyfinAPIAPI.basePath = server.currentURI
Defaults[.lastServerUserID] = user.id
setAuthHeader(with: user.accessToken)
@ -347,7 +294,7 @@ final class SessionManager {
try? transaction.commitAndWait()
}
private func setAuthHeader(with accessToken: String) {
fileprivate func setAuthHeader(with accessToken: String) {
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
var deviceName = UIDevice.current.name
deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current)
@ -370,3 +317,74 @@ final class SessionManager {
JellyfinAPIAPI.customHeaders["X-Emby-Authorization"] = header
}
}
extension AnyPublisher where Output == AuthenticationResult {
func processAuthenticationRequest(
with sessionManager: SessionManager,
server: SwiftfinStore.State.Server
) -> AnyPublisher<SwiftfinStore.State.User, Error> {
self
.tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in
guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") }
let transaction = SwiftfinStore.dataStack.beginUnsafe()
let newUser = transaction.create(Into<SwiftfinStore.Models.StoredUser>())
guard let username = response.user?.name,
let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") }
newUser.username = username
newUser.id = id
newUser.appleTVID = ""
// Check for existing user on device
if let existingUser = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>(
"id == %@",
newUser.id
)]
) {
throw SwiftfinStore.Error.existingUser(existingUser.state)
}
let newAccessToken = transaction.create(Into<SwiftfinStore.Models.StoredAccessToken>())
newAccessToken.value = accessToken
newUser.accessToken = newAccessToken
guard let userServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[
Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
),
]
)
else { fatalError("No stored server associated with given state server?") }
guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") }
editUserServer.users.insert(newUser)
return (editUserServer, newUser, transaction)
}
.handleEvents(receiveOutput: { server, user, transaction in
sessionManager.setAuthHeader(with: user.accessToken?.value ?? "")
try? transaction.commitAndWait()
// Fetch for the right queue
let currentServer = SwiftfinStore.dataStack.fetchExisting(server)!
let currentUser = SwiftfinStore.dataStack.fetchExisting(user)!
Defaults[.lastServerUserID] = user.id
sessionManager.currentLogin = (server: currentServer.state, user: currentUser.state)
Notifications[.didSignIn].post()
})
.map { _, user, _ in
user.state
}
.eraseToAnyPublisher()
}
}

View File

@ -34,9 +34,9 @@ class UserListViewModel: ViewModel {
self.users = SessionManager.main.fetchUsers(for: server)
}
func login(user: SwiftfinStore.State.User) {
func signIn(user: SwiftfinStore.State.User) {
self.isLoading = true
SessionManager.main.loginUser(server: server, user: user)
SessionManager.main.signInUser(server: server, user: user)
}
func remove(user: SwiftfinStore.State.User) {

View File

@ -15,14 +15,24 @@ final class UserSignInViewModel: ViewModel {
@RouterObject
var router: UserSignInCoordinator.Router?
let server: SwiftfinStore.State.Server
@Published
var publicUsers: [UserDto] = []
@Published
var quickConnectCode: String?
@Published
var quickConnectEnabled = false
let server: SwiftfinStore.State.Server
private var quickConnectTimer: Timer?
private var quickConnectSecret: String?
init(server: SwiftfinStore.State.Server) {
self.server = server
super.init()
JellyfinAPIAPI.basePath = server.currentURI
checkQuickConnect()
}
var alertTitle: String {
@ -34,10 +44,10 @@ final class UserSignInViewModel: ViewModel {
return message
}
func login(username: String, password: String) {
func signIn(username: String, password: String) {
LogManager.log.debug("Attempting to login to server at \"\(server.currentURI)\"", tag: "login")
SessionManager.main.loginUser(server: server, username: username, password: password)
SessionManager.main.signInUser(server: server, username: username, password: password)
.trackActivity(loading)
.sink { completion in
self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
@ -79,4 +89,65 @@ final class UserSignInViewModel: ViewModel {
let urlString = ImageAPI.getSplashscreenWithRequestBuilder().URLString
return URL(string: urlString)
}
func checkQuickConnect() {
QuickConnectAPI.getEnabled()
.sink(receiveCompletion: { completion in
self.handleAPIRequestError(completion: completion)
}, receiveValue: { enabled in
self.quickConnectEnabled = enabled
if enabled {
self.startQuickConnect()
}
})
.store(in: &cancellables)
}
private func startQuickConnect() {
QuickConnectAPI.initiate()
.sink(receiveCompletion: { completion in
self.handleAPIRequestError(completion: completion)
}, receiveValue: { response in
self.quickConnectSecret = response.secret
self.quickConnectCode = response.code
LogManager.log.debug("QuickConnect code: \(response.code ?? "--")")
self.quickConnectTimer = Timer.scheduledTimer(
timeInterval: 5,
target: self,
selector: #selector(self.checkAuthStatus),
userInfo: nil,
repeats: true
)
})
.store(in: &cancellables)
}
@objc
private func checkAuthStatus() {
guard let quickConnectSecret = quickConnectSecret else { return }
QuickConnectAPI.connect(secret: quickConnectSecret)
.sink(receiveCompletion: { _ in
// Prefer not to handle error handling like normal as
// this is a repeated call
}, receiveValue: { value in
guard let authenticated = value.authenticated, authenticated else {
LogManager.log.debug("QuickConnect not authenticated yet")
return
}
self.quickConnectTimer?.invalidate()
SessionManager.main.signInUser(server: self.server, quickConnectSecret: quickConnectSecret)
.trackActivity(self.loading)
.sink { completion in
self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
} receiveValue: { _ in
}
.store(in: &self.cancellables)
})
.store(in: &cancellables)
}
}

View File

@ -21,7 +21,7 @@ struct UserListView: View {
LazyVStack {
ForEach(viewModel.users, id: \.id) { user in
Button {
viewModel.login(user: user)
viewModel.signIn(user: user)
} label: {
HStack {
Text(user.username)

View File

@ -27,7 +27,8 @@ struct UserSignInView: View {
.opacity(0.9)
.ignoresSafeArea()
Form {
HStack(alignment: .top) {
VStack(alignment: .leading) {
Section {
TextField(L10n.username, text: $username)
.disableAutocorrection(true)
@ -38,11 +39,13 @@ struct UserSignInView: View {
.autocapitalization(.none)
Button {
viewModel.login(username: username, password: password)
viewModel.signIn(username: username, password: password)
} label: {
HStack {
L10n.connect.text
Spacer()
if viewModel.isLoading {
ProgressView()
}
@ -52,8 +55,16 @@ struct UserSignInView: View {
} header: {
L10n.signInToServer(viewModel.server.name).text
.foregroundColor(.secondary)
}
Spacer()
if !viewModel.quickConnectEnabled {
L10n.quickConnectNotEnabled.text
}
}
.frame(maxWidth: .infinity)
.alert(item: $viewModel.errorMessage) { _ in
Alert(
title: Text(viewModel.alertTitle),
@ -62,6 +73,37 @@ struct UserSignInView: View {
)
}
.navigationTitle(L10n.signIn)
if viewModel.quickConnectEnabled {
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)
}
.frame(maxWidth: .infinity)
}
}
}
}
}
struct UserSignInView_Preivews: PreviewProvider {
static var previews: some View {
UserSignInView(viewModel: .init(server: .sample))
}
}

View File

@ -23,7 +23,7 @@ struct UserLoginCellView: View {
DisclosureGroup {
SecureField(L10n.password, text: $enteredPassword)
Button {
viewModel.login(username: user.name ?? "--", password: enteredPassword)
viewModel.signIn(username: user.name ?? "--", password: enteredPassword)
} label: {
L10n.signIn.text
}

View File

@ -20,7 +20,7 @@ struct UserListView: View {
LazyVStack {
ForEach(viewModel.users, id: \.id) { user in
Button {
viewModel.login(user: user)
viewModel.signIn(user: user)
} label: {
ZStack(alignment: Alignment.leading) {
Rectangle()

View File

@ -37,7 +37,7 @@ struct UserSignInView: View {
}
} else {
Button {
viewModel.login(username: username, password: password)
viewModel.signIn(username: username, password: password)
} label: {
L10n.signIn.text
}