refactor(ios): isolate and test extension callbacks

This commit is contained in:
zarzet
2026-07-26 01:15:22 +07:00
parent fbe0674b04
commit b956c57652
4 changed files with 132 additions and 39 deletions
+4
View File
@@ -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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
A11CE0022F00000000000001 /* ExtensionCallbackParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionCallbackParser.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
@@ -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;
+20 -33
View File
@@ -107,44 +107,29 @@ import Gobackend
/// - Signed session: spotiflac://session-grant?grant=...&state=<extension_id>
@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
)
}
}
}
+53
View File
@@ -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
)
}
}
+55 -6
View File
@@ -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")!
)
)
}
}