@wwdrew/expo-spotify-sdk 2.2.3 → 2.3.1

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.
@@ -1,88 +1,8 @@
1
1
  import ExpoModulesCore
2
- import AuthenticationServices
3
2
  import Foundation
4
3
  import SpotifyiOS
5
4
  import UIKit
6
5
 
7
- /// Public, structured errors thrown by the coordinator. Each case carries the
8
- /// JS-facing error code in `code`, mirroring the Android `CodedException`
9
- /// taxonomy.
10
- enum SpotifyError: Error {
11
- case invalidConfiguration(String)
12
- case authInProgress
13
- case userCancelled
14
- case spotifyNotInstalled
15
- case networkError(message: String, cause: Error)
16
- case tokenSwapFailed(status: Int?, message: String, cause: Error)
17
- case tokenSwapParseError(message: String, cause: Error)
18
- case authError(message: String, cause: Error)
19
- case underlying(message: String, cause: Error)
20
-
21
- var code: String {
22
- switch self {
23
- case .invalidConfiguration: return "INVALID_CONFIG"
24
- case .authInProgress: return "AUTH_IN_PROGRESS"
25
- case .userCancelled: return "USER_CANCELLED"
26
- case .spotifyNotInstalled: return "SPOTIFY_NOT_INSTALLED"
27
- case .networkError: return "NETWORK_ERROR"
28
- case .tokenSwapFailed: return "TOKEN_SWAP_FAILED"
29
- case .tokenSwapParseError: return "TOKEN_SWAP_PARSE_ERROR"
30
- case .authError: return "AUTH_ERROR"
31
- case .underlying: return "UNKNOWN"
32
- }
33
- }
34
-
35
- var message: String {
36
- switch self {
37
- case .invalidConfiguration(let m): return m
38
- case .authInProgress: return "Another authentication request is already in progress"
39
- case .userCancelled: return "Authentication was cancelled by the user"
40
- case .spotifyNotInstalled: return "The Spotify app is not installed on this device"
41
- case .networkError(let message, _): return message
42
- case .tokenSwapFailed(_, let message, _): return message
43
- case .tokenSwapParseError(let message, _): return message
44
- case .authError(let message, _): return message
45
- case .underlying(let message, _): return message
46
- }
47
- }
48
-
49
- /// The original error that caused this failure, if any. Surfaced as the
50
- /// JS-facing exception's `cause` so debugging / Sentry breadcrumbs keep the
51
- /// full chain rather than collapsing into "undefined reason".
52
- var underlyingCause: Error? {
53
- switch self {
54
- case .networkError(_, let cause): return cause
55
- case .tokenSwapFailed(_, _, let cause): return cause
56
- case .tokenSwapParseError(_, let cause): return cause
57
- case .authError(_, let cause): return cause
58
- case .underlying(_, let cause): return cause
59
- default: return nil
60
- }
61
- }
62
- }
63
-
64
- /// `Exception` subclass that projects a `SpotifyError`'s `code` and `message`
65
- /// through `expo-modules-core`'s exception bridge so JS receives the structured
66
- /// code and a meaningful reason — not the default "undefined reason" placeholder.
67
- ///
68
- /// Without this wrapper an `AsyncFunction` rejection collapses to
69
- /// `FunctionCallException` → `cause.reason = "undefined reason"` because the
70
- /// previously-used `GenericException<String>` does not override `reason`.
71
- final class SpotifyAuthException: Exception, @unchecked Sendable {
72
- private let spotifyCode: String
73
- private let spotifyMessage: String
74
-
75
- init(_ error: SpotifyError, file: String = #fileID, line: UInt = #line, function: String = #function) {
76
- self.spotifyCode = error.code
77
- self.spotifyMessage = error.message
78
- super.init(file: file, line: line, function: function)
79
- self.cause = error.underlyingCause
80
- }
81
-
82
- override var code: String { spotifyCode }
83
- override var reason: String { spotifyMessage }
84
- }
85
-
86
6
  /// `actor` ensures `pending` is mutated only on the actor's serial executor;
87
7
  /// no manual locks needed.
