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,