Implement signIn flow

This commit is contained in:
Shav Kinderlehrer 2023-12-12 17:09:15 -05:00
parent 02fc87fe25
commit fbb3756746
9 changed files with 266 additions and 28 deletions

View File

@ -19,6 +19,9 @@
3D9063E72B279A320063DD2A /* JelUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9063E62B279A320063DD2A /* JelUITests.swift */; }; 3D9063E72B279A320063DD2A /* JelUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9063E62B279A320063DD2A /* JelUITests.swift */; };
3D9063E92B279A320063DD2A /* JelUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9063E82B279A320063DD2A /* JelUITestsLaunchTests.swift */; }; 3D9063E92B279A320063DD2A /* JelUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9063E82B279A320063DD2A /* JelUITestsLaunchTests.swift */; };
3D9064592B27E4C70063DD2A /* JellyfinKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3D9064582B27E4C70063DD2A /* JellyfinKit */; }; 3D9064592B27E4C70063DD2A /* JellyfinKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3D9064582B27E4C70063DD2A /* JellyfinKit */; };
3D91FDC92B28C62800919017 /* SignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D91FDC82B28C62800919017 /* SignInView.swift */; };
3D91FDCB2B28CA2500919017 /* SignInToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D91FDCA2B28CA2500919017 /* SignInToServerView.swift */; };
3D91FDCD2B2907E800919017 /* JellyfinDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D91FDCC2B2907E800919017 /* JellyfinDateFormatter.swift */; };
3DF1ED3E2B282836000AD8EA /* JellyfinClientController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF1ED3D2B282836000AD8EA /* JellyfinClientController.swift */; }; 3DF1ED3E2B282836000AD8EA /* JellyfinClientController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF1ED3D2B282836000AD8EA /* JellyfinClientController.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -69,6 +72,9 @@
3D9063E22B279A320063DD2A /* JelUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JelUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3D9063E22B279A320063DD2A /* JelUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JelUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3D9063E62B279A320063DD2A /* JelUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JelUITests.swift; sourceTree = "<group>"; }; 3D9063E62B279A320063DD2A /* JelUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JelUITests.swift; sourceTree = "<group>"; };
3D9063E82B279A320063DD2A /* JelUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JelUITestsLaunchTests.swift; sourceTree = "<group>"; }; 3D9063E82B279A320063DD2A /* JelUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JelUITestsLaunchTests.swift; sourceTree = "<group>"; };
3D91FDC82B28C62800919017 /* SignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInView.swift; sourceTree = "<group>"; };
3D91FDCA2B28CA2500919017 /* SignInToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInToServerView.swift; sourceTree = "<group>"; };
3D91FDCC2B2907E800919017 /* JellyfinDateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinDateFormatter.swift; sourceTree = "<group>"; };
3DC0E5802B2832B9001CCE96 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 3DC0E5802B2832B9001CCE96 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
3DF1ED3D2B282836000AD8EA /* JellyfinClientController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinClientController.swift; sourceTree = "<group>"; }; 3DF1ED3D2B282836000AD8EA /* JellyfinClientController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinClientController.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -102,7 +108,7 @@
3D1015D72B27F54A00F5C29A /* Views */ = { 3D1015D72B27F54A00F5C29A /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3D1015D82B27F57400F5C29A /* AddServerView.swift */, 3D91FDC52B28C28900919017 /* SignIn */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -120,6 +126,7 @@
3D1015E02B27FE5700F5C29A /* Models */ = { 3D1015E02B27FE5700F5C29A /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3D91FDCC2B2907E800919017 /* JellyfinDateFormatter.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@ -187,6 +194,16 @@
path = JelUITests; path = JelUITests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
3D91FDC52B28C28900919017 /* SignIn */ = {
isa = PBXGroup;
children = (
3D91FDC82B28C62800919017 /* SignInView.swift */,
3D1015D82B27F57400F5C29A /* AddServerView.swift */,
3D91FDCA2B28CA2500919017 /* SignInToServerView.swift */,
);
path = SignIn;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -328,7 +345,10 @@
3DF1ED3E2B282836000AD8EA /* JellyfinClientController.swift in Sources */, 3DF1ED3E2B282836000AD8EA /* JellyfinClientController.swift in Sources */,
3D1015D92B27F57400F5C29A /* AddServerView.swift in Sources */, 3D1015D92B27F57400F5C29A /* AddServerView.swift in Sources */,
3D9063CB2B279A310063DD2A /* JelApp.swift in Sources */, 3D9063CB2B279A310063DD2A /* JelApp.swift in Sources */,
3D91FDCD2B2907E800919017 /* JellyfinDateFormatter.swift in Sources */,
3D1015DC2B27F5D300F5C29A /* Model.xcdatamodeld in Sources */, 3D1015DC2B27F5D300F5C29A /* Model.xcdatamodeld in Sources */,
3D91FDC92B28C62800919017 /* SignInView.swift in Sources */,
3D91FDCB2B28CA2500919017 /* SignInToServerView.swift in Sources */,
3D1015E42B28000E00F5C29A /* AuthStateController.swift in Sources */, 3D1015E42B28000E00F5C29A /* AuthStateController.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@ -9,11 +9,10 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@ObservedObject var authState: AuthStateController @ObservedObject var authState: AuthStateController
var body: some View { var body: some View {
VStack { VStack {
if !authState.loggedIn { if !authState.loggedIn {
AddServerView(authState: authState) SignInView(authState: authState)
} else { } else {
Text("Logged in") Text("Logged in")
Button("Log out") { Button("Log out") {

View File

@ -14,6 +14,8 @@ class AuthStateController: ObservableObject {
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
static let shared = AuthStateController()
init(loggedIn: Bool = false, serverUrl: URL? = nil, authToken: String? = nil) { init(loggedIn: Bool = false, serverUrl: URL? = nil, authToken: String? = nil) {
self.loggedIn = loggedIn self.loggedIn = loggedIn
self.serverUrl = serverUrl self.serverUrl = serverUrl

View File

@ -9,22 +9,74 @@ import Foundation
import Get import Get
import JellyfinKit import JellyfinKit
class JellyfinClientController { struct AuthHeaders: Codable {
let api: APIClient var Client: String
var Device: String
var DeviceId: String
var Version: String
var Token: String
}
init(serverUrl: URL) { enum JellyfinClientError: Error {
self.api = APIClient( case badResponseCode
baseURL: serverUrl }
)
extension AuthHeaders {
func format() -> String {
return "MediaBrowser Client=\(self.Client), Device=\(self.Device), DeviceId=\(self.DeviceId), Version=\(self.Version), Token=\(self.Token)"
}
}
class JellyfinClientController: ObservableObject {
private var api: APIClient
private var authHeaders: AuthHeaders
private var authState: AuthStateController
init(authHeaders: AuthHeaders, serverUrl: URL? = nil, authState: AuthStateController = AuthStateController.shared) {
self.authHeaders = authHeaders
self.authState = authState
self.api = APIClient(baseURL: serverUrl)
self.setUrl(url: serverUrl)
}
func setToken(token: String) {
self.authHeaders.Token = token
}
func setUrl(url: URL?) {
if url == nil {
return
}
self.api = APIClient(baseURL: url, {
$0.sessionConfiguration.httpAdditionalHeaders = ["Authorization": self.authHeaders.format()]
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withFractionalSeconds
$0.decoder = decoder
})
} }
func isJellyfinServer() async -> Bool { func isJellyfinServer() async -> Bool {
let request = Paths.getPublicUsers let request = Paths.getPublicUsers
do { do {
try await api.send(request) let res = try await api.send(request)
if res.statusCode != 200 {
throw JellyfinClientError.badResponseCode
}
} catch { } catch {
return false return false
} }
return true return true
} }
func signIn(username: String, pw: String) async throws {
let request = Paths.authenticateUserByName(AuthenticateUserByName(pw: pw, username: username))
let res = try await self.api.send(request)
self.authState.loggedIn = true
self.authState.authToken = res.value.accessToken
self.authState.save()
}
} }

View File

@ -10,14 +10,26 @@ import SwiftUI
@main @main
struct JelApp: App { struct JelApp: App {
let datamodelController = DatamodelController.shared let datamodelController = DatamodelController.shared
let authStateController = AuthStateController() let authStateController = AuthStateController.shared
let jellyfinClientController = JellyfinClientController(authHeaders: AuthHeaders(
Client: "Jel",
Device: UIDevice.current.systemName,
DeviceId: UIDevice.current.identifierForVendor!.uuidString,
Version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0",
Token: ""))
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView(authState: authStateController) ContentView(authState: authStateController)
.environment(\.managedObjectContext, .environment(\.managedObjectContext,
datamodelController.container.viewContext) datamodelController.container.viewContext)
.environmentObject(jellyfinClientController)
.task { .task {
authStateController.load() authStateController.load()
if authStateController.serverUrl != nil {
jellyfinClientController.setUrl(url: authStateController.serverUrl!)
}
} }
} }
} }

View File

@ -0,0 +1,33 @@
//
// JellyfinDateFormatter.swift
// Jel
//
// Created by zerocool on 12/12/23.
//
import Foundation
// from: https://stackoverflow.com/a/46458771
extension Formatter {
static let iso8601withFractionalSeconds: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
static let iso8601: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
}
extension JSONDecoder.DateDecodingStrategy {
static let iso8601withFractionalSeconds = custom {
let container = try $0.singleValueContainer()
let string = try container.decode(String.self)
if let date = Formatter.iso8601withFractionalSeconds.date(from: string) ?? Formatter.iso8601.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)")
}
}

View File

@ -8,12 +8,14 @@
import SwiftUI import SwiftUI
struct AddServerView: View { struct AddServerView: View {
@EnvironmentObject var jellyfinClient: JellyfinClientController
@ObservedObject var authState: AuthStateController @ObservedObject var authState: AuthStateController
@Binding var serverUrlIsValid: Bool
@State var serverUrlString: String = "" @State var serverUrlString: String = "http://"
@State var urlHasError: Bool = false @State var urlHasError: Bool = false
@State var currentErrorMessage: String = "" @State var currentErrorMessage: String = ""
@State var loading: Bool = false @State var isLoading: Bool = false
@FocusState var serverUrlIsFocused: Bool @FocusState var serverUrlIsFocused: Bool
@ -22,10 +24,8 @@ struct AddServerView: View {
Text("Connect to a server") Text("Connect to a server")
.font(.title) .font(.title)
HStack { HStack {
TextField(text: $serverUrlString) { TextField(text: $serverUrlString) {
Text("http://jellyfin.example.com") Text("http://jellyfin.example.com")
.foregroundStyle(.placeholder)
} }
.keyboardType(.URL) .keyboardType(.URL)
.textContentType(.URL) .textContentType(.URL)
@ -33,11 +33,6 @@ struct AddServerView: View {
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused($serverUrlIsFocused) .focused($serverUrlIsFocused)
.onChange(of: serverUrlIsFocused) {
if serverUrlIsFocused {
urlHasError = false
}
}
.onSubmit { .onSubmit {
Task { Task {
await checkServerUrl() await checkServerUrl()
@ -45,7 +40,7 @@ struct AddServerView: View {
} }
if !loading { if !isLoading {
Button(action: { Button(action: {
Task { Task {
await checkServerUrl() await checkServerUrl()
@ -55,14 +50,14 @@ struct AddServerView: View {
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(urlHasError)
} else { } else {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
.padding() .padding([.leading, .trailing])
} }
} }
.padding() .padding()
.disabled(isLoading)
if urlHasError { if urlHasError {
Text(currentErrorMessage) Text(currentErrorMessage)
@ -73,13 +68,16 @@ struct AddServerView: View {
} }
func checkServerUrl() async { func checkServerUrl() async {
loading = true isLoading = true
serverUrlIsFocused = false serverUrlIsFocused = false
if isValidUrl(data: serverUrlString) { if isValidUrl(data: serverUrlString) {
let url = URL(string: serverUrlString)! let url = URL(string: serverUrlString)!
if await JellyfinClientController(serverUrl: url).isJellyfinServer() { jellyfinClient.setUrl(url: url)
if await jellyfinClient.isJellyfinServer() {
authState.serverUrl = url authState.serverUrl = url
authState.save()
urlHasError = false urlHasError = false
serverUrlIsValid = true
} else { } else {
urlHasError = true urlHasError = true
currentErrorMessage = "Server not responding" currentErrorMessage = "Server not responding"
@ -90,7 +88,7 @@ struct AddServerView: View {
currentErrorMessage = "Invalid url" currentErrorMessage = "Invalid url"
} }
loading = false isLoading = false
} }
func isValidUrl(data: String) -> Bool { func isValidUrl(data: String) -> Bool {
@ -105,5 +103,6 @@ struct AddServerView: View {
} }
#Preview { #Preview {
AddServerView(authState: AuthStateController()) AddServerView(authState: AuthStateController(), serverUrlIsValid: .constant(false))
} }

View File

@ -0,0 +1,79 @@
//
// SignInToServerView.swift
// Jel
//
// Created by zerocool on 12/12/23.
//
import SwiftUI
struct SignInToServerView: View {
@EnvironmentObject var jellyfinClient: JellyfinClientController
@ObservedObject var authState: AuthStateController
@State var username: String = ""
@State var password: String = ""
@State var isLoading: Bool = false
@State var hasError: Bool = false
var body: some View {
VStack {
Text("Sign in")
.font(.title)
TextField(text: $username) {
Text("Username")
}
.textContentType(.username)
SecureField(text: $password) {
Text("Password")
}
.textContentType(.password)
.onSubmit {
Task {
await logInToServer()
}
}
if !isLoading {
Button {
Task {
await logInToServer()
}
} label: {
Text("Sign in")
}
.disabled(username.isEmpty || password.isEmpty)
} else {
ProgressView()
.progressViewStyle(.circular)
}
if hasError {
Text("Unable to sign in")
.font(.callout)
.foregroundStyle(.red)
}
}
.padding()
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.disabled(isLoading)
}
func logInToServer() async {
isLoading = true
hasError = false
do {
try await jellyfinClient.signIn(username: username, pw: password)
} catch {
hasError = true
}
isLoading = false
}
}
#Preview {
SignInToServerView(authState: AuthStateController())
}

View File

@ -0,0 +1,42 @@
//
// SignInView.swift
// Jel
//
// Created by zerocool on 12/12/23.
//
import SwiftUI
struct SignInView: View {
@EnvironmentObject var jellyfinClient: JellyfinClientController
@ObservedObject var authState: AuthStateController
@State var serverUrlIsValid: Bool = false
var body: some View {
NavigationStack {
AddServerView(authState: authState, serverUrlIsValid: $serverUrlIsValid)
.navigationDestination(isPresented: $serverUrlIsValid) {
SignInToServerView(authState: authState)
}
}
.onAppear {
Task {
await checkLoadedServerUrl()
}
}
}
func checkLoadedServerUrl() async {
if authState.serverUrl == nil {
return
}
if await jellyfinClient.isJellyfinServer() {
serverUrlIsValid = true
}
}
}
#Preview {
SignInView(authState: AuthStateController())
}