From b956c576527288647932b7c482d0e3051d1433e1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 26 Jul 2026 01:15:22 +0700 Subject: [PATCH] refactor(ios): isolate and test extension callbacks --- ios/Runner.xcodeproj/project.pbxproj | 4 ++ ios/Runner/AppDelegate.swift | 53 ++++++++------------ ios/Runner/ExtensionCallbackParser.swift | 53 ++++++++++++++++++++ ios/RunnerTests/RunnerTests.swift | 61 +++++++++++++++++++++--- 4 files changed, 132 insertions(+), 39 deletions(-) create mode 100644 ios/Runner/ExtensionCallbackParser.swift diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 117792d9..1fc3fd47 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + A11CE0012F00000000000001 /* ExtensionCallbackParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11CE0022F00000000000001 /* ExtensionCallbackParser.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -47,6 +48,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + A11CE0022F00000000000001 /* ExtensionCallbackParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionCallbackParser.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -116,6 +118,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + A11CE0022F00000000000001 /* ExtensionCallbackParser.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -269,6 +272,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + A11CE0012F00000000000001 /* ExtensionCallbackParser.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 241c633c..2b3be14e 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -107,44 +107,29 @@ import Gobackend /// - Signed session: spotiflac://session-grant?grant=...&state= @discardableResult private func handleExtensionOAuthRedirect(url: URL) -> Bool { - guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false } - let host = (url.host ?? "").lowercased() - let path = url.path.lowercased() - let isSessionGrant = host == "session-grant" - let ok = - isSessionGrant || host == "callback" || host == "spotify-callback" || path.contains("callback") - guard ok else { return false } - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return false - } - let q = components.queryItems ?? [] - let code = - q.first { $0.name == (isSessionGrant ? "grant" : "code") }?.value?.trimmingCharacters( - in: .whitespacesAndNewlines) ?? - q.first { $0.name == "code" }?.value?.trimmingCharacters( - in: .whitespacesAndNewlines) ?? "" - let state = - q.first { $0.name == "state" }?.value?.trimmingCharacters( - in: .whitespacesAndNewlines) ?? "" - if code.isEmpty { return false } - if state.isEmpty { - NSLog("SpotiFLAC: Extension OAuth redirect missing state (extension id)") - return false - } + guard let route = ExtensionCallbackParser.parse(url) else { return false } streamQueue.async { var err: NSError? var response: String? - if isSessionGrant { - GobackendSetExtensionSessionGrantByID(state, code) - response = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err) + if route.isSessionGrant { + GobackendSetExtensionSessionGrantByID(route.extensionId, route.code) + response = GobackendInvokeExtensionActionJSON( + route.extensionId, + "completeGrant", + &err + ) } else { - GobackendSetExtensionAuthCodeByID(state, code) - response = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err) + GobackendSetExtensionAuthCodeByID(route.extensionId, route.code) + response = GobackendInvokeExtensionActionJSON( + route.extensionId, + "completeSpotifyLogin", + &err + ) } - if err == nil && isSessionGrant { + if err == nil && route.isSessionGrant { do { try self.requireSuccessfulExtensionAction( - extensionId: state, + extensionId: route.extensionId, actionName: "completeGrant", response: response ) @@ -155,9 +140,11 @@ import Gobackend if let err = err { NSLog( "SpotiFLAC: Extension callback complete failed: \(err.localizedDescription)") - } else if isSessionGrant { + } else if route.isSessionGrant { DispatchQueue.main.async { [weak self] in - self?.notifySessionGrantCompleted(extensionId: state) + self?.notifySessionGrantCompleted( + extensionId: route.extensionId + ) } } } diff --git a/ios/Runner/ExtensionCallbackParser.swift b/ios/Runner/ExtensionCallbackParser.swift new file mode 100644 index 00000000..11e06e0e --- /dev/null +++ b/ios/Runner/ExtensionCallbackParser.swift @@ -0,0 +1,53 @@ +import Foundation + +struct ExtensionCallbackRoute: Equatable { + let code: String + let extensionId: String + let isSessionGrant: Bool +} + +enum ExtensionCallbackParser { + static func parse(_ url: URL) -> ExtensionCallbackRoute? { + guard url.scheme?.lowercased() == "spotiflac" else { + return nil + } + + let host = (url.host ?? "").lowercased() + let path = url.path.lowercased() + let isSessionGrant = host == "session-grant" + let isSupportedCallback = + isSessionGrant || + host == "callback" || + host == "spotify-callback" || + path.contains("callback") + guard isSupportedCallback, + let components = URLComponents( + url: url, + resolvingAgainstBaseURL: false + ) else { + return nil + } + + let queryItems = components.queryItems ?? [] + let primaryCodeKey = isSessionGrant ? "grant" : "code" + let code = + queryItems.first { $0.name == primaryCodeKey }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? queryItems.first { $0.name == "code" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? "" + let extensionId = + queryItems.first { $0.name == "state" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? "" + + guard !code.isEmpty, !extensionId.isEmpty else { + return nil + } + return ExtensionCallbackRoute( + code: code, + extensionId: extensionId, + isSessionGrant: isSessionGrant + ) + } +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 86a7c3b1..6822204a 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,61 @@ -import Flutter -import UIKit +import Foundation import XCTest +@testable import Runner class RunnerTests: XCTestCase { + func testParsesOAuthCallback() { + let route = ExtensionCallbackParser.parse( + URL(string: "spotiflac://callback?code=auth-code&state=spotify-web")! + ) - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } + XCTAssertEqual( + route, + ExtensionCallbackRoute( + code: "auth-code", + extensionId: "spotify-web", + isSessionGrant: false + ) + ) + } + func testParsesSignedSessionGrant() { + let route = ExtensionCallbackParser.parse( + URL( + string: + "spotiflac://session-grant?grant=session-token&state=provider" + )! + ) + + XCTAssertEqual( + route, + ExtensionCallbackRoute( + code: "session-token", + extensionId: "provider", + isSessionGrant: true + ) + ) + } + + func testRejectsUntrustedOrIncompleteCallbacks() { + XCTAssertNil( + ExtensionCallbackParser.parse( + URL(string: "https://callback?code=auth&state=provider")! + ) + ) + XCTAssertNil( + ExtensionCallbackParser.parse( + URL(string: "spotiflac://unknown?code=auth&state=provider")! + ) + ) + XCTAssertNil( + ExtensionCallbackParser.parse( + URL(string: "spotiflac://callback?code=auth")! + ) + ) + XCTAssertNil( + ExtensionCallbackParser.parse( + URL(string: "spotiflac://callback?state=provider")! + ) + ) + } }