diff --git a/Package.swift b/Package.swift index 47b140b4..2a4dce52 100644 --- a/Package.swift +++ b/Package.swift @@ -65,7 +65,8 @@ let package = Package( name: "TidalAPI", dependencies: [ .AnyCodable, - .auth + .auth, + .common, ], plugins: [ .plugin(name: "SwiftLint", package: "SwiftLintPlugin"), diff --git a/TestApps/Auth/AuthTestApp.xcodeproj/project.pbxproj b/TestApps/Auth/AuthTestApp.xcodeproj/project.pbxproj index ada5fcd3..f39fc340 100644 --- a/TestApps/Auth/AuthTestApp.xcodeproj/project.pbxproj +++ b/TestApps/Auth/AuthTestApp.xcodeproj/project.pbxproj @@ -7,18 +7,20 @@ objects = { /* Begin PBXBuildFile section */ + C625802E2CEF3AE700D2B293 /* PresentationContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C625802D2CEF3AE700D2B293 /* PresentationContextProvider.swift */; }; E16DACE62B97196A006C66C9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E16DACE52B97196A006C66C9 /* Assets.xcassets */; }; E16DACE92B97196A006C66C9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E16DACE82B97196A006C66C9 /* Preview Assets.xcassets */; }; E16DACF42B971AD9006C66C9 /* AuthTestAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DACF02B971AD9006C66C9 /* AuthTestAppApp.swift */; }; E16DACF52B971AD9006C66C9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DACF12B971AD9006C66C9 /* HomeView.swift */; }; E16DACF62B971AD9006C66C9 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DACF22B971AD9006C66C9 /* AuthViewModel.swift */; }; E16DACFE2B971B7F006C66C9 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DACFD2B971B7F006C66C9 /* AuthView.swift */; }; - E16DAD012B971BE1006C66C9 /* SafariWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16DAD002B971BE1006C66C9 /* SafariWebView.swift */; }; E1D552F32B97303F00C26F97 /* Auth in Frameworks */ = {isa = PBXBuildFile; productRef = E1D552F22B97303F00C26F97 /* Auth */; }; E1D552F92B9730F300C26F97 /* tidal-sdk-ios in Resources */ = {isa = PBXBuildFile; fileRef = E1D552F82B9730ED00C26F97 /* tidal-sdk-ios */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + C625802C2CEE301100D2B293 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + C625802D2CEF3AE700D2B293 /* PresentationContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationContextProvider.swift; sourceTree = ""; }; C6D0C3312BF348CA005442B2 /* AuthTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AuthTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; E16DACE52B97196A006C66C9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E16DACE82B97196A006C66C9 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -26,7 +28,6 @@ E16DACF12B971AD9006C66C9 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HomeView.swift; path = AuthTestApp/HomeView.swift; sourceTree = SOURCE_ROOT; }; E16DACF22B971AD9006C66C9 /* AuthViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AuthViewModel.swift; path = AuthTestApp/AuthViewModel.swift; sourceTree = SOURCE_ROOT; }; E16DACFD2B971B7F006C66C9 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; - E16DAD002B971BE1006C66C9 /* SafariWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebView.swift; sourceTree = ""; }; E1D552F82B9730ED00C26F97 /* tidal-sdk-ios */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "tidal-sdk-ios"; path = ../..; sourceTree = ""; }; /* End PBXFileReference section */ @@ -42,23 +43,32 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + C657E4142CE2251B00D16710 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; E16DACD52B971969006C66C9 = { isa = PBXGroup; children = ( E1D552F82B9730ED00C26F97 /* tidal-sdk-ios */, E16DACE02B971969006C66C9 /* AuthTestApp */, C6D0C3312BF348CA005442B2 /* AuthTestApp.app */, + C657E4142CE2251B00D16710 /* Frameworks */, ); sourceTree = ""; }; E16DACE02B971969006C66C9 /* AuthTestApp */ = { isa = PBXGroup; children = ( - E16DACFF2B971BD3006C66C9 /* Utils */, - E16DACFD2B971B7F006C66C9 /* AuthView.swift */, + C625802C2CEE301100D2B293 /* Info.plist */, E16DACF02B971AD9006C66C9 /* AuthTestAppApp.swift */, - E16DACF22B971AD9006C66C9 /* AuthViewModel.swift */, E16DACF12B971AD9006C66C9 /* HomeView.swift */, + E16DACFD2B971B7F006C66C9 /* AuthView.swift */, + E16DACF22B971AD9006C66C9 /* AuthViewModel.swift */, + C625802D2CEF3AE700D2B293 /* PresentationContextProvider.swift */, E16DACE52B97196A006C66C9 /* Assets.xcassets */, E16DACE72B97196A006C66C9 /* Preview Content */, ); @@ -73,14 +83,6 @@ path = "Preview Content"; sourceTree = ""; }; - E16DACFF2B971BD3006C66C9 /* Utils */ = { - isa = PBXGroup; - children = ( - E16DAD002B971BE1006C66C9 /* SafariWebView.swift */, - ); - path = Utils; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -157,8 +159,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C625802E2CEF3AE700D2B293 /* PresentationContextProvider.swift in Sources */, E16DACF62B971AD9006C66C9 /* AuthViewModel.swift in Sources */, - E16DAD012B971BE1006C66C9 /* SafariWebView.swift in Sources */, E16DACF52B971AD9006C66C9 /* HomeView.swift in Sources */, E16DACFE2B971B7F006C66C9 /* AuthView.swift in Sources */, E16DACF42B971AD9006C66C9 /* AuthTestAppApp.swift in Sources */, @@ -298,11 +300,13 @@ DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AuthTestApp/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -328,11 +332,13 @@ DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AuthTestApp/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/TestApps/Auth/AuthTestApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TestApps/Auth/AuthTestApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2a14fe37..9d8c56fc 100644 --- a/TestApps/Auth/AuthTestApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TestApps/Auth/AuthTestApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "anycodable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Flight-School/AnyCodable", + "state" : { + "revision" : "862808b2070cd908cb04f9aafe7de83d35f81b05", + "version" : "0.6.7" + } + }, { "identity" : "grdb.swift", "kind" : "remoteSourceControl", @@ -27,6 +36,15 @@ "version" : "4.2.2" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", + "version" : "1.6.1" + } + }, { "identity" : "swiftlintplugin", "kind" : "remoteSourceControl", diff --git a/TestApps/Auth/AuthTestApp/AuthTestAppApp.swift b/TestApps/Auth/AuthTestApp/AuthTestAppApp.swift index 1da4c801..04075ab3 100644 --- a/TestApps/Auth/AuthTestApp/AuthTestAppApp.swift +++ b/TestApps/Auth/AuthTestApp/AuthTestAppApp.swift @@ -4,7 +4,7 @@ import SwiftUI struct AuthTestAppApp: App { var body: some Scene { WindowGroup { - NavigationStack { + NavigationView { HomeView() } .environment(\.colorScheme, .dark) diff --git a/TestApps/Auth/AuthTestApp/AuthView.swift b/TestApps/Auth/AuthTestApp/AuthView.swift index 86fae17b..82dce1cc 100644 --- a/TestApps/Auth/AuthTestApp/AuthView.swift +++ b/TestApps/Auth/AuthTestApp/AuthView.swift @@ -2,7 +2,6 @@ import Auth import SwiftUI struct AuthView: View { - @State private var presentLoginScreen = false @StateObject var viewModel: AuthViewModel = AuthViewModel() let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -60,17 +59,7 @@ struct AuthView: View { if !viewModel.isLoggedIn, !viewModel.isDeviceLoginEnabled { Button("Login") { - viewModel.errorMessage = "" viewModel.initializeLogin() - presentLoginScreen = true - } - .sheet(isPresented: $presentLoginScreen) { - if let url = viewModel.loginUrl { - SafariWebView(url: url, redicrectUrl: AuthViewModel.redirectUri) { url in - viewModel.finalizeLogin(url) - presentLoginScreen = false - } - } } } diff --git a/TestApps/Auth/AuthTestApp/AuthViewModel.swift b/TestApps/Auth/AuthTestApp/AuthViewModel.swift index ad1993b1..a49fb40b 100644 --- a/TestApps/Auth/AuthTestApp/AuthViewModel.swift +++ b/TestApps/Auth/AuthTestApp/AuthViewModel.swift @@ -1,12 +1,18 @@ import Auth +import AuthenticationServices import Foundation +// MARK: - AuthViewModel + @MainActor -class AuthViewModel: ObservableObject { +class AuthViewModel: NSObject, ObservableObject { private let CLIENT_UNIQUE_KEY = "ClientUniqueKey" - private let CLIENT_ID_DEVICE_LOGIN = "ClientID2" + private let CLIENT_ID_DEVICE_LOGIN = "ClientIDDeviceLogin" private let CLIENT_ID = "ClientID" - static let redirectUri = "https://tidal.com/ios/login/auth" + private let CLIENT_SECRET = "ClientSecret" + private let customScheme = "testauthsdk" + private let redirectUri = "testauthsdk://auth-test" + private let scopes: Set = [] @Published var isDeviceLoginEnabled: Bool = false { didSet { @@ -18,30 +24,39 @@ class AuthViewModel: ObservableObject { @Published var isLoggedIn: Bool = false @Published var errorMessage: String = "" @Published var expiresIn: String = "" - private(set) var loginUrl: URL? - private var expiresAt: Date = Date() - private var authConfig: AuthConfig { - AuthConfig( - clientId: isDeviceLoginEnabled ? CLIENT_ID_DEVICE_LOGIN : CLIENT_ID, - clientUniqueKey: CLIENT_UNIQUE_KEY, - credentialsKey: "storage" - ) - } + private var webAuthSession: ASWebAuthenticationSession? + private var contextProvider: ASWebAuthenticationPresentationContextProviding? + private var loginUrl: URL? private var loginConfig: LoginConfig { LoginConfig(customParams: [QueryParameter(key: "appMode", value: "iOS")]) } - var isDeviceLoginCodeExpired: Bool { + private var expiresAt: Date = Date() + private var isDeviceLoginCodeExpired: Bool { Date() > expiresAt } - var auth: TidalAuth { + private var auth: TidalAuth { .shared } - init() { + private var authConfig: AuthConfig { + AuthConfig( + clientId: isDeviceLoginEnabled ? CLIENT_ID_DEVICE_LOGIN : CLIENT_ID, + clientUniqueKey: CLIENT_UNIQUE_KEY, + credentialsKey: "auth-storage", + scopes: scopes + ) + } + + override init() { + super.init() + initAuth() + } + + func initAuth() { auth.config(config: authConfig) isLoggedIn = auth.isUserLoggedIn } @@ -53,26 +68,31 @@ class AuthViewModel: ObservableObject { errorMessage = "" isLoggedIn = auth.isUserLoggedIn } catch { - errorMessage = error.localizedDescription + errorMessage = "Logout error: " + error.localizedDescription } } func initializeLogin() { - if let url = auth.initializeLogin(redirectUri: Self.redirectUri, loginConfig: loginConfig) { - loginUrl = url - } else { + guard let url = auth.initializeLogin(redirectUri: redirectUri, loginConfig: loginConfig) else { loginUrl = nil errorMessage = "Error: login URL can't be populated" + return } + startAuthenticationFlow(with: url) } func finalizeLogin(_ url: String) { Task { + defer { + contextProvider = nil + webAuthSession = nil + } + do { try await auth.finalizeLogin(loginResponseUri: url) isLoggedIn = auth.isUserLoggedIn } catch { - errorMessage = error.localizedDescription + errorMessage = "Login error: " + error.localizedDescription } } } @@ -89,6 +109,8 @@ class AuthViewModel: ObservableObject { } catch let error as TidalError { if let message = error.message { errorMessage = message + } else { + errorMessage = error.localizedDescription } } catch { errorMessage = error.localizedDescription @@ -102,7 +124,52 @@ class AuthViewModel: ObservableObject { return } - let duration = Duration.seconds(expiresAt.timeIntervalSinceNow) - expiresIn = duration.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2))) + if #available(iOS 16, *) { + let duration = Duration.seconds(expiresAt.timeIntervalSinceNow) + expiresIn = duration.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2))) + } else { + let timeInterval = expiresAt.timeIntervalSinceNow + let minutes = Int(timeInterval) / 60 + let seconds = Int(timeInterval) % 60 + expiresIn = String(format: "%02d:%02d", minutes, seconds) + } + } +} + +private extension AuthViewModel { + private func startAuthenticationFlow(with loginURL: URL) { + errorMessage = "" + loginUrl = loginURL + + let completionHandler: ASWebAuthenticationSession.CompletionHandler = { [weak self] callbackURL, error in + guard let self else { + return + } + if let error { + errorMessage = "Authentication failed: " + error.localizedDescription + } else if let callbackURL { + finalizeLogin(callbackURL.absoluteString) + } + } + + if #available(iOS 17.4, *) { + webAuthSession = ASWebAuthenticationSession( + url: loginURL, + callback: .customScheme(customScheme), + completionHandler: completionHandler + ) + } else { + webAuthSession = ASWebAuthenticationSession( + url: loginURL, + callbackURLScheme: customScheme, + completionHandler: completionHandler + ) + } + + // Provide the presentation context for iPad compatibility + contextProvider = PresentationContextProvider() + webAuthSession?.presentationContextProvider = contextProvider + webAuthSession?.prefersEphemeralWebBrowserSession = false + webAuthSession?.start() } } diff --git a/TestApps/Auth/AuthTestApp/Info.plist b/TestApps/Auth/AuthTestApp/Info.plist new file mode 100644 index 00000000..0d75e3bd --- /dev/null +++ b/TestApps/Auth/AuthTestApp/Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.tidal.AuthTestApp + CFBundleURLSchemes + + testauthsdk + + + + + diff --git a/TestApps/Auth/AuthTestApp/PresentationContextProvider.swift b/TestApps/Auth/AuthTestApp/PresentationContextProvider.swift new file mode 100644 index 00000000..c2854d8b --- /dev/null +++ b/TestApps/Auth/AuthTestApp/PresentationContextProvider.swift @@ -0,0 +1,8 @@ +import AuthenticationServices +import Foundation + +class PresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + ASPresentationAnchor() + } +} diff --git a/TestApps/Auth/AuthTestApp/Utils/SafariWebView.swift b/TestApps/Auth/AuthTestApp/Utils/SafariWebView.swift deleted file mode 100644 index 719abc75..00000000 --- a/TestApps/Auth/AuthTestApp/Utils/SafariWebView.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SafariServices -import SwiftUI - -// MARK: - SafariWebView - -struct SafariWebView: UIViewControllerRepresentable { - let url: URL - let redicrectUrl: String - let onSuccess: (String) -> Void - let delegate: SafariDelegate - - init(url: URL, redicrectUrl: String, onSuccess: @escaping (String) -> Void) { - self.url = url - self.redicrectUrl = redicrectUrl - self.onSuccess = onSuccess - delegate = SafariDelegate(redicrectUrl: redicrectUrl, onSuccess: onSuccess) - } - - func makeUIViewController(context: Context) -> SFSafariViewController { - let viewController = SFSafariViewController(url: url) - viewController.delegate = delegate - viewController.preferredControlTintColor = .white - viewController.preferredBarTintColor = .black - return viewController - } - - func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {} -} - -// MARK: - SafariDelegate - -final class SafariDelegate: NSObject, SFSafariViewControllerDelegate { - let redicrectUrl: String - let onSuccess: (String) -> Void - - init(redicrectUrl: String, onSuccess: @escaping (String) -> Void) { - self.redicrectUrl = redicrectUrl - self.onSuccess = onSuccess - } - - func safariViewController(_ controller: SFSafariViewController, initialLoadDidRedirectTo URL: URL) { - if URL.absoluteString.starts(with: redicrectUrl) { - onSuccess(URL.absoluteString) - } - } -}