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