From fbb37567460b689f01eb8a8717b9ac8673652c28 Mon Sep 17 00:00:00 2001 From: Shav Kinderlehrer Date: Tue, 12 Dec 2023 17:09:15 -0500 Subject: Implement signIn flow --- Jel.xcodeproj/project.pbxproj | 22 ++++- Jel/ContentView.swift | 3 +- Jel/Controllers/AuthStateController.swift | 2 + Jel/Controllers/JellyfinClientController.swift | 66 +++++++++++++-- Jel/JelApp.swift | 14 +++- Jel/Models/JellyfinDateFormatter.swift | 33 ++++++++ Jel/Views/AddServerView.swift | 109 ------------------------- Jel/Views/SignIn/AddServerView.swift | 108 ++++++++++++++++++++++++ Jel/Views/SignIn/SignInToServerView.swift | 79 ++++++++++++++++++ Jel/Views/SignIn/SignInView.swift | 42 ++++++++++ 10 files changed, 358 insertions(+), 120 deletions(-) create mode 100644 Jel/Models/JellyfinDateFormatter.swift delete mode 100644 Jel/Views/AddServerView.swift create mode 100644 Jel/Views/SignIn/AddServerView.swift create mode 100644 Jel/Views/SignIn/SignInToServerView.swift create mode 100644 Jel/Views/SignIn/SignInView.swift diff --git a/Jel.xcodeproj/project.pbxproj b/Jel.xcodeproj/project.pbxproj index 85470fb..cc85edc 100644 --- a/Jel.xcodeproj/project.pbxproj +++ b/Jel.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ 3D9063E72B279A320063DD2A /* JelUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9063E62B279A320063DD2A /* JelUITests.swift */; }; 3D9063E92B279A320063DD2A /* JelUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9063E82B279A320063DD2A /* JelUITestsLaunchTests.swift */; }; 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 */; }; /* End PBXBuildFile section */ @@ -69,6 +72,9 @@ 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 = ""; }; 3D9063E82B279A320063DD2A /* JelUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JelUITestsLaunchTests.swift; sourceTree = ""; }; + 3D91FDC82B28C62800919017 /* SignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInView.swift; sourceTree = ""; }; + 3D91FDCA2B28CA2500919017 /* SignInToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInToServerView.swift; sourceTree = ""; }; + 3D91FDCC2B2907E800919017 /* JellyfinDateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinDateFormatter.swift; sourceTree = ""; }; 3DC0E5802B2832B9001CCE96 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3DF1ED3D2B282836000AD8EA /* JellyfinClientController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinClientController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -102,7 +108,7 @@ 3D1015D72B27F54A00F5C29A /* Views */ = { isa = PBXGroup; children = ( - 3D1015D82B27F57400F5C29A /* AddServerView.swift */, + 3D91FDC52B28C28900919017 /* SignIn */, ); path = Views; sourceTree = ""; @@ -120,6 +126,7 @@ 3D1015E02B27FE5700F5C29A /* Models */ = { isa = PBXGroup; children = ( + 3D91FDCC2B2907E800919017 /* JellyfinDateFormatter.swift */, ); path = Models; sourceTree = ""; @@ -187,6 +194,16 @@ path = JelUITests; sourceTree = ""; }; + 3D91FDC52B28C28900919017 /* SignIn */ = { + isa = PBXGroup; + children = ( + 3D91FDC82B28C62800919017 /* SignInView.swift */, + 3D1015D82B27F57400F5C29A /* AddServerView.swift */, + 3D91FDCA2B28CA2500919017 /* SignInToServerView.swift */, + ); + path = SignIn; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -328,7 +345,10 @@ 3DF1ED3E2B282836000AD8EA /* JellyfinClientController.swift in Sources */, 3D1015D92B27F57400F5C29A /* AddServerView.swift in Sources */, 3D9063CB2B279A310063DD2A /* JelApp.swift in Sources */, + 3D91FDCD2B2907E800919017 /* JellyfinDateFormatter.swift in Sources */, 3D1015DC2B27F5D300F5C29A /* Model.xcdatamodeld in Sources */, + 3D91FDC92B28C62800919017 /* SignInView.swift in Sources */, + 3D91FDCB2B28CA2500919017 /* SignInToServerView.swift in Sources */, 3D1015E42B28000E00F5C29A /* AuthStateController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Jel/ContentView.swift b/Jel/ContentView.swift index 2c388be..356615d 100644 --- a/Jel/ContentView.swift +++ b/Jel/ContentView.swift @@ -9,11 +9,10 @@ import SwiftUI struct ContentView: View { @ObservedObject var authState: AuthStateController - var body: some View { VStack { if !authState.loggedIn { - AddServerView(authState: authState) + SignInView(authState: authState) } else { Text("Logged in") Button("Log out") { diff --git a/Jel/Controllers/AuthStateController.swift b/Jel/Controllers/AuthStateController.swift index 1629556..93dbee3 100644 --- a/Jel/Controllers/AuthStateController.swift +++ b/Jel/Controllers/AuthStateController.swift @@ -14,6 +14,8 @@ class AuthStateController: ObservableObject { private let defaults = UserDefaults.standard + static let shared = AuthStateController() + init(loggedIn: Bool = false, serverUrl: URL? = nil, authToken: String? = nil) { self.loggedIn = loggedIn self.serverUrl = serverUrl diff --git a/Jel/Controllers/JellyfinClientController.swift b/Jel/Controllers/JellyfinClientController.swift index 343efe1..b50157e 100644 --- a/Jel/Controllers/JellyfinClientController.swift +++ b/Jel/Controllers/JellyfinClientController.swift @@ -9,22 +9,74 @@ import Foundation import Get import JellyfinKit -class JellyfinClientController { - let api: APIClient +struct AuthHeaders: Codable { + var Client: String + var Device: String + var DeviceId: String + var Version: String + var Token: String +} + +enum JellyfinClientError: Error { + case badResponseCode +} + +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(serverUrl: URL) { - self.api = APIClient( - baseURL: serverUrl - ) + 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 { let request = Paths.getPublicUsers do { - try await api.send(request) + let res = try await api.send(request) + if res.statusCode != 200 { + throw JellyfinClientError.badResponseCode + } } catch { return false } 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() + } } diff --git a/Jel/JelApp.swift b/Jel/JelApp.swift index d70e444..6e4490e 100644 --- a/Jel/JelApp.swift +++ b/Jel/JelApp.swift @@ -10,14 +10,26 @@ import SwiftUI @main struct JelApp: App { 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 { WindowGroup { ContentView(authState: authStateController) .environment(\.managedObjectContext, datamodelController.container.viewContext) + .environmentObject(jellyfinClientController) .task { authStateController.load() + if authStateController.serverUrl != nil { + jellyfinClientController.setUrl(url: authStateController.serverUrl!) + } } } } diff --git a/Jel/Models/JellyfinDateFormatter.swift b/Jel/Models/JellyfinDateFormatter.swift new file mode 100644 index 0000000..74b89d1 --- /dev/null +++ b/Jel/Models/JellyfinDateFormatter.swift @@ -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)") + } +} diff --git a/Jel/Views/AddServerView.swift b/Jel/Views/AddServerView.swift deleted file mode 100644 index beab5e7..0000000 --- a/Jel/Views/AddServerView.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// AddServerView.swift -// Jel -// -// Created by zerocool on 12/11/23. -// - -import SwiftUI - -struct AddServerView: View { - @ObservedObject var authState: AuthStateController - - @State var serverUrlString: String = "" - @State var urlHasError: Bool = false - @State var currentErrorMessage: String = "" - @State var loading: Bool = false - - @FocusState var serverUrlIsFocused: Bool - - var body: some View { - VStack { - Text("Connect to a server") - .font(.title) - HStack { - - TextField(text: $serverUrlString) { - Text("http://jellyfin.example.com") - .foregroundStyle(.placeholder) - } - .keyboardType(.URL) - .textContentType(.URL) - .textFieldStyle(.roundedBorder) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($serverUrlIsFocused) - .onChange(of: serverUrlIsFocused) { - if serverUrlIsFocused { - urlHasError = false - } - } - .onSubmit { - Task { - await checkServerUrl() - } - } - - - if !loading { - Button(action: { - Task { - await checkServerUrl() - } - }) { - Label("Connect", systemImage: "arrow.right") - .labelStyle(.iconOnly) - } - .buttonStyle(.bordered) - .disabled(urlHasError) - } else { - ProgressView() - .progressViewStyle(.circular) - .padding() - } - } - .padding() - - if urlHasError { - Text(currentErrorMessage) - .font(.callout) - .foregroundStyle(.red) - } - } - } - - func checkServerUrl() async { - loading = true - serverUrlIsFocused = false - if isValidUrl(data: serverUrlString) { - let url = URL(string: serverUrlString)! - if await JellyfinClientController(serverUrl: url).isJellyfinServer() { - authState.serverUrl = url - urlHasError = false - } else { - urlHasError = true - currentErrorMessage = "Server not responding" - } - - } else { - urlHasError = true - currentErrorMessage = "Invalid url" - } - - loading = false - } - - func isValidUrl(data: String) -> Bool { - if let url = URL(string: data) { - if UIApplication.shared.canOpenURL(url) { - return true - } - } - return false - } - -} - -#Preview { - AddServerView(authState: AuthStateController()) -} diff --git a/Jel/Views/SignIn/AddServerView.swift b/Jel/Views/SignIn/AddServerView.swift new file mode 100644 index 0000000..516b982 --- /dev/null +++ b/Jel/Views/SignIn/AddServerView.swift @@ -0,0 +1,108 @@ +// +// AddServerView.swift +// Jel +// +// Created by zerocool on 12/11/23. +// + +import SwiftUI + +struct AddServerView: View { + @EnvironmentObject var jellyfinClient: JellyfinClientController + @ObservedObject var authState: AuthStateController + @Binding var serverUrlIsValid: Bool + + @State var serverUrlString: String = "http://" + @State var urlHasError: Bool = false + @State var currentErrorMessage: String = "" + @State var isLoading: Bool = false + + @FocusState var serverUrlIsFocused: Bool + + var body: some View { + VStack { + Text("Connect to a server") + .font(.title) + HStack { + TextField(text: $serverUrlString) { + Text("http://jellyfin.example.com") + } + .keyboardType(.URL) + .textContentType(.URL) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($serverUrlIsFocused) + .onSubmit { + Task { + await checkServerUrl() + } + } + + + if !isLoading { + Button(action: { + Task { + await checkServerUrl() + } + }) { + Label("Connect", systemImage: "arrow.right") + .labelStyle(.iconOnly) + } + .buttonStyle(.bordered) + } else { + ProgressView() + .progressViewStyle(.circular) + .padding([.leading, .trailing]) + } + } + .padding() + .disabled(isLoading) + + if urlHasError { + Text(currentErrorMessage) + .font(.callout) + .foregroundStyle(.red) + } + } + } + + func checkServerUrl() async { + isLoading = true + serverUrlIsFocused = false + if isValidUrl(data: serverUrlString) { + let url = URL(string: serverUrlString)! + jellyfinClient.setUrl(url: url) + if await jellyfinClient.isJellyfinServer() { + authState.serverUrl = url + authState.save() + urlHasError = false + serverUrlIsValid = true + } else { + urlHasError = true + currentErrorMessage = "Server not responding" + } + + } else { + urlHasError = true + currentErrorMessage = "Invalid url" + } + + isLoading = false + } + + func isValidUrl(data: String) -> Bool { + if let url = URL(string: data) { + if UIApplication.shared.canOpenURL(url) { + return true + } + } + return false + } + +} + +#Preview { + AddServerView(authState: AuthStateController(), serverUrlIsValid: .constant(false)) + +} diff --git a/Jel/Views/SignIn/SignInToServerView.swift b/Jel/Views/SignIn/SignInToServerView.swift new file mode 100644 index 0000000..ae8d82d --- /dev/null +++ b/Jel/Views/SignIn/SignInToServerView.swift @@ -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()) +} diff --git a/Jel/Views/SignIn/SignInView.swift b/Jel/Views/SignIn/SignInView.swift new file mode 100644 index 0000000..c06788d --- /dev/null +++ b/Jel/Views/SignIn/SignInView.swift @@ -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()) +} -- cgit v1.2.3