@superfan-app/spotify-auth 0.1.33 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,593 +2,684 @@ import ExpoModulesCore
2
2
  import SpotifyiOS
3
3
  import KeychainAccess
4
4
 
5
+ /// A lightweight session model to hold token information.
6
+ struct SpotifySessionData {
7
+ let accessToken: String
8
+ let refreshToken: String
9
+ let expirationDate: Date
10
+
11
+ var isExpired: Bool {
12
+ return Date() >= expirationDate
13
+ }
14
+
15
+ init(accessToken: String, refreshToken: String, expirationDate: Date) {
16
+ self.accessToken = accessToken
17
+ self.refreshToken = refreshToken
18
+ self.expirationDate = expirationDate
19
+ }
20
+
21
+ /// Initialize from an SPTSession (from app‑switch flow)
22
+ init?(session: SPTSession) {
23
+ // We assume that SPTSession has valid properties.
24
+ self.accessToken = session.accessToken
25
+ self.refreshToken = session.refreshToken
26
+ self.expirationDate = session.expirationDate
27
+ }
28
+ }
29
+
5
30
  enum SpotifyAuthError: Error {
6
- case missingConfiguration(String)
7
- case invalidConfiguration(String)
8
- case authenticationFailed(String)
9
- case tokenError(String)
10
- case sessionError(String)
11
- case networkError(String)
12
- case recoverable(String, RetryStrategy)
13
-
14
- enum RetryStrategy {
15
- case none
16
- case retry(attempts: Int, delay: TimeInterval)
17
- case exponentialBackoff(maxAttempts: Int, initialDelay: TimeInterval)
18
- }
19
-
20
- var isRecoverable: Bool {
21
- switch self {
22
- case .recoverable:
23
- return true
24
- case .networkError:
25
- return true
26
- case .tokenError:
27
- return true
28
- default:
29
- return false
30
- }
31
- }
32
-
33
- var localizedDescription: String {
34
- switch self {
35
- case .missingConfiguration(let field):
36
- return "Missing configuration: \(field). Please check your app.json configuration."
37
- case .invalidConfiguration(let reason):
38
- return "Invalid configuration: \(reason). Please verify your settings."
39
- case .authenticationFailed(let reason):
40
- return "Authentication failed: \(reason). Please try again."
41
- case .tokenError(let reason):
42
- return "Token error: \(reason). Please try logging in again."
43
- case .sessionError(let reason):
44
- return "Session error: \(reason). Please restart the authentication process."
45
- case .networkError(let reason):
46
- return "Network error: \(reason). Please check your internet connection."
47
- case .recoverable(let message, _):
48
- return message
49
- }
50
- }
51
-
52
- var retryStrategy: RetryStrategy {
53
- switch self {
54
- case .recoverable(_, let strategy):
55
- return strategy
56
- case .networkError:
57
- return .exponentialBackoff(maxAttempts: 3, initialDelay: 1.0)
58
- case .tokenError:
59
- return .retry(attempts: 3, delay: 5.0)
60
- default:
61
- return .none
62
- }
63
- }
31
+ case missingConfiguration(String)
32
+ case invalidConfiguration(String)
33
+ case authenticationFailed(String)
34
+ case tokenError(String)
35
+ case sessionError(String)
36
+ case networkError(String)
37
+ case recoverable(String, RetryStrategy)
38
+
39
+ enum RetryStrategy {
40
+ case none
41
+ case retry(attempts: Int, delay: TimeInterval)
42
+ case exponentialBackoff(maxAttempts: Int, initialDelay: TimeInterval)
43
+ }
44
+
45
+ var isRecoverable: Bool {
46
+ switch self {
47
+ case .recoverable:
48
+ return true
49
+ case .networkError:
50
+ return true
51
+ case .tokenError:
52
+ return true
53
+ default:
54
+ return false
55
+ }
56
+ }
57
+
58
+ var localizedDescription: String {
59
+ switch self {
60
+ case .missingConfiguration(let field):
61
+ return "Missing configuration: \(field). Please check your app.json configuration."
62
+ case .invalidConfiguration(let reason):
63
+ return "Invalid configuration: \(reason). Please verify your settings."
64
+ case .authenticationFailed(let reason):
65
+ return "Authentication failed: \(reason). Please try again."
66
+ case .tokenError(let reason):
67
+ return "Token error: \(reason). Please try logging in again."
68
+ case .sessionError(let reason):
69
+ return "Session error: \(reason). Please restart the authentication process."
70
+ case .networkError(let reason):
71
+ return "Network error: \(reason). Please check your internet connection."
72
+ case .recoverable(let message, _):
73
+ return message
74
+ }
75
+ }
76
+
77
+ var retryStrategy: RetryStrategy {
78
+ switch self {
79
+ case .recoverable(_, let strategy):
80
+ return strategy
81
+ case .networkError:
82
+ return .exponentialBackoff(maxAttempts: 3, initialDelay: 1.0)
83
+ case .tokenError:
84
+ return .retry(attempts: 3, delay: 5.0)
85
+ default:
86
+ return .none
87
+ }
88
+ }
64
89
  }
65
90
 