88
8
  actor SpotifyAuthCoordinator {
@@ -218,8 +138,11 @@ final class SpotifySessionDelegateBridge: NSObject, SPTSessionManagerDelegate {
218
138
  }
219
139
 
220
140
  func sessionManager(manager _: SPTSessionManager, didFailWith error: Error) {
221
- let mapped: Error = mapSDKError(error)
222
- NSLog("[ExpoSpotifySDK] didFailWithError %@", String(describing: error))
141
+ NSLog("[ExpoSpotifySDK] didFailWithError %@", error.safeLogSummary)
142
+ let mapped = SpotifyAuthErrorMapping.classify(
143
+ error,
144
+ context: .init(tokenSwapConfigured: authContext?.tokenSwapURL != nil)
145
+ )
223
146
  Task { await coordinator?.deliver(.failure(mapped)) }
224
147
  }
225
148
 
@@ -231,246 +154,4 @@ final class SpotifySessionDelegateBridge: NSObject, SPTSessionManagerDelegate {
231
154
  )
232
155
  Task { await coordinator?.deliver(.success(session)) }
233
156
  }
234
-
235
- /// Translate a few well-known SDK error shapes into our taxonomy.
236
- private func mapSDKError(_ error: Error) -> Error {
237
- let nsError = error as NSError
238
- if isUserCancelled(error: nsError) {
239
- NSLog(
240
- "[ExpoSpotifySDK] mapSDKError classified=USER_CANCELLED domain=%@ code=%d detail=%@",
241
- nsError.domain,
242
- nsError.code,
243
- describeError(nsError)
244
- )
245
- return SpotifyError.userCancelled
246
- }
247
-
248
- if let classified = classifyNonCancellationAuthError(nsError) {
249
- return classified
250
- }
251
-
252
- // Keep the original NSError as `cause` so the structured underlying-chain
253
- // is preserved (Sentry, debug breadcrumbs); the rendered string goes to
254
- // `message` so JS callers get a single human-readable line.
255
- let detail = describeError(nsError)
256
- NSLog(
257
- "[ExpoSpotifySDK] mapSDKError classified=UNKNOWN domain=%@ code=%d detail=%@",
258
- nsError.domain,
259
- nsError.code,
260
- detail
261
- )
262
- return SpotifyError.underlying(message: detail, cause: nsError)
263
- }
264
-
265
- private func classifyNonCancellationAuthError(_ error: NSError) -> SpotifyError? {
266
- let detail = describeError(error)
267
- let lower = detail.lowercased()
268
- let tokenSwapConfigured = authContext?.tokenSwapURL != nil
269
-
270
- if isNetworkFailure(error) {
271
- return SpotifyError.networkError(message: "Network error during Spotify authentication: \(detail)", cause: error)
272
- }
273
-
274
- // OAuth-style rejection from Spotify auth endpoints — evaluated before the
275
- // tokenSwapConfigured gate so these are never misclassified as token-swap errors.
276
- if lower.contains("access_denied") || lower.contains("invalid_scope") || lower.contains("invalid_client") ||
277
- lower.contains("authorization") || lower.contains("oauth") || lower.contains("spotify account")
278
- {
279
- return SpotifyError.authError(
280
- message: "Spotify authorization failed: \(detail)",
281
- cause: error
282
- )
283
- }
284
-
285
- if let status = extractHTTPStatusCode(from: detail), status == 401 || status == 403 {
286
- return SpotifyError.authError(
287
- message: "Spotify authorization failed (HTTP \(status)): \(detail)",
288
- cause: error
289
- )
290
- }
291
-
292
- if tokenSwapConfigured || lower.contains("token swap") || lower.contains("token endpoint") || lower.contains("token exchange") {
293
- if let status = extractHTTPStatusCode(from: detail) {
294
- return SpotifyError.tokenSwapFailed(
295
- status: status,
296
- message: "Token swap server returned HTTP \(status): \(detail)",
297
- cause: error
298
- )
299
- }
300
- if lower.contains("parse") || lower.contains("json") || lower.contains("decode") || lower.contains("malformed") {
301
- return SpotifyError.tokenSwapParseError(
302
- message: "Token swap response was invalid: \(detail)",
303
- cause: error
304
- )
305
- }
306
- return SpotifyError.tokenSwapFailed(
307
- status: nil,
308
- message: "Token swap failed: \(detail)",
309
- cause: error
310
- )
311
- }
312
-
313
- return nil
314
- }
315
-
316
- /// Detect user cancellation from the full NSError chain rather than relying
317
- /// on a single wrapper domain/code pair.
318
- private func isUserCancelled(error: NSError) -> Bool {
319
- var visited = Set<ObjectIdentifier>()
320
- var stack: [NSError] = [error]
321
-
322
- while let current = stack.popLast() {
323
- let id = ObjectIdentifier(current)
324
- guard visited.insert(id).inserted else { continue }
325
-
326
- if isKnownCancellationCode(current) || messageLooksCancelled(current) {
327
- return true
328
- }
329
-
330
- if let underlying = current.userInfo[NSUnderlyingErrorKey] as? NSError {
331
- stack.append(underlying)
332
- }
333
- }
334
-
335
- return false
336
- }
337
-
338
- private func isKnownCancellationCode(_ error: NSError) -> Bool {
339
- if error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled {
340
- return true
341
- }
342
-
343
- if #available(iOS 12.0, *),
344
- error.domain == ASWebAuthenticationSessionErrorDomain,
345
- error.code == ASWebAuthenticationSessionError.canceledLogin.rawValue
346
- {
347
- return true
348
- }
349
-
350
- if error.domain == "SFAuthenticationErrorDomain" && error.code == 1 {
351
- return true
352
- }
353
-
354
- return false
355
- }
356
-
357
- private func isNetworkFailure(_ error: NSError) -> Bool {
358
- var visited = Set<ObjectIdentifier>()
359
- var stack: [NSError] = [error]
360
-
361
- while let current = stack.popLast() {
362
- let id = ObjectIdentifier(current)
363
- guard visited.insert(id).inserted else { continue }
364
-
365
- if current.domain == NSURLErrorDomain,
366
- current.code != NSURLErrorCancelled
367
- {
368
- return true
369
- }
370
-
371
- if let underlying = current.userInfo[NSUnderlyingErrorKey] as? NSError {
372
- stack.append(underlying)
373
- }
374
- }
375
-
376
- return false
377
- }
378
-
379
- private func messageLooksCancelled(_ error: NSError) -> Bool {
380
- // Only apply fuzzy text matching in auth/browser domains to avoid mapping
381
- // unrelated transport or backend failures into USER_CANCELLED.
382
- guard isLikelyAuthCancellationDomain(error.domain) else {
383
- return false
384
- }
385
-
386
- let cancelKeywords = ["cancel", "canceled", "cancelled"]
387
- let negativeKeywords = [
388
- "timed out",
389
- "timeout",
390
- "network",
391
- "offline",
392
- "unreachable",
393
- "server",
394
- "unauthorized",
395
- "forbidden",
396
- "invalid"
397
- ]
398
- let messageParts = [
399
- error.localizedDescription,
400
- error.localizedFailureReason ?? "",
401
- error.userInfo[NSLocalizedDescriptionKey] as? String ?? "",
402
- error.userInfo[NSLocalizedFailureReasonErrorKey] as? String ?? ""
403
- ]
404
- let combined = messageParts
405
- .joined(separator: " ")
406
- .lowercased()
407
-
408
- if negativeKeywords.contains(where: { combined.contains($0) }) {
409
- return false
410
- }
411
-
412
- return cancelKeywords.contains(where: { combined.contains($0) })
413
- }
414
-
415
- private func isLikelyAuthCancellationDomain(_ domain: String) -> Bool {
416
- if domain == ASWebAuthenticationSessionErrorDomain || domain == "SFAuthenticationErrorDomain" {
417
- return true
418
- }
419
-
420
- // SPT wrappers commonly bubble auth-web errors under their own namespace.
421
- if domain.lowercased().contains("spotify") && domain.lowercased().contains("auth") {
422
- return true
423
- }
424
-
425
- return false
426
- }
427
-
428
- /// Build a diagnostic string from an NSError that includes the domain,
429
- /// code, localized description, and the full chain of underlying errors.
430
- /// Used because `SPTError` (and many `URLSession` errors it wraps) often
431
- /// have an empty `localizedDescription`, surfacing as "undefined reason"
432
- /// in JS without this expansion.
433
- private func describeError(_ error: NSError) -> String {
434
- var parts: [String] = []
435
- parts.append("\(error.domain) code \(error.code)")
436
- let desc = error.localizedDescription
437
- if !desc.isEmpty {
438
- parts.append("\"\(desc)\"")
439
- }
440
- var ui = error.userInfo
441
- ui.removeValue(forKey: NSUnderlyingErrorKey)
442
- ui.removeValue(forKey: NSLocalizedDescriptionKey)
443
- if !ui.isEmpty {
444
- parts.append("userInfo=\(ui)")
445
- }
446
- if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError {
447
- parts.append("→ underlying: \(describeError(underlying))")
448
- }
449
- return parts.joined(separator: " ")
450
- }
451
-
452
- private func extractHTTPStatusCode(from message: String) -> Int? {
453
- let patterns = [
454
- #"http\s+([1-5][0-9]{2})"#,
455
- #"status(?:\s+code)?[:=\s]+([1-5][0-9]{2})"#
456
- ]
457
-
458
- for pattern in patterns {
459
- guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
460
- continue
461
- }
462
- let range = NSRange(message.startIndex..<message.endIndex, in: message)
463
- guard let match = regex.firstMatch(in: message, options: [], range: range),
464
- match.numberOfRanges > 1,
465
- let codeRange = Range(match.range(at: 1), in: message)
466
- else {
467
- continue
468
- }
469
- if let code = Int(message[codeRange]) {
470
- return code
471
- }
472
- }
473
-
474
- return nil
475
- }
476
157
  }
