diff --git a/Spwifiy/Info.plist b/Spwifiy/Info.plist index b7c55d6..684d66a 100644 --- a/Spwifiy/Info.plist +++ b/Spwifiy/Info.plist @@ -17,8 +17,6 @@ - SpotifyClientId - 72ef80b9d2aa4dd580f8a68f0c92334c UIAppFonts Satoshi.ttf diff --git a/Spwifiy/Localizations/Localizable.xcstrings b/Spwifiy/Localizations/Localizable.xcstrings index faf5a0d..0543b67 100644 --- a/Spwifiy/Localizations/Localizable.xcstrings +++ b/Spwifiy/Localizations/Localizable.xcstrings @@ -32,14 +32,11 @@ "Artists" : { }, - "Attempting to authorize..." : { + "attempting authorization" : { }, "By" : { - }, - "Click the URL below if an authorization window does not appear" : { - }, "Discover" : { "extractionState" : "manual" @@ -70,6 +67,9 @@ }, "Liked songs" : { + }, + "Login with Spotify" : { + }, "My Library" : { "extractionState" : "manual" diff --git a/Spwifiy/Models/SpotifyAuthCookie.swift b/Spwifiy/Models/SpotifyAuthCookie.swift new file mode 100644 index 0000000..940a265 --- /dev/null +++ b/Spwifiy/Models/SpotifyAuthCookie.swift @@ -0,0 +1,35 @@ +// +// SpotifyAuthCookie.swift +// Spwifiy +// +// Created by Peter Duanmu on 12/4/24. +// + +import Foundation + +struct SpotifyAuthCookie: Codable { + let name: String + let value: String + let expiresDate: Date + let domain: String + + var httpCookie: HTTPCookie? { + let properties: [HTTPCookiePropertyKey: Any] = [ + .domain: domain, + .path: "/", + .name: name, + .value: value, + .secure: true, + .expires: expiresDate + ] + + return HTTPCookie(properties: properties) + } + + init(cookie: HTTPCookie) { + self.name = cookie.name + self.value = cookie.value + self.expiresDate = cookie.expiresDate ?? Date.now + self.domain = cookie.domain + } +} diff --git a/Spwifiy/Models/SpotifyAuthResponse.swift b/Spwifiy/Models/SpotifyAuthResponse.swift new file mode 100644 index 0000000..74ecf98 --- /dev/null +++ b/Spwifiy/Models/SpotifyAuthResponse.swift @@ -0,0 +1,13 @@ +// +// SpotifyAuthResponse.swift +// Spwifiy +// +// Created by Peter Duanmu on 12/4/24. +// + +struct SpotifyAuthResponse: Decodable { + let clientId: String + let accessToken: String + let accessTokenExpirationTimestampMs: Double + let isAnonymous: Bool +} diff --git a/Spwifiy/SpwifiyApp.swift b/Spwifiy/SpwifiyApp.swift index 77c421a..043a598 100644 --- a/Spwifiy/SpwifiyApp.swift +++ b/Spwifiy/SpwifiyApp.swift @@ -21,51 +21,31 @@ struct SpwifiyApp: App { @StateObject var spotifyCache: SpotifyCache = SpotifyCache() - @State var showAuthLoading: Bool = false - @State var showErrorMessage: Bool = false - - @State var errorMessage: String = "" { - didSet { - showErrorMessage = !errorMessage.isEmpty - } - } - var body: some Scene { WindowGroup { Group { - if spotifyViewModel.isAuthorized { + switch mainViewModel.authStatus { + case .success: MainView(spotifyViewModel: spotifyViewModel, spotifyDataViewModel: spotifyDataViewModel, mainViewModel: mainViewModel, spotifyCache: spotifyCache) .onAppear { - showAuthLoading = false + mainViewModel.currentView = .home } - } else { - LoginView(spotifyViewModel: spotifyViewModel) - } - } - .handlesExternalEvents(preferring: ["{path of URL?}"], allowing: ["*"]) - .onOpenURL { url in - Task { @MainActor in - if url.absoluteString.contains(SpotifyViewModel.loginCallback) { - do { - showAuthLoading = true - - try await spotifyViewModel.spotifyRequestAccess(redirectURL: url) - } catch { - errorMessage = error.localizedDescription + case .inProcess, .failed: + LoginView(authStatus: $mainViewModel.authStatus) + case .cookieSet: + Text("attempting authorization") + .font(.satoshiBlack(24)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task(priority: .utility) { + await spotifyViewModel.attemptSpotifyAuthToken() } - - showAuthLoading = false - } } } - .toast(isPresenting: $showAuthLoading) { - AlertToast(displayMode: .alert, type: .loading) - } - .toast(isPresenting: $showErrorMessage) { - AlertToast(displayMode: .alert, type: .error(.red), title: errorMessage) + .onChange(of: spotifyViewModel.isAuthorized) { newValue in + mainViewModel.authStatus = newValue == .valid ? .success : .failed } .frame(minWidth: 950, minHeight: 550) .background(.bgMain) diff --git a/Spwifiy/Utils/API/APIRequest.swift b/Spwifiy/Utils/API/APIRequest.swift index a8590d1..239b4c1 100644 --- a/Spwifiy/Utils/API/APIRequest.swift +++ b/Spwifiy/Utils/API/APIRequest.swift @@ -31,8 +31,16 @@ class APIRequest { self.noCacheSession.configuration.urlCache = nil } - public func request(url: URL, noCache: Bool = false, success: @escaping (Data?) -> Void) { - (noCache ? noCacheSession : session).dataTask(with: URLRequest(url: url)) { data, _, error in + public func setCookie(cookie: HTTPCookie, noCache: Bool = false) { + (noCache ? noCacheSession : session).configuration.httpCookieStorage?.setCookie(cookie) + } + + public func removeCookie(cookie: HTTPCookie, noCache: Bool = false) { + (noCache ? noCacheSession : session).configuration.httpCookieStorage?.deleteCookie(cookie) + } + + public func request(request: URLRequest, noCache: Bool = false, success: @escaping (Data?) -> Void) { + (noCache ? noCacheSession : session).dataTask(with: request) { data, _, error in if error == nil, let data = data { success(data) } else { @@ -42,6 +50,10 @@ class APIRequest { .resume() } + public func request(url: URL, noCache: Bool = false, success: @escaping (Data?) -> Void) { + request(request: URLRequest(url: url), noCache: noCache, success: success) + } + public func request(urlString: String, noCache: Bool = false, success: @escaping (Data?) -> Void) { guard let url = URL(string: urlString) else { return success(nil) diff --git a/Spwifiy/Utils/API/SpotifyCustomLogin.swift b/Spwifiy/Utils/API/SpotifyCustomLogin.swift new file mode 100644 index 0000000..8f5d2c7 --- /dev/null +++ b/Spwifiy/Utils/API/SpotifyCustomLogin.swift @@ -0,0 +1,101 @@ +// +// SpotifyCustomLogin.swift +// Spwifiy +// +// Created by Peter Duanmu on 12/4/24. +// + +import SwiftUI +import WebKit +import KeychainAccess + +class SpotifyAuthManager: NSObject, WKHTTPCookieStoreObserver { + + public enum AuthStatus { + case success, failed, inProcess, cookieSet + } + + public static let spDcCookieKey = "sp_dc_cookie" + public static let spTCookieKey = "sp_t_cookie" + + @Binding var authStatus: AuthStatus + + let keychain: Keychain + let webStore: WKWebsiteDataStore + + init(webStore: WKWebsiteDataStore, authStatus: Binding) { + self._authStatus = authStatus + self.keychain = Keychain(service: SpwifiyApp.service) + self.webStore = webStore + + super.init() + + self.webStore.httpCookieStore.add(self) + } + + func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { + cookieStore.getAllCookies { cookies in + if let spDcCookie = cookies.filter({ $0.name == "sp_dc" }).first, + let spTCookie = cookies.filter({ $0.name == "sp_t" }).first { + let encoder = JSONEncoder() + + do { + self.keychain[ + data: SpotifyAuthManager.spDcCookieKey + ] = try encoder.encode(SpotifyAuthCookie(cookie: spDcCookie)) + + self.keychain[ + data: SpotifyAuthManager.spTCookieKey + ] = try encoder.encode(SpotifyAuthCookie(cookie: spTCookie)) + + self.authStatus = .cookieSet + } catch { + print("unable to store cookie data in keychain: \(error)") + + self.authStatus = .failed + } + + cookies.forEach { + self.webStore.httpCookieStore.delete($0) + } + + self.webStore.httpCookieStore.remove(self) + } + } + } + +} + +struct SpotifyWebView: NSViewRepresentable { + + let webStore: WKWebsiteDataStore + + let spotifyAuthManager: SpotifyAuthManager + var webView: WKWebView + + init(authStatus: Binding) { + self.webStore = .default() + + let config = WKWebViewConfiguration() + config.websiteDataStore = webStore + config.limitsNavigationsToAppBoundDomains = false + + self.webView = WKWebView(frame: .zero, configuration: config) + + self.spotifyAuthManager = SpotifyAuthManager( + webStore: self.webStore, + authStatus: authStatus + ) + + self.webView.load(URLRequest(url: URL(string: "https://accounts.spotify.com/login")!)) + } + + func makeNSView(context: Context) -> some NSView { + return webView + } + + func updateNSView(_ nsView: NSViewType, context: Context) { + + } + +} diff --git a/Spwifiy/ViewModels/MainViewModel.swift b/Spwifiy/ViewModels/MainViewModel.swift index 1934db6..73678e2 100644 --- a/Spwifiy/ViewModels/MainViewModel.swift +++ b/Spwifiy/ViewModels/MainViewModel.swift @@ -10,6 +10,8 @@ import SpotifyWebAPI class MainViewModel: ObservableObject { + @Published var authStatus: SpotifyAuthManager.AuthStatus = .cookieSet + @Published var currentView: MainViewOptions = .home { willSet { withAnimation(.defaultAnimation) { diff --git a/Spwifiy/ViewModels/SpotifyViewModel/SpotifyViewModel.swift b/Spwifiy/ViewModels/SpotifyViewModel/SpotifyViewModel.swift index c1ab363..ef52d47 100644 --- a/Spwifiy/ViewModels/SpotifyViewModel/SpotifyViewModel.swift +++ b/Spwifiy/ViewModels/SpotifyViewModel/SpotifyViewModel.swift @@ -12,11 +12,15 @@ import KeychainAccess class SpotifyViewModel: ObservableObject { - public static let loginCallback = "spotify-login-callback" + public enum AuthorizationStatus { + case none, valid, failed + } private var isLoadingUserProfile: Bool = false - private static let authorizationManagerKey = "authorizationManager" + private var spotifyAccessTokenURL: String { + "https://open.spotify.com/get_access_token?reason=transport&productType=web_player" + } private static let authScopes: Set = [ .playlistReadPrivate, @@ -29,19 +33,10 @@ class SpotifyViewModel: ObservableObject { .userTopRead ] - @Published var isAuthorized: Bool = false - @Published var useURLAuth: Bool = false - - private let clientId: String - - private let codeVerifier: String - private let codeChallenge: String - private let state: String + @Published var isAuthorized: AuthorizationStatus = .none public let spotify: SpotifyAPI - public let authorizationURL: URL - private var cancellables: Set = [] private let keychain: Keychain @@ -49,28 +44,12 @@ class SpotifyViewModel: ObservableObject { @Published var userProfile: SpotifyUser? init() { - self.clientId = Bundle.main.infoDictionary?["SpotifyClientId"] as? String ?? "" - self.keychain = Keychain(service: SpwifiyApp.service) self.spotify = SpotifyAPI( - authorizationManager: AuthorizationCodeFlowPKCEManager(clientId: clientId) + authorizationManager: AuthorizationCodeFlowPKCEManager(clientId: "") ) - self.isAuthorized = self.spotify.authorizationManager.isAuthorized() - - self.codeVerifier = String.randomURLSafe(length: 128) - self.codeChallenge = String.makeCodeChallenge(codeVerifier: self.codeVerifier) - - self.state = String.randomURLSafe(length: 128) - - self.authorizationURL = spotify.authorizationManager.makeAuthorizationURL( - redirectURI: URL(string: SpwifiyApp.redirectURI + SpotifyViewModel.loginCallback)!, - codeChallenge: self.codeChallenge, - state: self.state, - scopes: SpotifyViewModel.authScopes - )! - self.spotify.authorizationManagerDidChange .receive(on: RunLoop.main) .sink(receiveValue: self.authorizationManagerDidChange) @@ -80,65 +59,85 @@ class SpotifyViewModel: ObservableObject { .receive(on: RunLoop.main) .sink(receiveValue: self.authorizationManagerDidDeauthorize) .store(in: &cancellables) + } - if let authData = self.keychain[data: SpotifyViewModel.authorizationManagerKey], - let pckeAuthManager = try? JSONDecoder() - .decode(AuthorizationCodeFlowPKCEManager.self, from: authData) { - self.spotify.authorizationManager = pckeAuthManager - } + public func attemptSpotifyAuthToken() async { + let decoder = JSONDecoder() - if self.spotify.authorizationManager.refreshToken != nil { - self.spotifyRequest { - self.spotify.authorizationManager.refreshTokens(onlyIfExpired: false) - } sink: { completion in - do { - try self.authorizeCallback(completion: completion) - } catch { - print(error) + if let spDcCookieData = await keychain[data: SpotifyAuthManager.spDcCookieKey], + let spTCookieData = await keychain[data: SpotifyAuthManager.spTCookieKey], + let spDcCookie = try? decoder.decode(SpotifyAuthCookie.self, from: spDcCookieData).httpCookie, + let spTCookie = try? decoder.decode(SpotifyAuthCookie.self, from: spTCookieData).httpCookie { - Task { @MainActor in - self.isAuthorized = false - self.useURLAuth = true + APIRequest.shared.setCookie(cookie: spDcCookie, noCache: true) + APIRequest.shared.setCookie(cookie: spTCookie, noCache: true) + + APIRequest.shared.request(url: URL(string: spotifyAccessTokenURL)!, noCache: true) { data in + Task { @MainActor in + APIRequest.shared.removeCookie(cookie: spDcCookie, noCache: true) + APIRequest.shared.removeCookie(cookie: spTCookie, noCache: true) + + guard let data = data, + let authResponse = try? decoder.decode(SpotifyAuthResponse.self, from: data) else { + self.isAuthorized = .failed + + return } + + if authResponse.isAnonymous { + self.isAuthorized = .failed + + return + } + + self.spotify.authorizationManager = AuthorizationCodeFlowPKCEManager( + clientId: authResponse.clientId, + accessToken: authResponse.accessToken, + expirationDate: Date(millisecondsSince1970: authResponse.accessTokenExpirationTimestampMs), + refreshToken: nil, + scopes: SpotifyViewModel.authScopes + ) + + self.isAuthorized = .valid } } } else { - self.useURLAuth = true + Task { @MainActor in + self.isAuthorized = .failed + } } } - private func authorizeCallback(completion: Subscribers.Completion) throws { - switch completion { - case .finished: - print("user successfully authorized") - case .failure(let error): - if let authError = error as? SpotifyAuthorizationError, authError.accessWasDenied { - print("the user denied the authorization request") - throw SpwifiyErrors.authAccessDenied - } else { - print("couldn't authorize application: \(error)") - throw SpwifiyErrors.unknownError(error.localizedDescription) + public func logout() { + var request = URLRequest(url: URL(string: "https://open.spotify.com/logout")!) + + request.setValue("Bearer \(spotify.authorizationManager.accessToken!)", forHTTPHeaderField: "Authorization") + + APIRequest.shared.request(request: request, noCache: true) { _ in + Task { @MainActor in + self.removeCookies() + + self.isAuthorized = .none } } } private func authorizationManagerDidChange() { - isAuthorized = spotify.authorizationManager.isAuthorized() - - do { - let authManagerData = try JSONEncoder().encode(spotify.authorizationManager) - - keychain[data: SpotifyViewModel.authorizationManagerKey] = authManagerData - } catch { - print("unable to store auth manager state") + Task(priority: .utility) { + await attemptSpotifyAuthToken() } } private func authorizationManagerDidDeauthorize() { - isAuthorized = false + isAuthorized = .failed + + removeCookies() + } + private func removeCookies() { do { - try keychain.remove(SpotifyViewModel.authorizationManagerKey) + try keychain.remove(SpotifyAuthManager.spDcCookieKey) + try keychain.remove(SpotifyAuthManager.spTCookieKey) } catch { print("unable to remove unauthorized manager") } @@ -210,18 +209,6 @@ class SpotifyViewModel: ObservableObject { } } - func spotifyRequestAccess(redirectURL: URL) async throws { - try await spotifyRequest { - spotify.authorizationManager.requestAccessAndRefreshTokens( - redirectURIWithQuery: redirectURL, - codeVerifier: codeVerifier, - state: state - ) - } sink: { result in - try self.authorizeCallback(completion: result) - } - } - @MainActor func loadUserProfile() async { guard !isLoadingUserProfile else { diff --git a/Spwifiy/Views/LoginView.swift b/Spwifiy/Views/LoginView.swift index 7735724..84ec3bd 100644 --- a/Spwifiy/Views/LoginView.swift +++ b/Spwifiy/Views/LoginView.swift @@ -2,38 +2,33 @@ // LoginView.swift // Spwifiy // -// Created by Peter Duanmu on 11/24/24. +// Created by Peter Duanmu on 12/4/24. // import SwiftUI struct LoginView: View { - @ObservedObject var spotifyViewModel: SpotifyViewModel + @Binding var authStatus: SpotifyAuthManager.AuthStatus + + @State var isLoggingIn: Bool = false var body: some View { VStack { - Spacer() - - Text("Attempting to authorize...") - .font(.title) - - Spacer() - - Text("Click the URL below if an authorization window does not appear") - .font(.title) - Link(destination: spotifyViewModel.authorizationURL) { - Text(spotifyViewModel.authorizationURL.absoluteString) - .font(.title) + if isLoggingIn { + SpotifyWebView(authStatus: $authStatus) + } else { + Button { + isLoggingIn.toggle() + } label: { + Text("Login with Spotify") + .font(.satoshiBlack(24)) + .padding() + } + .cursorHover(.pointingHand) } - - Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - if spotifyViewModel.useURLAuth { - NSWorkspace.shared.open(spotifyViewModel.authorizationURL) - } - } } + } diff --git a/Spwifiy/Views/MainElements/HeadElementView.swift b/Spwifiy/Views/MainElements/HeadElementView.swift index e8df8d1..369045f 100644 --- a/Spwifiy/Views/MainElements/HeadElementView.swift +++ b/Spwifiy/Views/MainElements/HeadElementView.swift @@ -153,9 +153,8 @@ public struct HeadElementView: View { NavButton(currentButton: .profile, currentView: $mainViewModel.currentView) { + spotifyViewModel.logout() spotifyViewModel.spotify.authorizationManager.deauthorize() - - NSApplication.shared.terminate(nil) } label: { CroppedCachedAsyncImage(url: userProfile?.images?.first?.url, width: 40,