66
91
  final class SpotifyAuthAuth: NSObject, SPTSessionManagerDelegate, SpotifyOAuthViewDelegate {
67
- weak var module: SpotifyAuthModule?
68
- private var webAuthView: SpotifyOAuthView?
69
- private var isUsingWebAuth = false
70
- private var currentConfig: AuthorizeConfig?
71
-
72
- static let shared = SpotifyAuthAuth()
73
-
74
- private var clientID: String {
75
- get throws {
76
- guard let config = currentConfig else {
77
- throw SpotifyAuthError.missingConfiguration("No active configuration")
78
- }
79
- guard !config.clientId.isEmpty else {
80
- throw SpotifyAuthError.missingConfiguration("clientId")
81
- }
82
- return config.clientId
92
+ /// A weak reference to our module’s JS interface.
93
+ weak var module: SpotifyAuthModule?
94
+
95
+ /// For web‑auth we present our own OAuth view.
96
+ private var webAuthView: SpotifyOAuthView?
97
+ private var isUsingWebAuth = false
98
+
99
+ /// Stores the active configuration from JS.
100
+ private var currentConfig: AuthorizeConfig?
101
+
102
+ /// Our own session model.
103
+ private var currentSession: SpotifySessionData? {
104
+ didSet {
105
+ cleanupPreviousSession()
106
+ if let session = currentSession {
107
+ securelyStoreToken(session)
108
+ scheduleTokenRefresh(session)
109
+ }
110
+ }
111
+ }
112
+
113
+ /// If authentication is in progress.
114
+ private var isAuthenticating: Bool = false {
115
+ didSet {
116
+ if !isAuthenticating && currentSession == nil {
117
+ module?.onAuthorizationError("Authentication process ended without session")
118
+ }
119
+ }
120
+ }
121
+
122
+ private var refreshTimer: Timer?
123
+
124
+ static let shared = SpotifyAuthAuth()
125
+
126
+ // MARK: - Configuration Accessors
127
+
128
+ private var clientID: String {
129
+ get throws {
130
+ guard let config = currentConfig else {
131
+ throw SpotifyAuthError.missingConfiguration("No active configuration")
132
+ }
133
+ guard !config.clientId.isEmpty else {
134
+ throw SpotifyAuthError.missingConfiguration("clientId")
135
+ }
136
+ return config.clientId
137
+ }
138
+ }
139
+
140
+ private var redirectURL: URL {
141
+ get throws {
142
+ guard let config = currentConfig else {
143
+ throw SpotifyAuthError.missingConfiguration("No active configuration")
144
+ }
145
+ guard let url = URL(string: config.redirectUrl),
146
+ url.scheme != nil,
147
+ url.host != nil else {
148
+ throw SpotifyAuthError.invalidConfiguration("Invalid redirect URL format")
149
+ }
150
+ return url
151
+ }
152
+ }
153
+
154
+ private var tokenRefreshURL: String {
155
+ get throws {
156
+ guard let config = currentConfig else {
157
+ throw SpotifyAuthError.missingConfiguration("No active configuration")
158
+ }
159
+ guard !config.tokenRefreshURL.isEmpty else {
160
+ throw SpotifyAuthError.missingConfiguration("tokenRefreshURL")
161
+ }
162
+ return config.tokenRefreshURL
163
+ }
164
+ }
165
+
166
+ private var tokenSwapURL: String {
167
+ get throws {
168
+ guard let config = currentConfig else {
169
+ throw SpotifyAuthError.missingConfiguration("No active configuration")
170
+ }
171
+ guard !config.tokenSwapURL.isEmpty else {
172
+ throw SpotifyAuthError.missingConfiguration("tokenSwapURL")
173
+ }
174
+ return config.tokenSwapURL
175
+ }
176
+ }
177
+
178
+ private var requestedScopes: SPTScope {
179
+ get throws {
180
+ guard let config = currentConfig else {
181
+ throw SpotifyAuthError.missingConfiguration("No active configuration")
182
+ }
183
+ guard !config.scopes.isEmpty else {
184
+ throw SpotifyAuthError.missingConfiguration("scopes")
185
+ }
186
+
187
+ var combinedScope: SPTScope = []
188
+ for scopeString in config.scopes {
189
+ if let scope = stringToScope(scopeString: scopeString) {
190
+ combinedScope.insert(scope)
83
191
  }
192
+ }
193
+
194
+ if combinedScope.isEmpty {
195
+ throw SpotifyAuthError.invalidConfiguration("No valid scopes provided")
196
+ }
197
+
198
+ return combinedScope
199
+ }
200
+ }
201
+
202
+ /// Validate and set up secure URLs on the SPTConfiguration.
203
+ private func validateAndConfigureURLs(_ config: SPTConfiguration) throws {
204
+ // Validate token swap URL
205
+ guard let tokenSwapURL = URL(string: try self.tokenSwapURL),
206
+ tokenSwapURL.scheme?.lowercased() == "https" else {
207
+ throw SpotifyAuthError.invalidConfiguration("Token swap URL must use HTTPS")
84
208
  }
85
-
86
- private var redirectURL: URL {
87
- get throws {
88
- guard let config = currentConfig else {
89
- throw SpotifyAuthError.missingConfiguration("No active configuration")
90
- }
91
- guard let url = URL(string: config.redirectUrl),
92
- url.scheme != nil,
93
- url.host != nil else {
94
- throw SpotifyAuthError.invalidConfiguration("Invalid redirect URL format")
95
- }
96
- return url
97
- }
98
- }
99
-
100
- private var tokenRefreshURL: String {
101
- get throws {
102
- guard let config = currentConfig else {
103
- throw SpotifyAuthError.missingConfiguration("No active configuration")
104
- }
105
- guard !config.tokenRefreshURL.isEmpty else {
106
- throw SpotifyAuthError.missingConfiguration("tokenRefreshURL")
107
- }
108
- return config.tokenRefreshURL
109
- }
110
- }
111
-
112
- private var tokenSwapURL: String {
113
- get throws {
114
- guard let config = currentConfig else {
115
- throw SpotifyAuthError.missingConfiguration("No active configuration")
116
- }
117
- guard !config.tokenSwapURL.isEmpty else {
118
- throw SpotifyAuthError.missingConfiguration("tokenSwapURL")
119
- }
120
- return config.tokenSwapURL
121
- }
122
- }
123
-
124
- private var requestedScopes: SPTScope {
125
- get throws {
126
- guard let config = currentConfig else {
127
- throw SpotifyAuthError.missingConfiguration("No active configuration")
128
- }
129
- guard !config.scopes.isEmpty else {
130
- throw SpotifyAuthError.missingConfiguration("scopes")
131
- }
132
-
133
- var combinedScope: SPTScope = []
134
- for scopeString in config.scopes {
135
- if let scope = stringToScope(scopeString: scopeString) {
136
- combinedScope.insert(scope)
137
- }
138
- }
139
-
140
- if combinedScope.isEmpty {
141
- throw SpotifyAuthError.invalidConfiguration("No valid scopes provided")
142
- }
143
-
144
- return combinedScope
145
- }
146
- }
147
-
148
- private func validateAndConfigureURLs(_ config: SPTConfiguration) throws {
149
- // Validate token swap URL
150
- guard let tokenSwapURL = URL(string: try self.tokenSwapURL),
151
- tokenSwapURL.scheme?.lowercased() == "https" else {
152
- throw SpotifyAuthError.invalidConfiguration("Token swap URL must use HTTPS")
153
- }
154
-
155
- // Validate token refresh URL
156
- guard let tokenRefreshURL = URL(string: try self.tokenRefreshURL),
157
- tokenRefreshURL.scheme?.lowercased() == "https" else {
158
- throw SpotifyAuthError.invalidConfiguration("Token refresh URL must use HTTPS")
159
- }
160
-
161
- config.tokenSwapURL = tokenSwapURL
162
- config.tokenRefreshURL = tokenRefreshURL
163
-
164
- // Configure session for secure communication
165
- let session = URLSession(configuration: .ephemeral)
166
- session.configuration.tlsMinimumSupportedProtocolVersion = .TLSv12
167
- session.configuration.httpAdditionalHeaders = [
168
- "X-Client-ID": try self.clientID // Add secure client identification
169
- ]
209
+
210
+ // Validate token refresh URL
211
+ guard let tokenRefreshURL = URL(string: try self.tokenRefreshURL),
212
+ tokenRefreshURL.scheme?.lowercased() == "https" else {
213
+ throw SpotifyAuthError.invalidConfiguration("Token refresh URL must use HTTPS")
170
214
  }
171
-
172
- lazy var configuration: SPTConfiguration? = {
173
- do {
174
- let clientID = try self.clientID
175
- let redirectUrl = try self.redirectURL
176
-
177
- let config = SPTConfiguration(clientID: clientID, redirectURL: redirectUrl)
178
- try validateAndConfigureURLs(config)
179
-
180
- return config
181
- } catch {
182
- module?.onAuthorizationError(error.localizedDescription)
183
- return nil
184
- }
185
- }()
186
-
187
- lazy var sessionManager: SPTSessionManager? = {
188
- guard let configuration = self.configuration else {
189
- module?.onAuthorizationError("Failed to create configuration")
190
- return nil
191
- }
192
- return SPTSessionManager(configuration: configuration, delegate: self)
193
- }()
194
-
195
- private var currentSession: SPTSession? {
196
- didSet {
197
- cleanupPreviousSession()
198
- if let session = currentSession {
199
- securelyStoreToken(session)
200
- scheduleTokenRefresh(session)
201
- }
202
- }
215
+
216
+ config.tokenSwapURL = tokenSwapURL
217
+ config.tokenRefreshURL = tokenRefreshURL
218
+
219
+ // Configure secure communication headers.
220
+ let session = URLSession(configuration: .ephemeral)
221
+ session.configuration.tlsMinimumSupportedProtocolVersion = .TLSv12
222
+ session.configuration.httpAdditionalHeaders = [
223
+ "X-Client-ID": try self.clientID
224
+ ]
225
+ }
226
+
227
+ // MARK: - SPTConfiguration and Session Manager (for app‑switch auth)
228
+
229
+ lazy var configuration: SPTConfiguration? = {
230
+ do {
231
+ let clientID = try self.clientID
232
+ let redirectUrl = try self.redirectURL
233
+ let config = SPTConfiguration(clientID: clientID, redirectURL: redirectUrl)
234
+ try validateAndConfigureURLs(config)
235
+ return config
236
+ } catch {
237
+ module?.onAuthorizationError(error.localizedDescription)
238
+ return nil
239
+ }
240
+ }()
241
+
242
+ lazy var sessionManager: SPTSessionManager? = {
243
+ guard let configuration = self.configuration else {
244
+ module?.onAuthorizationError("Failed to create configuration")
245
+ return nil
246
+ }
247
+ return SPTSessionManager(configuration: configuration, delegate: self)
248
+ }()
249
+
250
+ // MARK: - Session Refresh and Secure Storage
251
+
252
+ private func scheduleTokenRefresh(_ session: SpotifySessionData) {
253
+ refreshTimer?.invalidate()
254
+
255
+ // Schedule refresh 5 minutes before expiration.
256
+ let refreshInterval = session.expirationDate.timeIntervalSinceNow - 300
257
+ if refreshInterval > 0 {
258
+ refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: false) { [weak self] _ in
259
+ self?.refreshToken()
260
+ }
261
+ } else {
262
+ refreshToken()
263
+ }
264
+ }
265
+
266
+ private func getKeychainKey() throws -> String {
267
+ let clientID = try self.clientID
268
+ let redirectUrl = try self.redirectURL
269
+ return "expo.modules.spotifyauth.\(redirectUrl.scheme ?? "unknown").\(clientID).refresh_token"
270
+ }
271
+
272
+ private func securelyStoreToken(_ session: SpotifySessionData) {
273
+ // Pass token back to JS.
274
+ module?.onAccessTokenObtained(session.accessToken)
275
+
276
+ let refreshToken = session.refreshToken
277
+ if !refreshToken.isEmpty {
278
+ do {
279
+ let keychainKey = try getKeychainKey()
280
+ let keychain = Keychain(service: Bundle.main.bundleIdentifier ?? "com.superfan.app")
281
+ .accessibility(.afterFirstUnlock)
282
+ try keychain.set(refreshToken, key: keychainKey)
283
+ } catch {
284
+ print("Failed to store refresh token securely: \(error)")
285
+ }
286
+ }
287
+ }
288
+
289
+ private func cleanupPreviousSession() {
290
+ refreshTimer?.invalidate()
291
+
292
+ do {
293
+ let keychainKey = try getKeychainKey()
294
+ let keychain = Keychain(service: Bundle.main.bundleIdentifier ?? "com.superfan.app")
295
+ try keychain.remove(keychainKey)
296
+ } catch {
297
+ print("Failed to clear previous refresh token: \(error)")
298
+ }
299
+ }
300
+
301
+ /// Refresh token either via SPTSessionManager (app‑switch) or manually (web‑auth).
302
+ private func refreshToken(retryCount: Int = 0) {
303
+ if isUsingWebAuth {
304
+ manualRefreshToken(retryCount: retryCount)
305
+ } else {
306
+ guard let sessionManager = self.sessionManager else {
307
+ handleError(SpotifyAuthError.sessionError("Session manager not available"), context: "token_refresh")
308
+ return
309
+ }
310
+ do {
311
+ try sessionManager.renewSession()
312
+ } catch {
313
+ handleError(error, context: "token_refresh")
314
+ }
315
+ }
316
+ }
317
+
318
+ /// Manual refresh (for web‑auth) that calls the token refresh endpoint.
319
+ private func manualRefreshToken(retryCount: Int = 0) {
320
+ guard let currentSession = self.currentSession else {
321
+ handleError(SpotifyAuthError.sessionError("No session available"), context: "token_refresh")
322
+ return
323
+ }
324
+ guard let refreshURLString = try? self.tokenRefreshURL,
325
+ let url = URL(string: refreshURLString) else {
326
+ handleError(SpotifyAuthError.invalidConfiguration("Invalid token refresh URL"), context: "token_refresh")
327
+ return
203
328
  }
204
329
 
205
- private var isAuthenticating: Bool = false {
206
- didSet {
207
- if !isAuthenticating && currentSession == nil {
208
- module?.onAuthorizationError("Authentication process ended without session")
209
- }
210
- }
330
+ var request = URLRequest(url: url)
331
+ request.httpMethod = "POST"
332
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
333
+
334
+ let params: [String: String]
335
+ do {
336
+ params = [
337
+ "grant_type": "refresh_token",
338
+ "refresh_token": currentSession.refreshToken,
339
+ "client_id": try self.clientID
340
+ ]
341
+ } catch {
342
+ handleError(error, context: "token_refresh")
343
+ return
211
344
  }
212
-
213
- private var refreshTimer: Timer?
214
-
215
- private func scheduleTokenRefresh(_ session: SPTSession) {
216
- refreshTimer?.invalidate()
217
-
218
- // Schedule refresh 5 minutes before expiration
219
- let refreshInterval = TimeInterval(session.expirationDate.timeIntervalSinceNow - 300)
220
- if refreshInterval > 0 {
221
- refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: false) { [weak self] _ in
222
- self?.refreshToken()
223
- }
224
- } else {
225
- refreshToken()
345
+
346
+ let bodyString = params.map { "\($0)=\($1)" }.joined(separator: "&")
347
+ request.httpBody = bodyString.data(using: .utf8)
348
+
349
+ let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
350
+ if let error = error {
351
+ self?.handleError(error, context: "token_refresh")
352
+ return
353
+ }
354
+ guard let data = data,
355
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
356
+ let accessToken = json["access_token"] as? String,
357
+ let expiresIn = json["expires_in"] as? TimeInterval else {
358
+ self?.handleError(SpotifyAuthError.tokenError("Invalid token refresh response"), context: "token_refresh")
359
+ return
360
+ }
361
+
362
+ let newRefreshToken = (json["refresh_token"] as? String) ?? currentSession.refreshToken
363
+ let expirationDate = Date(timeIntervalSinceNow: expiresIn)
364
+ let newSession = SpotifySessionData(accessToken: accessToken, refreshToken: newRefreshToken, expirationDate: expirationDate)
365
+ DispatchQueue.main.async {
366
+ self?.currentSession = newSession
367
+ self?.module?.onAccessTokenObtained(accessToken)
368
+ }
369
+ }
370
+ task.resume()
371
+ }
372
+
373
+ // MARK: - Authentication Flow
374
+
375
+ public func initAuth(config: AuthorizeConfig) {
376
+ do {
377
+ // Save configuration.
378
+ self.currentConfig = config
379
+
380
+ guard let sessionManager = self.sessionManager else {
381
+ throw SpotifyAuthError.sessionError("Session manager not initialized")
382
+ }
383
+ let scopes = try self.requestedScopes
384
+ isAuthenticating = true
385
+
386
+ if sessionManager.isSpotifyAppInstalled {
387
+ // Use the native app‑switch flow.
388
+ if config.showDialog {
389
+ sessionManager.alwaysShowAuthorizationDialog = true
226
390
  }
227
- }
228
-
229
- private func getKeychainKey() throws -> String {
230
- let clientID = try self.clientID
391
+ sessionManager.initiateSession(with: scopes, options: .default, campaign: config.campaign)
392
+ } else {
393
+ // Fall back to web‑auth.
394
+ isUsingWebAuth = true
395
+ let clientId = try self.clientID
231
396
  let redirectUrl = try self.redirectURL
232
- return "expo.modules.spotifyauth.\(redirectUrl.scheme ?? "unknown").\(clientID).refresh_token"
233
- }
234
-
235
- private func securelyStoreToken(_ session: SPTSession) {
236
- // Pass token to JS and securely store refresh token
237
- module?.onAccessTokenObtained(session.accessToken)
238
397
 
239
- // Since refreshToken is now a non-optional String, we simply check for an empty value.
240
- let refreshToken = session.refreshToken
241
- if !refreshToken.isEmpty {
242
- do {
243
- let keychainKey = try getKeychainKey()
244
- // Create a Keychain instance (using bundle identifier as the service)
245
- let keychain = Keychain(service: Bundle.main.bundleIdentifier ?? "com.superfan.app")
246
- .accessibility(.afterFirstUnlock)
247
- try keychain.set(refreshToken, key: keychainKey)
248
- } catch {
249
- print("Failed to store refresh token securely: \(error)")
250
- }
251
- }
252
- }
253
-
254
- private func cleanupPreviousSession() {
255
- refreshTimer?.invalidate()
256
-
257
- do {
258
- let keychainKey = try getKeychainKey()
259
- let keychain = Keychain(service: Bundle.main.bundleIdentifier ?? "com.superfan.app")
260
- try keychain.remove(keychainKey)
261
- } catch {
262
- print("Failed to clear previous refresh token: \(error)")
263
- }
398
+ let webAuthView = SpotifyOAuthView(appContext: nil)
399
+ webAuthView.delegate = self
400
+ self.webAuthView = webAuthView
264
401
 
265
- // Clear in-memory session data
266
- currentSession = nil
267
- }
268
-
269
- private func refreshToken(retryCount: Int = 0) {
270
- guard let sessionManager = self.sessionManager else {
271
- handleError(SpotifyAuthError.sessionError("Session manager not available"), context: "token_refresh")
272
- return
273
- }
402
+ let scopeStrings = scopes.scopesToStringArray()
274
403
 
275
- do {
276
- try sessionManager.renewSession()
277
- } catch {
278
- handleError(error, context: "token_refresh")
279
- }
280
- }
281
-
282
- public func initAuth(config: AuthorizeConfig) {
283
- do {
284
- // Store the current configuration
285
- self.currentConfig = config
286
-
287
- guard let sessionManager = self.sessionManager else {
288
- throw SpotifyAuthError.sessionError("Session manager not initialized")
289
- }
290
- let scopes = try self.requestedScopes
291
- isAuthenticating = true
292
-
293
- // Check if Spotify app is installed
294
- if sessionManager.isSpotifyAppInstalled {
295
- // Use app-switch auth
296
- if config.showDialog {
297
- sessionManager.alwaysShowAuthorizationDialog = true
298
- }
299
- sessionManager.initiateSession(with: scopes, options: .default, campaign: config.campaign)
300
- } else {
301
- // Fall back to web auth
302
- isUsingWebAuth = true
303
- let clientId = try self.clientID
304
- let redirectUrl = try self.redirectURL
305
-
306
- // Create and configure web auth view
307
- let webAuthView = SpotifyOAuthView(appContext: nil)
308
- webAuthView.delegate = self
309
- self.webAuthView = webAuthView
310
-
311
- // Convert SPTScope to string array for web auth
312
- let scopeStrings = scopes.scopesToStringArray()
313
-
314
- // Start web auth flow
315
- webAuthView.startOAuthFlow(
316
- clientId: clientId,
317
- redirectUri: redirectUrl.absoluteString,
318
- scopes: scopeStrings
319
- )
320
-
321
- // Notify module to present web auth view
322
- module?.presentWebAuth(webAuthView)
323
- }
324
- } catch {
325
- isAuthenticating = false
326
- handleError(error, context: "authentication")
327
- }
328
- }
329
-
330
- private func retryAuthentication() {
331
- guard !isAuthenticating else { return }
404
+ webAuthView.startOAuthFlow(
405
+ clientId: clientId,
406
+ redirectUri: redirectUrl.absoluteString,
407
+ scopes: scopeStrings
408
+ )
332
409
 
333
- do {
334
- guard let sessionManager = self.sessionManager else {
335
- throw SpotifyAuthError.sessionError("Session manager not initialized")
336
- }
337
- let scopes = try self.requestedScopes
338
- isAuthenticating = true
339
- sessionManager.initiateSession(with: scopes, options: .default, campaign: nil)
340
- } catch {
341
- isAuthenticating = false
342
- handleError(error, context: "authentication_retry")
343
- }
344
- }
345
-
346
- public func sessionManager(manager _: SPTSessionManager, didInitiate session: SPTSession) {
347
- secureLog("Authentication successful")
348
- isAuthenticating = false
349
- currentSession = session
350
- }
351
-
352
- public func sessionManager(manager _: SPTSessionManager, didFailWith error: Error) {
353
- secureLog("Authentication failed")
354
- isAuthenticating = false
355
- currentSession = nil
356
- handleError(error, context: "authentication")
357
- }
358
-
359
- public func sessionManager(manager _: SPTSessionManager, didRenew session: SPTSession) {
360
- secureLog("Token renewed successfully")
361
- currentSession = session
362
- }
410
+ module?.presentWebAuth(webAuthView)
411
+ }
412
+ } catch {
413
+ isAuthenticating = false
414
+ handleError(error, context: "authentication")
415
+ }
416
+ }
417
+
418
+ private func retryAuthentication() {
419
+ guard !isAuthenticating else { return }
363
420
 
364
- public func clearSession() {
365
- currentSession = nil
366
- module?.onSignOut()
367
- }
368
-
369
- private func stringToScope(scopeString: String) -> SPTScope? {
370
- switch scopeString {
371
- case "playlist-read-private":
372
- return .playlistReadPrivate
373
- case "playlist-read-collaborative":
374
- return .playlistReadCollaborative
375
- case "playlist-modify-public":
376
- return .playlistModifyPublic
377
- case "playlist-modify-private":
378
- return .playlistModifyPrivate
379
- case "user-follow-read":
380
- return .userFollowRead
381
- case "user-follow-modify":
382
- return .userFollowModify
383
- case "user-library-read":
384
- return .userLibraryRead
385
- case "user-library-modify":
386
- return .userLibraryModify
387
- case "user-read-birthdate":
388
- return .userReadBirthDate
389
- case "user-read-email":
390
- return .userReadEmail
391
- case "user-read-private":
392
- return .userReadPrivate
393
- case "user-top-read":
394
- return .userTopRead
395
- case "ugc-image-upload":
396
- return .ugcImageUpload
397
- case "streaming":
398
- return .streaming
399
- case "app-remote-control":
400
- return .appRemoteControl
401
- case "user-read-playback-state":
402
- return .userReadPlaybackState
403
- case "user-modify-playback-state":
404
- return .userModifyPlaybackState
405
- case "user-read-currently-playing":
406
- return .userReadCurrentlyPlaying
407
- case "user-read-recently-played":
408
- return .userReadRecentlyPlayed
409
- case "openid":
410
- return .openid
411
- default:
412
- return nil
413
- }
421
+ do {
422
+ guard let sessionManager = self.sessionManager else {
423
+ throw SpotifyAuthError.sessionError("Session manager not initialized")
424
+ }
425
+ let scopes = try self.requestedScopes
426
+ isAuthenticating = true
427
+ sessionManager.initiateSession(with: scopes, options: .default, campaign: nil)
428
+ } catch {
429
+ isAuthenticating = false
430
+ handleError(error, context: "authentication_retry")
431
+ }
432
+ }
433
+
434
+ // MARK: - SPTSessionManagerDelegate
435
+
436
+ public func sessionManager(manager: SPTSessionManager, didInitiate session: SPTSession) {
437
+ secureLog("Authentication successful")
438
+ isAuthenticating = false
439
+ if let sessionData = SpotifySessionData(session: session) {
440
+ currentSession = sessionData
441
+ }
442
+ }
443
+
444
+ public func sessionManager(manager: SPTSessionManager, didFailWith error: Error) {
445
+ secureLog("Authentication failed")
446
+ isAuthenticating = false
447
+ currentSession = nil
448
+ handleError(error, context: "authentication")
449
+ }
450
+
451
+ public func sessionManager(manager: SPTSessionManager, didRenew session: SPTSession) {
452
+ secureLog("Token renewed successfully")
453
+ if let sessionData = SpotifySessionData(session: session) {
454
+ currentSession = sessionData
455
+ }
456
+ }
457
+
458
+ public func clearSession() {
459
+ currentSession = nil
460
+ module?.onSignOut()
461
+ }
462
+
463
+ // MARK: - SpotifyOAuthViewDelegate
464
+
465
+ func oauthView(_ view: SpotifyOAuthView, didReceiveCode code: String) {
466
+ // Exchange the code for tokens using tokenSwapURL.
467
+ exchangeCodeForToken(code)
468
+ cleanupWebAuth()
469
+ }
470
+
471
+ func oauthView(_ view: SpotifyOAuthView, didFailWithError error: Error) {
472
+ handleError(error, context: "web_authentication")
473
+ cleanupWebAuth()
474
+ }
475
+
476
+ func oauthViewDidCancel(_ view: SpotifyOAuthView) {
477
+ module?.onAuthorizationError("User cancelled authentication")
478
+ cleanupWebAuth()
479
+ }
480
+
481
+ private func cleanupWebAuth() {
482
+ isUsingWebAuth = false
483
+ webAuthView = nil
484
+ currentConfig = nil
485
+ module?.dismissWebAuth()
486
+ }
487
+
488
+ /// Exchange an authorization code for tokens.
489
+ private func exchangeCodeForToken(_ code: String) {
490
+ guard let swapURLString = try? self.tokenSwapURL,
491
+ let url = URL(string: swapURLString) else {
492
+ handleError(SpotifyAuthError.invalidConfiguration("Invalid token swap URL"), context: "token_exchange")
493
+ return
414
494
  }
415
-
416
- private func handleError(_ error: Error, context: String) {
417
- let spotifyError: SpotifyAuthError
418
-
419
- // Instead of switching on SPTError cases (which are no longer available),
420
- // we simply wrap the error's description.
421
- if error is SPTError {
422
- spotifyError = .authenticationFailed(error.localizedDescription)
423
- } else {
424
- spotifyError = .authenticationFailed(error.localizedDescription)
425
- }
426
-
427
- secureLog("Error in \(context): \(spotifyError.localizedDescription)")
428
-
429
- switch spotifyError.retryStrategy {
430
- case .none:
431
- module?.onAuthorizationError(spotifyError.localizedDescription)
432
- cleanupPreviousSession()
433
-
434
- case .retry(let attempts, let delay):
435
- handleRetry(error: spotifyError, context: context, remainingAttempts: attempts, delay: delay)
436
-
437
- case .exponentialBackoff(let maxAttempts, let initialDelay):
438
- handleExponentialBackoff(error: spotifyError, context: context, remainingAttempts: maxAttempts, currentDelay: initialDelay)
439
- }
440
- }
441
-
442
- private func handleRetry(error: SpotifyAuthError, context: String, remainingAttempts: Int, delay: TimeInterval) {
443
- guard remainingAttempts > 0 else {
444
- module?.onAuthorizationError("\(error.localizedDescription) (Max retries reached)")
445
- cleanupPreviousSession()
446
- return
447
- }
448
-
449
- secureLog("Retrying \(context) in \(delay) seconds. Attempts remaining: \(remainingAttempts)")
450
-
451
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
452
- switch context {
453
- case "token_refresh":
454
- self?.refreshToken(retryCount: 3 - remainingAttempts)
455
- case "authentication":
456
- self?.retryAuthentication()
457
- default:
458
- break
459
- }
460
- }
461
- }
462
-
463
- private func handleExponentialBackoff(error: SpotifyAuthError, context: String, remainingAttempts: Int, currentDelay: TimeInterval) {
464
- guard remainingAttempts > 0 else {
465
- module?.onAuthorizationError("\(error.localizedDescription) (Max retries reached)")
466
- cleanupPreviousSession()
467
- return
468
- }
469
-
470
- secureLog("Retrying \(context) in \(currentDelay) seconds. Attempts remaining: \(remainingAttempts)")
471
-
472
- DispatchQueue.main.asyncAfter(deadline: .now() + currentDelay) { [weak self] in
473
- switch context {
474
- case "token_refresh":
475
- self?.refreshToken(retryCount: 3 - remainingAttempts)
476
- case "authentication":
477
- self?.retryAuthentication()
478
- default:
479
- break
480
- }
481
- }
482
- }
483
-
484
- deinit {
485
- cleanupPreviousSession()
486
- }
487
-
488
- // MARK: - SpotifyOAuthViewDelegate
489
495
 
490
- func oauthView(_ view: SpotifyOAuthView, didReceiveCode code: String) {
491
- // Exchange the code for tokens using token swap URL
492
- exchangeCodeForToken(code)
493
-
494
- // Cleanup web view
495
- cleanupWebAuth()
496
- }
496
+ var request = URLRequest(url: url)
497
+ request.httpMethod = "POST"
498
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
497
499
 
498
- func oauthView(_ view: SpotifyOAuthView, didFailWithError error: Error) {
499
- handleError(error, context: "web_authentication")
500
- cleanupWebAuth()
500
+ let params: [String: String]
501
+ do {
502
+ params = [
503
+ "grant_type": "authorization_code",
504
+ "code": code,
505
+ "redirect_uri": try self.redirectURL.absoluteString,
506
+ "client_id": try self.clientID
507
+ ]
508
+ } catch {
509
+ handleError(error, context: "token_exchange")
510
+ return
501
511
  }
502
512
 
503
- func oauthViewDidCancel(_ view: SpotifyOAuthView) {
504
- module?.onAuthorizationError("User cancelled authentication")
505
- cleanupWebAuth()
513
+ let bodyString = params.map { "\($0)=\($1)" }.joined(separator: "&")
514
+ request.httpBody = bodyString.data(using: .utf8)
515
+
516
+ let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
517
+ if let error = error {
518
+ self?.handleError(error, context: "token_exchange")
519
+ return
520
+ }
521
+ guard let data = data,
522
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
523
+ let accessToken = json["access_token"] as? String,
524
+ let refreshToken = json["refresh_token"] as? String,
525
+ let expiresIn = json["expires_in"] as? TimeInterval else {
526
+ self?.handleError(SpotifyAuthError.tokenError("Invalid token response"), context: "token_exchange")
527
+ return
528
+ }
529
+
530
+ let expirationDate = Date(timeIntervalSinceNow: expiresIn)
531
+ let sessionData = SpotifySessionData(accessToken: accessToken, refreshToken: refreshToken, expirationDate: expirationDate)
532
+ DispatchQueue.main.async {
533
+ self?.currentSession = sessionData
534
+ self?.module?.onAccessTokenObtained(accessToken)
535
+ }
506
536
  }
507
537
 
508
- private func cleanupWebAuth() {
509
- isUsingWebAuth = false
510
- webAuthView = nil
511
- currentConfig = nil
512
- module?.dismissWebAuth()
538
+ task.resume()
539
+ }
540
+
541
+ // MARK: - Helpers
542
+
543
+ private func stringToScope(scopeString: String) -> SPTScope? {
544
+ switch scopeString {
545
+ case "playlist-read-private":
546
+ return .playlistReadPrivate
547
+ case "playlist-read-collaborative":
548
+ return .playlistReadCollaborative
549
+ case "playlist-modify-public":
550
+ return .playlistModifyPublic
551
+ case "playlist-modify-private":
552
+ return .playlistModifyPrivate
553
+ case "user-follow-read":
554
+ return .userFollowRead
555
+ case "user-follow-modify":
556
+ return .userFollowModify
557
+ case "user-library-read":
558
+ return .userLibraryRead
559
+ case "user-library-modify":
560
+ return .userLibraryModify
561
+ case "user-read-birthdate":
562
+ return .userReadBirthDate
563
+ case "user-read-email":
564
+ return .userReadEmail
565
+ case "user-read-private":
566
+ return .userReadPrivate
567
+ case "user-top-read":
568
+ return .userTopRead
569
+ case "ugc-image-upload":
570
+ return .ugcImageUpload
571
+ case "streaming":
572
+ return .streaming
573
+ case "app-remote-control":
574
+ return .appRemoteControl
575
+ case "user-read-playback-state":
576
+ return .userReadPlaybackState
577
+ case "user-modify-playback-state":
578
+ return .userModifyPlaybackState
579
+ case "user-read-currently-playing":
580
+ return .userReadCurrentlyPlaying
581
+ case "user-read-recently-played":
582
+ return .userReadRecentlyPlayed
583
+ case "openid":
584
+ return .openid
585
+ default:
586
+ return nil
587
+ }
588
+ }
589
+
590
+ private func handleError(_ error: Error, context: String) {
591
+ let spotifyError: SpotifyAuthError = .authenticationFailed(error.localizedDescription)
592
+
593
+ secureLog("Error in \(context): \(spotifyError.localizedDescription)")
594
+
595
+ switch spotifyError.retryStrategy {
596
+ case .none:
597
+ module?.onAuthorizationError(spotifyError.localizedDescription)
598
+ cleanupPreviousSession()
599
+ case .retry(let attempts, let delay):
600
+ handleRetry(error: spotifyError, context: context, remainingAttempts: attempts, delay: delay)
601
+ case .exponentialBackoff(let maxAttempts, let initialDelay):
602
+ handleExponentialBackoff(error: spotifyError, context: context, remainingAttempts: maxAttempts, currentDelay: initialDelay)
603
+ }
604
+ }
605
+
606
+ private func handleRetry(error: SpotifyAuthError, context: String, remainingAttempts: Int, delay: TimeInterval) {
607
+ guard remainingAttempts > 0 else {
608
+ module?.onAuthorizationError("\(error.localizedDescription) (Max retries reached)")
609
+ cleanupPreviousSession()
610
+ return
513
611
  }
514
612
 
515
- private func exchangeCodeForToken(_ code: String) {
516
- guard let tokenSwapURL = try? URL(string: self.tokenSwapURL) else {
517
- handleError(SpotifyAuthError.invalidConfiguration("Invalid token swap URL"), context: "token_exchange")
518
- return
519
- }
520
-
521
- var request = URLRequest(url: tokenSwapURL)
522
- request.httpMethod = "POST"
523
- request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
524
-
525
- let params = [
526
- "grant_type": "authorization_code",
527
- "code": code,
528
- "redirect_uri": try? self.redirectURL.absoluteString,
529
- "client_id": try? self.clientID
530
- ].compactMapValues { $0 }
531
-
532
- request.httpBody = params
533
- .map { "\($0)=\($1)" }
534
- .joined(separator: "&")
535
- .data(using: .utf8)
536
-
537
- let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
538
- if let error = error {
539
- self?.handleError(error, context: "token_exchange")
540
- return
541
- }
542
-
543
- guard let data = data,
544
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
545
- let accessToken = json["access_token"] as? String,
546
- let refreshToken = json["refresh_token"] as? String,
547
- let expiresIn = json["expires_in"] as? TimeInterval else {
548
- self?.handleError(SpotifyAuthError.tokenError("Invalid token response"), context: "token_exchange")
549
- return
550
- }
551
-
552
- // Create session from token response
553
- let expirationDate = Date(timeIntervalSinceNow: expiresIn)
554
- if let session = SPTSession(accessToken: accessToken, refreshToken: refreshToken, expirationDate: expirationDate) {
555
- DispatchQueue.main.async {
556
- self?.currentSession = session
557
- self?.module?.onAccessTokenObtained(accessToken)
558
- }
559
- }
560
- }
561
-
562
- task.resume()
613
+ secureLog("Retrying \(context) in \(delay) seconds. Attempts remaining: \(remainingAttempts)")
614
+
615
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
616
+ switch context {
617
+ case "token_refresh":
618
+ self?.refreshToken(retryCount: 3 - remainingAttempts)
619
+ case "authentication":
620
+ self?.retryAuthentication()
621
+ default:
622
+ break
623
+ }
624
+ }
625
+ }
626
+
627
+ private func handleExponentialBackoff(error: SpotifyAuthError, context: String, remainingAttempts: Int, currentDelay: TimeInterval) {
628
+ guard remainingAttempts > 0 else {
629
+ module?.onAuthorizationError("\(error.localizedDescription) (Max retries reached)")
630
+ cleanupPreviousSession()
631
+ return
563
632
  }
633
+
634
+ secureLog("Retrying \(context) in \(currentDelay) seconds. Attempts remaining: \(remainingAttempts)")
635
+
636
+ DispatchQueue.main.asyncAfter(deadline: .now() + currentDelay) { [weak self] in
637
+ switch context {
638
+ case "token_refresh":
639
+ self?.refreshToken(retryCount: 3 - remainingAttempts)
640
+ case "authentication":
641
+ self?.retryAuthentication()
642
+ default:
643
+ break
644
+ }
645
+ }
646
+ }
647
+
648
+ private func secureLog(_ message: String) {
649
+ print("[SpotifyAuthAuth] \(message)")
650
+ }
651
+
652
+ deinit {
653
+ cleanupPreviousSession()
654
+ }
564
655
  }
565
656
 
566
- // Helper extension to convert SPTScope to string array
657
+ // MARK: - Helper Extension
658
+
567
659
  extension SPTScope {
568
- func scopesToStringArray() -> [String] {
569
- var scopes: [String] = []
570
-
571
- if contains(.playlistReadPrivate) { scopes.append("playlist-read-private") }
572
- if contains(.playlistReadCollaborative) { scopes.append("playlist-read-collaborative") }
573
- if contains(.playlistModifyPublic) { scopes.append("playlist-modify-public") }
574
- if contains(.playlistModifyPrivate) { scopes.append("playlist-modify-private") }
575
- if contains(.userFollowRead) { scopes.append("user-follow-read") }
576
- if contains(.userFollowModify) { scopes.append("user-follow-modify") }
577
- if contains(.userLibraryRead) { scopes.append("user-library-read") }
578
- if contains(.userLibraryModify) { scopes.append("user-library-modify") }
579
- if contains(.userReadBirthDate) { scopes.append("user-read-birthdate") }
580
- if contains(.userReadEmail) { scopes.append("user-read-email") }
581
- if contains(.userReadPrivate) { scopes.append("user-read-private") }
582
- if contains(.userTopRead) { scopes.append("user-top-read") }
583
- if contains(.ugcImageUpload) { scopes.append("ugc-image-upload") }
584
- if contains(.streaming) { scopes.append("streaming") }
585
- if contains(.appRemoteControl) { scopes.append("app-remote-control") }
586
- if contains(.userReadPlaybackState) { scopes.append("user-read-playback-state") }
587
- if contains(.userModifyPlaybackState) { scopes.append("user-modify-playback-state") }
588
- if contains(.userReadCurrentlyPlaying) { scopes.append("user-read-currently-playing") }
589
- if contains(.userReadRecentlyPlayed) { scopes.append("user-read-recently-played") }
590
- if contains(.openid) { scopes.append("openid") }
591
-
592
- return scopes
593
- }
660
+ /// Converts an SPTScope value into an array of scope strings.
661
+ func scopesToStringArray() -> [String] {
662
+ var scopes: [String] = []
663
+ if contains(.playlistReadPrivate) { scopes.append("playlist-read-private") }
664
+ if contains(.playlistReadCollaborative) { scopes.append("playlist-read-collaborative") }
665
+ if contains(.playlistModifyPublic) { scopes.append("playlist-modify-public") }
666
+ if contains(.playlistModifyPrivate) { scopes.append("playlist-modify-private") }
667
+ if contains(.userFollowRead) { scopes.append("user-follow-read") }
668
+ if contains(.userFollowModify) { scopes.append("user-follow-modify") }
669
+ if contains(.userLibraryRead) { scopes.append("user-library-read") }
670
+ if contains(.userLibraryModify) { scopes.append("user-library-modify") }
671
+ if contains(.userReadBirthDate) { scopes.append("user-read-birthdate") }
672
+ if contains(.userReadEmail) { scopes.append("user-read-email") }
673
+ if contains(.userReadPrivate) { scopes.append("user-read-private") }
674
+ if contains(.userTopRead) { scopes.append("user-top-read") }
675
+ if contains(.ugcImageUpload) { scopes.append("ugc-image-upload") }
676
+ if contains(.streaming) { scopes.append("streaming") }
677
+ if contains(.appRemoteControl) { scopes.append("app-remote-control") }
678
+ if contains(.userReadPlaybackState) { scopes.append("user-read-playback-state") }
679
+ if contains(.userModifyPlaybackState) { scopes.append("user-modify-playback-state") }
680
+ if contains(.userReadCurrentlyPlaying) { scopes.append("user-read-currently-playing") }
681
+ if contains(.userReadRecentlyPlayed) { scopes.append("user-read-recently-played") }
682
+ if contains(.openid) { scopes.append("openid") }
683
+ return scopes
684
+ }
594
685
  }
@@ -92,8 +92,8 @@ public class SpotifyAuthModule: Module {
92
92
  }
93
93
 
94
94
  // Enables the module to be used as a native view.
95
- View(SpotifyAuthView.self) {
96
- Prop("name") { (_: SpotifyAuthView, prop: String) in
95
+ View(SpotifyOAuthView.self) {
96
+ Prop("name") { (_: SpotifyOAuthView, prop: String) in
97
97
  secureLog("View prop updated: \(prop)")
98
98
  }
99
99
  }
@@ -158,8 +158,7 @@ public class SpotifyAuthModule: Module {
158
158
  }
159
159
 
160
160
  func presentWebAuth(_ webAuthView: SpotifyOAuthView) {
161
- // Find the top-most view controller to present from
162
- guard let topViewController = UIApplication.shared.keyWindow?.rootViewController?.topMostViewController() else {
161
+ guard let topViewController = UIApplication.shared.currentKeyWindow?.rootViewController?.topMostViewController() else {
163
162
  onAuthorizationError("Could not present web authentication")
164
163
  return
165
164
  }
@@ -178,7 +177,7 @@ public class SpotifyAuthModule: Module {
178
177
  func dismissWebAuth() {
179
178
  // Find and dismiss the web auth view controller
180
179
  DispatchQueue.main.async {
181
- UIApplication.shared.keyWindow?.rootViewController?.topMostViewController()?.dismiss(animated: true)
180
+ UIApplication.shared.currentKeyWindow?.rootViewController?.topMostViewController().dismiss(animated: true)
182
181
  }
183
182
  }
184
183
  }
@@ -198,3 +197,18 @@ extension UIViewController {
198
197
  return self
199
198
  }
200
199
  }
200
+
201
+ // Extension to safely get key window on iOS 13+
202
+ extension UIApplication {
203
+ var currentKeyWindow: UIWindow? {
204
+ if #available(iOS 13, *) {
205
+ return self.connectedScenes
206
+ .filter { $0.activationState == .foregroundActive }
207
+ .compactMap { $0 as? UIWindowScene }
208
+ .flatMap { $0.windows }
209
+ .first(where: { $0.isKeyWindow })
210
+ } else {
211
+ return self.keyWindow
212
+ }
213
+ }
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superfan-app/spotify-auth",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Spotify OAuth module for Expo",
5
5
  "main": "src/index.ts",
6
6
  "types": "build/index.d.ts",