@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.
- package/README.md +36 -0
- package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +1 -1
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +7 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyTokenSwapClient.kt +30 -2
- package/build/auth/error.d.ts +1 -1
- package/build/auth/error.d.ts.map +1 -1
- package/build/auth/error.js.map +1 -1
- package/build/auth/index.js +1 -1
- package/build/auth/index.js.map +1 -1
- package/build/internal/native-errors.d.ts +0 -2
- package/build/internal/native-errors.d.ts.map +1 -1
- package/build/internal/native-errors.js +5 -8
- package/build/internal/native-errors.js.map +1 -1
- package/ios/ExpoSpotifySDKModule.swift +26 -15
- package/ios/NSError+SafeLocalizedDescription.swift +40 -0
- package/ios/SpotifyAppRemoteConnection.swift +2 -2
- package/ios/SpotifyAppRemoteCoordinator.swift +1 -1
- package/ios/SpotifyAppRemoteErrorMapping.swift +28 -24
- package/ios/SpotifyAuthCoordinator.swift +5 -324
- package/ios/SpotifyAuthErrorMapping.swift +257 -0
- package/ios/SpotifyError.swift +82 -0
- package/ios/SpotifyTokenRefreshClient.swift +19 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
222
|
-
|
|
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
|
+
}
|