@@ -0,0 +1,257 @@
1
+ import AuthenticationServices
2
+ import Foundation
3
+
4
+ /// Maps a raw authentication `NSError` into the structured `SpotifyError`
5
+ /// taxonomy.
6
+ ///
7
+ /// This is the **single** canonical entry point for auth-error classification.
8
+ /// Both the `SPTSessionManager` delegate failure callback and the module-level
9
+ /// top-level `catch` route through `classify(_:context:)`, so a given raw error
10
+ /// yields the same `SpotifyError` regardless of which path delivers it. (The
11
+ /// Spotify iOS SDK does not always route cancellations through the delegate, so
12
+ /// the same error can arrive via either path.)
13
+ ///
14
+ /// Keep aligned with [docs/auth-error-mapping.md](../docs/auth-error-mapping.md).
15
+ enum SpotifyAuthErrorMapping {
16
+ /// Signals the classifier needs that aren't derivable from the error itself.
17
+ /// `tokenSwapConfigured` lets ambiguous SDK failures be treated as
18
+ /// token-swap-class errors when the caller supplied a token swap URL.
19
+ struct Context {
20
+ var tokenSwapConfigured: Bool = false
21
+ }
22
+
23
+ /// Walk the error once, then apply rules in priority order: typed/structural
24
+ /// signals first (cancellation domain/code, URL transport errors, parsed HTTP
25
+ /// status), then best-effort message-text heuristics, then `UNKNOWN`.
26
+ static func classify(_ error: Error, context: Context = Context()) -> SpotifyError {
27
+ let chain = nsErrorChain(error)
28
+ let root = chain[0]
29
+ // `detail` keeps raw `userInfo` for the in-process classification heuristics
30
+ // only; `redactedDetail` (keys-only `userInfo`) is the sole variant that
31
+ // leaves this process via NSLog or JS-visible error messages.
32
+ let detail = describeError(root)
33
+ let redactedDetail = describeError(root, redactUserInfo: true)
34
+ let result = classify(
35
+ chain: chain,
36
+ root: root,
37
+ detail: detail,
38
+ redactedDetail: redactedDetail,
39
+ context: context
40
+ )
41
+
42
+ NSLog(
43
+ "[ExpoSpotifySDK] classifyAuthError classified=%@ domain=%@ code=%d detail=%@",
44
+ result.code,
45
+ root.domain,
46
+ root.code,
47
+ redactedDetail
48
+ )
49
+ return result
50
+ }
51
+
52
+ private static func classify(
53
+ chain: [NSError],
54
+ root: NSError,
55
+ detail: String,
56
+ redactedDetail: String,
57
+ context: Context
58
+ ) -> SpotifyError {
59
+ // 1. User cancellation — a typed domain/code anywhere in the chain, or
60
+ // best-effort cancel text within a known auth/browser domain.
61
+ if chain.contains(where: { isKnownCancellationCode($0) || messageLooksCancelled($0) }) {
62
+ return .userCancelled
63
+ }
64
+
65
+ // 2. Transport failure anywhere in the chain (cancellation already handled).
66
+ if chain.contains(where: { $0.domain == NSURLErrorDomain && $0.code != NSURLErrorCancelled }) {
67
+ return .networkError(
68
+ message: "Network error during Spotify authentication: \(redactedDetail)",
69
+ cause: root
70
+ )
71
+ }
72
+
73
+ let lower = detail.lowercased()
74
+
75
+ // 3. OAuth-style rejection text — evaluated before the token-swap gate so
76
+ // these are never misclassified as token-swap errors.
77
+ if lower.contains("access_denied") || lower.contains("invalid_scope") ||
78
+ lower.contains("invalid_client") || lower.contains("authorization") ||
79
+ lower.contains("oauth") || lower.contains("spotify account") {
80
+ return .authError(message: "Spotify authorization failed: \(redactedDetail)", cause: root)
81
+ }
82
+
83
+ if let status = extractHTTPStatusCode(from: detail), status == 401 || status == 403 {
84
+ return .authError(
85
+ message: "Spotify authorization failed (HTTP \(status)): \(redactedDetail)",
86
+ cause: root
87
+ )
88
+ }
89
+
90
+ // 4. Token-swap-class failure — when a swap URL was configured, or the text
91
+ // explicitly references the token endpoint.
92
+ if context.tokenSwapConfigured || lower.contains("token swap") ||
93
+ lower.contains("token endpoint") || lower.contains("token exchange") {
94
+ if let status = extractHTTPStatusCode(from: detail) {
95
+ return .tokenSwapFailed(
96
+ status: status,
97
+ message: "Token swap server returned HTTP \(status): \(redactedDetail)",
98
+ cause: root
99
+ )
100
+ }
101
+ if lower.contains("parse") || lower.contains("json") ||
102
+ lower.contains("decode") || lower.contains("malformed") {
103
+ return .tokenSwapParseError(
104
+ message: "Token swap response was invalid: \(redactedDetail)",
105
+ cause: root
106
+ )
107
+ }
108
+ return .tokenSwapFailed(status: nil, message: "Token swap failed: \(redactedDetail)", cause: root)
109
+ }
110
+
111
+ // 5. Fallback. Keep the original NSError as `cause` so the structured
112
+ // underlying-chain is preserved (Sentry, debug breadcrumbs).
113
+ return .underlying(message: redactedDetail, cause: root)
114
+ }
115
+
116
+ // MARK: — Chain traversal
117
+
118
+ /// The error plus its `NSUnderlyingErrorKey` ancestry, root first. The chain
119
+ /// is effectively a singly-linked list; the `seen` set only guards against a
120
+ /// pathological cycle. Every rule above consumes this single walk instead of
121
+ /// re-implementing the traversal.
122
+ private static func nsErrorChain(_ error: Error) -> [NSError] {
123
+ var chain: [NSError] = []
124
+ var seen = Set<ObjectIdentifier>()
125
+ var current: NSError? = error as NSError
126
+ while let node = current, seen.insert(ObjectIdentifier(node)).inserted {
127
+ chain.append(node)
128
+ current = node.userInfo[NSUnderlyingErrorKey] as? NSError
129
+ }
130
+ return chain
131
+ }
132
+
133
+ // MARK: — Typed cancellation signals (locale-independent)
134
+
135
+ /// Recognise a single error's well-known cancellation domain/code. These are
136
+ /// stable identifiers and integer codes, so they hold across every locale.
137
+ private static func isKnownCancellationCode(_ error: NSError) -> Bool {
138
+ if error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled {
139
+ return true
140
+ }
141
+
142
+ if #available(iOS 12.0, *),
143
+ error.domain == ASWebAuthenticationSessionErrorDomain,
144
+ error.code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
145
+ return true
146
+ }
147
+
148
+ if error.domain == "SFAuthenticationErrorDomain" && error.code == 1 {
149
+ return true
150
+ }
151
+
152
+ // The Spotify iOS auth/login SDK reports user cancellation under its own
153
+ // login domain with code 1. This domain contains "spotify" but not "auth",
154
+ // so the fuzzy text matching below would not catch it either.
155
+ if error.domain == "com.spotify.sdk.login" && error.code == 1 {
156
+ return true
157
+ }
158
+
159
+ return false
160
+ }
161
+
162
+ // MARK: — Best-effort text heuristics (locale-dependent fallback)
163
+
164
+ /// Last-resort cancellation detection for errors that lack a recognised
165
+ /// domain/code. Matches localized message text, so it is English-biased by
166
+ /// nature — kept deliberately narrow (auth/browser domains only) so unrelated
167
+ /// transport/backend failures are never mapped to `USER_CANCELLED`.
168
+ private static func messageLooksCancelled(_ error: NSError) -> Bool {
169
+ guard isLikelyAuthCancellationDomain(error.domain) else {
170
+ return false
171
+ }
172
+
173
+ let cancelKeywords = ["cancel", "canceled", "cancelled"]
174
+ let negativeKeywords = [
175
+ "timed out", "timeout", "network", "offline", "unreachable",
176
+ "server", "unauthorized", "forbidden", "invalid"
177
+ ]
178
+ let combined = [
179
+ error.safeLocalizedDescription,
180
+ error.safeLocalizedFailureReason ?? ""
181
+ ].joined(separator: " ").lowercased()
182
+
183
+ if negativeKeywords.contains(where: { combined.contains($0) }) {
184
+ return false
185
+ }
186
+ return cancelKeywords.contains(where: { combined.contains($0) })
187
+ }
188
+
189
+ private static func isLikelyAuthCancellationDomain(_ domain: String) -> Bool {
190
+ if domain == ASWebAuthenticationSessionErrorDomain || domain == "SFAuthenticationErrorDomain" {
191
+ return true
192
+ }
193
+ // SPT wrappers commonly bubble auth-web errors under their own namespace.
194
+ let lower = domain.lowercased()
195
+ return lower.contains("spotify") && lower.contains("auth")
196
+ }
197
+
198
+ // MARK: — Diagnostics
199
+
200
+ /// Build a diagnostic string from an NSError that includes the domain, code,
201
+ /// localized description, and the full chain of underlying errors. Needed
202
+ /// because `SPTError` (and many `URLSession` errors it wraps) often have an
203
+ /// empty `localizedDescription`, surfacing as "undefined reason" in JS
204
+ /// without this expansion.
205
+ ///
206
+ /// When `redactUserInfo` is `true`, arbitrary `userInfo` *values* are dropped
207
+ /// in favour of a sorted key list. `userInfo` can carry secrets (tokens,
208
+ /// auth codes, raw request/response bodies), so any string that is logged or
209
+ /// returned to JS must use the redacted form; the unredacted form is for the
210
+ /// in-process classification heuristics only.
211
+ private static func describeError(_ error: NSError, redactUserInfo: Bool = false) -> String {
212
+ var parts: [String] = ["\(error.domain) code \(error.code)"]
213
+ let desc = error.safeLocalizedDescription
214
+ if !desc.isEmpty {
215
+ parts.append("\"\(desc)\"")
216
+ }
217
+ var ui = error.userInfo
218
+ ui.removeValue(forKey: NSUnderlyingErrorKey)
219
+ ui.removeValue(forKey: NSLocalizedDescriptionKey)
220
+ if !ui.isEmpty {
221
+ if redactUserInfo {
222
+ let keys = ui.keys.sorted().joined(separator: ", ")
223
+ parts.append("userInfoKeys=[\(keys)]")
224
+ } else {
225
+ parts.append("userInfo=\(ui)")
226
+ }
227
+ }
228
+ if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError {
229
+ parts.append("→ underlying: \(describeError(underlying, redactUserInfo: redactUserInfo))")
230
+ }
231
+ return parts.joined(separator: " ")
232
+ }
233
+
234
+ private static func extractHTTPStatusCode(from message: String) -> Int? {
235
+ let patterns = [
236
+ #"http\s+([1-5][0-9]{2})"#,
237
+ #"status(?:\s+code)?[:=\s]+([1-5][0-9]{2})"#
238
+ ]
239
+
240
+ for pattern in patterns {
241
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
242
+ continue
243
+ }
244
+ let range = NSRange(message.startIndex..<message.endIndex, in: message)
245
+ guard let match = regex.firstMatch(in: message, options: [], range: range),
246
+ match.numberOfRanges > 1,
247
+ let codeRange = Range(match.range(at: 1), in: message)
248
+ else {
249
+ continue
250
+ }
251
+ if let code = Int(message[codeRange]) {
252
+ return code
253
+ }
254
+ }
255
+ return nil
256
+ }
257
+ }
@@ -0,0 +1,82 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ /// Public, structured errors thrown by the auth coordinator. Each case carries
5
+ /// the JS-facing error code in `code`, mirroring the Android `CodedException`
6
+ /// taxonomy. Raw `NSError`s are turned into these by
7
+ /// `SpotifyAuthErrorMapping.classify(_:context:)`.
8
+ enum SpotifyError: Error {
9
+ case invalidConfiguration(String)
10
+ case authInProgress
11
+ case userCancelled
12
+ case spotifyNotInstalled
13
+ case networkError(message: String, cause: Error)
14
+ case tokenSwapFailed(status: Int?, message: String, cause: Error)
15
+ case tokenSwapParseError(message: String, cause: Error)
16
+ case authError(message: String, cause: Error)
17
+ case underlying(message: String, cause: Error)
18
+
19
+ var code: String {
20
+ switch self {
21
+ case .invalidConfiguration: return "INVALID_CONFIG"
22
+ case .authInProgress: return "AUTH_IN_PROGRESS"
23
+ case .userCancelled: return "USER_CANCELLED"
24
+ case .spotifyNotInstalled: return "SPOTIFY_NOT_INSTALLED"
25
+ case .networkError: return "NETWORK_ERROR"
26
+ case .tokenSwapFailed: return "TOKEN_SWAP_FAILED"
27
+ case .tokenSwapParseError: return "TOKEN_SWAP_PARSE_ERROR"
28
+ case .authError: return "AUTH_ERROR"
29
+ case .underlying: return "UNKNOWN"
30
+ }
31
+ }
32
+
33
+ var message: String {
34
+ switch self {
35
+ case .invalidConfiguration(let m): return m
36
+ case .authInProgress: return "Another authentication request is already in progress"
37
+ case .userCancelled: return "Authentication was cancelled by the user"
38
+ case .spotifyNotInstalled: return "The Spotify app is not installed on this device"
39
+ case .networkError(let message, _): return message
40
+ case .tokenSwapFailed(_, let message, _): return message
41
+ case .tokenSwapParseError(let message, _): return message
42
+ case .authError(let message, _): return message
43
+ case .underlying(let message, _): return message
44
+ }
45
+ }
46
+
47
+ /// The original error that caused this failure, if any. Surfaced as the
48
+ /// JS-facing exception's `cause` so debugging / Sentry breadcrumbs keep the
49
+ /// full chain rather than collapsing into "undefined reason".
50
+ var underlyingCause: Error? {
51
+ switch self {
52
+ case .networkError(_, let cause): return cause
53
+ case .tokenSwapFailed(_, _, let cause): return cause
54
+ case .tokenSwapParseError(_, let cause): return cause
55
+ case .authError(_, let cause): return cause
56
+ case .underlying(_, let cause): return cause
57
+ default: return nil
58
+ }
59
+ }
60
+ }
61
+
62
+ /// `Exception` subclass that projects a `SpotifyError`'s `code` and `message`
63
+ /// through `expo-modules-core`'s exception bridge so JS receives the structured
64
+ /// code and a meaningful reason — not the default "undefined reason" placeholder.
65
+ ///
66
+ /// Without this wrapper an `AsyncFunction` rejection collapses to
67
+ /// `FunctionCallException` → `cause.reason = "undefined reason"` because the
68
+ /// previously-used `GenericException<String>` does not override `reason`.
69
+ final class SpotifyAuthException: Exception, @unchecked Sendable {
70
+ private let spotifyCode: String
71
+ private let spotifyMessage: String
72
+
73
+ init(_ error: SpotifyError, file: String = #fileID, line: UInt = #line, function: String = #function) {
74
+ self.spotifyCode = error.code
75
+ self.spotifyMessage = error.message
76
+ super.init(file: file, line: line, function: function)
77
+ self.cause = error.underlyingCause
78
+ }
79
+
80
+ override var code: String { spotifyCode }
81
+ override var reason: String { spotifyMessage }
82
+ }