Files
SpotiFLAC-Mobile/ios/Runner/AppDelegate.swift
T
zarzet b32bdbc291 perf(metadata): read complete SAF tags through descriptors
Add complete metadata reads with a display-name hint in Go and both native bridges. Read seekable SAF descriptors directly and use temporary copies only when direct reading fails.

Add format parity tests for MP3, FLAC, M4A, and WAV plus a Dart bridge hint regression.
2026-09-06 14:04:04 +07:00

1440 lines
58 KiB
Swift

import AuthenticationServices
import Flutter
import UIKit
import UniformTypeIdentifiers
import Gobackend
@main
@objc class AppDelegate: FlutterAppDelegate {
private let CHANNEL = "com.zarz.spotiflac/backend"
private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream"
private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream"
private let LARGE_JSON_RESULT_FILE_KEY = "__json_file"
private let LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
private let streamQueue = DispatchQueue(label: "com.zarz.spotiflac.progress_stream", qos: .utility)
private let downloadProgressSubscription = DownloadProgressSubscription { sequence, timeout in
GobackendWaitForAllDownloadProgressDelta(sequence, timeout) as String? ?? ""
}
private var libraryScanProgressTimer: DispatchSourceTimer?
private var libraryScanProgressEventSink: FlutterEventSink?
private var lastLibraryScanProgressPayload: String?
private var libraryScanProgressGeneration: UInt64 = 0
private var backendChannel: FlutterMethodChannel?
private var pendingSessionGrantEvents: [[String: Any]] = []
private let securityScopedAccessLock = NSLock()
private var securityScopedAccesses: [String: URL] = [:]
/// Pending Flutter result for the native folder picker
private var pendingDirectoryPickerResult: FlutterResult?
/// Whether a download queue is active; while true a background assertion
/// is acquired before work starts and renewed on later background entries.
/// Main-thread only.
private var downloadsActive = false
private var downloadBackgroundTask: UIBackgroundTaskIdentifier = .invalid
/// Strong reference to the in-flight ASWebAuthenticationSession; the
/// session is deallocated (and its sheet dismissed) without it.
private var activeWebAuthSession: AnyObject?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
GobackendSetAppVersion(version)
}
let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(
name: CHANNEL,
binaryMessenger: controller.binaryMessenger
)
backendChannel = channel
if !pendingSessionGrantEvents.isEmpty {
let events = pendingSessionGrantEvents
pendingSessionGrantEvents.removeAll()
for event in events {
channel.invokeMethod("extensionSessionGrantCompleted", arguments: event)
}
}
let downloadProgressEvents = FlutterEventChannel(
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
binaryMessenger: controller.binaryMessenger
)
let libraryScanProgressEvents = FlutterEventChannel(
name: LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL,
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { [weak self] call, result in
self?.handleMethodCall(call: call, result: result)
}
downloadProgressEvents.setStreamHandler(
ClosureStreamHandler(
onListen: { [weak self] _, events in
self?.startDownloadProgressStream(events)
return nil
},
onCancel: { [weak self] _ in
self?.stopDownloadProgressStream()
return nil
}
)
)
libraryScanProgressEvents.setStreamHandler(
ClosureStreamHandler(
onListen: { [weak self] _, events in
self?.startLibraryScanProgressStream(events)
return nil
},
onCancel: { [weak self] _ in
self?.stopLibraryScanProgressStream()
return nil
}
)
)
GeneratedPluginRegistrant.register(with: self)
if let url = launchOptions?[.url] as? URL {
_ = handleExtensionOAuthRedirect(url: url)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// Extension return URLs:
/// - OAuth: spotiflac://callback?code=...&state=<one_time_nonce>
/// - Signed session: spotiflac://session-grant?grant=...&state=<one_time_nonce>
@discardableResult
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
guard let route = ExtensionCallbackParser.parse(url) else { return false }
streamQueue.async {
var err: NSError?
var response: String?
let extensionId: String
if route.isSessionGrant {
extensionId = GobackendResolveExtensionCallbackState(
route.state,
&err
)
} else {
extensionId = GobackendConsumeExtensionCallbackState(
route.state,
&err
)
}
guard err == nil, !extensionId.isEmpty else {
NSLog("SpotiFLAC Mobile: Rejected invalid or expired extension callback")
return
}
if route.isSessionGrant {
GobackendSetExtensionSessionGrantByID(extensionId, route.code)
response = GobackendInvokeExtensionActionJSON(
extensionId,
"completeGrant",
&err
)
} else {
GobackendSetExtensionAuthCodeByID(extensionId, route.code)
response = GobackendInvokeExtensionActionJSON(
extensionId,
"completeSpotifyLogin",
&err
)
}
if err == nil && route.isSessionGrant {
do {
try self.requireSuccessfulExtensionAction(
extensionId: extensionId,
actionName: "completeGrant",
response: response
)
} catch {
err = error as NSError
}
}
if let err = err {
NSLog(
"SpotiFLAC Mobile: Extension callback failed (code \(err.code))")
} else if route.isSessionGrant {
DispatchQueue.main.async { [weak self] in
self?.notifySessionGrantCompleted(
extensionId: extensionId
)
}
}
}
return true
}
private func requireSuccessfulExtensionAction(
extensionId: String,
actionName: String,
response: String?
) throws {
let text = response ?? ""
guard let data = text.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NSError(
domain: "SpotiFLAC",
code: 1,
userInfo: [
NSLocalizedDescriptionKey:
"Extension \(actionName) for \(extensionId) returned invalid JSON: \(String(text.prefix(240)))"
]
)
}
if (obj["success"] as? Bool) == true {
return
}
let error =
(obj["error"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ??
(obj["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ??
String(text.prefix(240))
throw NSError(
domain: "SpotiFLAC",
code: 2,
userInfo: [
NSLocalizedDescriptionKey: "Extension \(actionName) failed for \(extensionId): \(error)"
]
)
}
private func notifySessionGrantCompleted(extensionId: String) {
let payload: [String: Any] = [
"extension_id": extensionId,
"success": true,
]
if let channel = backendChannel {
channel.invokeMethod("extensionSessionGrantCompleted", arguments: payload)
} else {
pendingSessionGrantEvents.append(payload)
}
}
override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if handleExtensionOAuthRedirect(url: url) {
return true
}
return super.application(app, open: url, options: options)
}
deinit {
stopDownloadProgressStream()
stopLibraryScanProgressStream()
}
private func startDownloadProgressStream(_ eventSink: @escaping FlutterEventSink) {
downloadProgressSubscription.start(eventSink)
}
private func stopDownloadProgressStream() {
downloadProgressSubscription.stop()
}
private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) {
stopLibraryScanProgressStream()
libraryScanProgressGeneration &+= 1
let generation = libraryScanProgressGeneration
libraryScanProgressEventSink = eventSink
lastLibraryScanProgressPayload = nil
let timer = DispatchSource.makeTimerSource(queue: streamQueue)
timer.schedule(deadline: .now(), repeating: .milliseconds(800))
timer.setEventHandler { [weak self] in
guard let self, self.libraryScanProgressGeneration == generation else { return }
let payload = GobackendGetLibraryScanProgressJSON() as String? ?? "{}"
if payload == self.lastLibraryScanProgressPayload {
return
}
self.lastLibraryScanProgressPayload = payload
DispatchQueue.main.async { [weak self] in
guard let self, self.libraryScanProgressGeneration == generation else { return }
eventSink(self.parseJsonPayload(payload))
}
}
libraryScanProgressTimer = timer
timer.resume()
}
private func stopLibraryScanProgressStream() {
libraryScanProgressGeneration &+= 1
libraryScanProgressTimer?.setEventHandler {}
libraryScanProgressTimer?.cancel()
libraryScanProgressTimer = nil
libraryScanProgressEventSink = nil
lastLibraryScanProgressPayload = nil
}
private func parseJsonPayload(_ payload: String) -> Any {
guard let data = payload.data(using: .utf8) else {
return payload
}
do {
return try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
} catch {
return payload
}
}
private func bridgeJsonResult(_ payload: String) -> Any {
if payload.utf8.count < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES {
return payload
}
do {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("bridge_json_\(UUID().uuidString).json")
try payload.write(to: url, atomically: true, encoding: .utf8)
return [LARGE_JSON_RESULT_FILE_KEY: url.path]
} catch {
NSLog("SpotiFLAC: failed to spill large bridge JSON result to file: \(error.localizedDescription)")
return payload
}
}
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "beginBackgroundDownloadTask":
downloadsActive = true
// Request the assertion before the long-running Go call starts.
// Waiting for applicationDidEnterBackground is too late: iOS may
// suspend the process before the assertion is granted.
beginBackgroundDownloadTask()
result(nil)
return
case "endBackgroundDownloadTask":
downloadsActive = false
endBackgroundDownloadTask()
result(nil)
return
case "pickIosDirectory":
pickIosDirectory(result: result)
return
case "startWebAuthSession":
let args = call.arguments as? [String: Any] ?? [:]
let urlString = (args["url"] as? String) ?? ""
let callbackScheme = (args["callback_scheme"] as? String) ?? "spotiflac"
startWebAuthSession(
urlString: urlString,
callbackScheme: callbackScheme,
result: result
)
return
default:
break
}
DispatchQueue.global(qos: .userInitiated).async {
do {
let response = try self.invokeGoMethod(call: call)
DispatchQueue.main.async {
result(response)
}
} catch {
DispatchQueue.main.async {
result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil))
}
}
}
}
/// Runs a verification/OAuth page inside ASWebAuthenticationSession. The
/// session intercepts the callback scheme in-process — no OS-level URL
/// scheme registration is involved — so the flow completes even where the
/// app's scheme is not registered with iOS (sideload containers such as
/// LiveContainer). The callback URL is fed into the same deep-link handler
/// the OS path uses. Returns whether the session was presented; completion
/// is delivered later through the existing grant-event plumbing.
private func startWebAuthSession(
urlString: String,
callbackScheme: String,
result: @escaping FlutterResult
) {
guard #available(iOS 13.0, *) else {
result(false)
return
}
guard let url = URL(string: urlString), url.scheme?.lowercased() == "https" else {
result(false)
return
}
let scheme = callbackScheme.isEmpty ? "spotiflac" : callbackScheme
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: scheme
) { [weak self] callbackURL, error in
self?.activeWebAuthSession = nil
guard let callbackURL = callbackURL else {
if let error = error {
NSLog("SpotiFLAC: web auth session ended: \(error.localizedDescription)")
}
return
}
_ = self?.handleExtensionOAuthRedirect(url: callbackURL)
}
session.presentationContextProvider = self
// Share Safari's cookie store so captcha providers see an established
// browsing context instead of a blank ephemeral one.
session.prefersEphemeralWebBrowserSession = false
activeWebAuthSession = session
let started = session.start()
if !started {
activeWebAuthSession = nil
}
result(started)
}
override func applicationDidEnterBackground(_ application: UIApplication) {
super.applicationDidEnterBackground(application)
if downloadsActive {
beginBackgroundDownloadTask()
}
}
override func applicationWillEnterForeground(_ application: UIApplication) {
super.applicationWillEnterForeground(application)
endBackgroundDownloadTask()
}
private func beginBackgroundDownloadTask() {
if downloadBackgroundTask != .invalid { return }
downloadBackgroundTask = UIApplication.shared.beginBackgroundTask(
withName: "SpotiFLACDownloads"
) { [weak self] in
if self?.downloadsActive == true {
// Flutter channel delivery is asynchronous and iOS may suspend
// us immediately after this callback. Cancel live Go requests
// synchronously first; Dart then persists/requeues the items.
let cancelledItemIDs = GobackendCancelAllActiveDownloads()
self?.backendChannel?.invokeMethod(
"iosBackgroundDownloadExpired",
arguments: cancelledItemIDs
)
}
self?.endBackgroundDownloadTask()
}
}
private func endBackgroundDownloadTask() {
if downloadBackgroundTask != .invalid {
UIApplication.shared.endBackgroundTask(downloadBackgroundTask)
downloadBackgroundTask = .invalid
}
}
private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? {
var error: NSError?
switch call.method {
case "downloadByStrategy":
let requestJson = call.arguments as! String
let response = GobackendDownloadByStrategy(requestJson, &error)
if let error = error { throw error }
return response
case "getAllDownloadProgress":
let response = GobackendGetAllDownloadProgress()
return parseJsonPayload(response as String? ?? "{}")
case "clearItemProgress":
let args = call.arguments as! [String: Any]
let itemId = args["item_id"] as! String
GobackendClearItemProgress(itemId)
return nil
case "cancelDownload":
let args = call.arguments as! [String: Any]
let itemId = args["item_id"] as! String
GobackendCancelDownload(itemId)
return nil
case "resetDownloadCancel":
let args = call.arguments as! [String: Any]
let itemId = args["item_id"] as! String
GobackendResetDownloadCancel(itemId)
return nil
case "setDownloadDirectory":
let args = call.arguments as! [String: Any]
let path = args["path"] as! String
GobackendSetDownloadDirectory(path, &error)
if let error = error { throw error }
return nil
case "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions":
let args = call.arguments as! [String: Any]
let allowHTTP = args["allow_http"] as? Bool ?? false
let insecureTLS = args["insecure_tls"] as? Bool ?? false
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
return nil
case "setAllowPrivateNetwork":
let args = call.arguments as! [String: Any]
let allowed = args["allowed"] as? Bool ?? false
GobackendSetAllowPrivateNetwork(allowed)
return nil
case "checkDuplicatesBatch":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
let tracksJson = args["tracks"] as? String ?? "[]"
let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error)
if let error = error { throw error }
return response
case "preBuildDuplicateIndex":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
GobackendPreBuildDuplicateIndex(outputDir, &error)
if let error = error { throw error }
return nil
case "invalidateDuplicateIndex":
let args = call.arguments as! [String: Any]
let outputDir = args["output_dir"] as! String
GobackendInvalidateDuplicateIndex(outputDir)
return nil
case "buildFilename":
let args = call.arguments as! [String: Any]
let template = args["template"] as! String
let metadata = args["metadata"] as! String
let response = GobackendBuildFilename(template, metadata, &error)
if let error = error { throw error }
return response
case "sanitizeFilename":
let args = call.arguments as! [String: Any]
let filename = args["filename"] as! String
let response = GobackendSanitizeFilename(filename)
return response
case "getLyricsLRC":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let filePath = args["file_path"] as? String ?? ""
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
if let error = error { throw error }
return response
case "getLyricsLRCWithSource":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let filePath = args["file_path"] as? String ?? ""
let durationMs = args["duration_ms"] as? Int64 ?? 0
let response = GobackendGetLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs, &error)
if let error = error { throw error }
return response
case "embedLyricsToFile":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let lyrics = args["lyrics"] as! String
let response = GobackendEmbedLyricsToFile(filePath, lyrics, &error)
if let error = error { throw error }
return response
case "rewriteSplitArtistTags":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let artist = args["artist"] as! String
let albumArtist = args["album_artist"] as! String
let response = GobackendRewriteSplitArtistTagsExport(filePath, artist, albumArtist, &error)
if let error = error { throw error }
return response
case "cleanupConnections":
GobackendCleanupConnections()
return nil
case "downloadCoverToFile":
let args = call.arguments as! [String: Any]
let coverURL = args["cover_url"] as! String
let outputPath = args["output_path"] as! String
let maxDimension = max(0, (args["max_dimension"] as? NSNumber)?.intValue ?? 0)
GobackendDownloadCoverToFileSized(coverURL, outputPath, maxDimension, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "extractCoverToFile":
let args = call.arguments as! [String: Any]
let audioPath = args["audio_path"] as! String
let outputPath = args["output_path"] as! String
GobackendExtractCoverToFile(audioPath, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "fetchAndSaveLyrics":
let args = call.arguments as! [String: Any]
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let spotifyId = args["spotify_id"] as! String
let durationMs = args["duration_ms"] as? Int64 ?? 0
let outputPath = args["output_path"] as! String
let audioFilePath = args["audio_file_path"] as? String ?? ""
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, audioFilePath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "reEnrichFile":
let args = call.arguments as! [String: Any]
let requestJson = args["request_json"] as? String ?? "{}"
let response = GobackendReEnrichFile(requestJson, &error)
if let error = error { throw error }
return response
case "readFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendReadFileMetadataWithHint(filePath, args["display_name"] as? String ?? "", &error)
if let error = error { throw error }
return response
case "editFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let metadataJson = args["metadata_json"] as? String ?? "{}"
let response = GobackendEditFileMetadata(filePath, metadataJson, &error)
if let error = error { throw error }
return response
case "getProviderMetadata":
let args = call.arguments as! [String: Any]
let providerId = args["provider_id"] as! String
let resourceType = args["resource_type"] as! String
let resourceId = args["resource_id"] as! String
let response = GobackendGetProviderMetadataJSON(providerId, resourceType, resourceId, &error)
if let error = error { throw error }
return response
case "searchDeezerByISRC":
let args = call.arguments as! [String: Any]
let isrc = args["isrc"] as! String
let itemId = args["item_id"] as? String ?? ""
let response = GobackendSearchDeezerByISRCForItemID(isrc, itemId, &error)
if let error = error { throw error }
return response
case "getDeezerExtendedMetadata":
let args = call.arguments as! [String: Any]
let trackId = args["track_id"] as! String
let response = GobackendGetDeezerExtendedMetadata(trackId, &error)
if let error = error { throw error }
return response
case "convertSpotifyToDeezer":
let args = call.arguments as! [String: Any]
let resourceType = args["resource_type"] as! String
let spotifyId = args["spotify_id"] as! String
let response = GobackendConvertSpotifyToDeezer(resourceType, spotifyId, &error)
if let error = error { throw error }
return response
case "getSpotifyIDFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "getTidalURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "getTrackCacheSize":
let response = GobackendGetTrackCacheSize()
return response
case "clearTrackCache":
GobackendClearTrackIDCache()
return nil
case "getLogsSince":
let args = call.arguments as! [String: Any]
let index = args["index"] as? Int ?? 0
let response = GobackendGetLogsSince(Int(index))
return response
case "clearLogs":
GobackendClearLogs()
return nil
case "releaseMemory":
GobackendReleaseMemory()
return nil
case "releaseMemoryUnderPressure":
GobackendReleaseMemoryUnderPressure()
return nil
case "getGoRuntimeMetrics":
return GobackendGetRuntimeMetricsJSON()
case "setLoggingEnabled":
let args = call.arguments as! [String: Any]
let enabled = args["enabled"] as? Bool ?? false
GobackendSetLoggingEnabled(enabled)
return nil
case "initExtensionSystem":
let args = call.arguments as! [String: Any]
let extensionsDir = args["extensions_dir"] as! String
let dataDir = args["data_dir"] as! String
let masterKey = args["master_key"] as! String
GobackendSetExtensionStorageMasterKey(masterKey, &error)
if let error = error { throw error }
GobackendInitExtensionSystem(extensionsDir, dataDir, &error)
if let error = error { throw error }
return nil
case "loadExtensionsFromDir":
let args = call.arguments as! [String: Any]
let dirPath = args["dir_path"] as! String
let response = GobackendLoadExtensionsFromDir(dirPath, &error)
if let error = error { throw error }
return response
case "loadExtensionFromPath":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendLoadExtensionFromPath(filePath, &error)
if let error = error { throw error }
return response
case "unloadExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
GobackendUnloadExtensionByID(extensionId, &error)
if let error = error { throw error }
return nil
case "getInstalledExtensions":
let response = GobackendGetInstalledExtensions(&error)
if let error = error { throw error }
return response
case "setExtensionEnabled":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let enabled = args["enabled"] as? Bool ?? false
GobackendSetExtensionEnabledByID(extensionId, enabled, &error)
if let error = error { throw error }
return nil
case "setProviderPriority":
let args = call.arguments as! [String: Any]
let priorityJson = args["priority"] as! String
GobackendSetProviderPriorityJSON(priorityJson, &error)
if let error = error { throw error }
return nil
case "getProviderPriority":
let response = GobackendGetProviderPriorityJSON(&error)
if let error = error { throw error }
return response
case "setDownloadFallbackExtensionIds":
let args = call.arguments as! [String: Any]
let extensionIdsJson = args["extension_ids"] as? String ?? ""
GobackendSetExtensionFallbackProviderIDsJSON(extensionIdsJson, &error)
if let error = error { throw error }
return nil
case "setMetadataProviderPriority":
let args = call.arguments as! [String: Any]
let priorityJson = args["priority"] as! String
GobackendSetMetadataProviderPriorityJSON(priorityJson, &error)
if let error = error { throw error }
return nil
case "getMetadataProviderPriority":
let response = GobackendGetMetadataProviderPriorityJSON(&error)
if let error = error { throw error }
return response
case "getExtensionSettings":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionSettingsJSON(extensionId, &error)
if let error = error { throw error }
return response
case "checkExtensionHealth":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendCheckExtensionHealthJSON(extensionId, &error)
if let error = error { throw error }
return response
case "setExtensionSettings":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let settingsJson = args["settings"] as! String
GobackendSetExtensionSettingsJSON(extensionId, settingsJson, &error)
if let error = error { throw error }
return nil
case "invokeExtensionAction":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let actionName = args["action"] as! String
let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error)
if let error = error { throw error }
return response
case "searchTracksWithMetadataProviders":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let limit = args["limit"] as? Int ?? 20
let includeExtensions = args["include_extensions"] as? Bool ?? true
let response = GobackendSearchTracksWithMetadataProvidersJSON(
query,
Int(limit),
includeExtensions,
&error
)
if let error = error { throw error }
return response
case "searchTracksWithMetadataProvider":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as? String ?? ""
let query = args["query"] as? String ?? ""
let limit = args["limit"] as? Int ?? 20
let response = GobackendSearchTracksWithMetadataProviderJSON(
extensionId,
query,
Int(limit),
&error
)
if let error = error { throw error }
return response
case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let trackJson = args["track"] as? String ?? "{}"
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
if let error = error { throw error }
return response
case "downloadWithExtensions":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
if let error = error { throw error }
return response
case "removeExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
GobackendRemoveExtensionByID(extensionId, &error)
if let error = error { throw error }
return nil
case "upgradeExtension":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendUpgradeExtensionFromPath(filePath, &error)
if let error = error { throw error }
return response
case "checkExtensionUpgrade":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendCheckExtensionUpgradeFromPath(filePath, &error)
if let error = error { throw error }
return response
case "cleanupExtensions":
GobackendCleanupExtensions()
return nil
case "getExtensionPendingAuth":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendGetExtensionPendingAuthJSON(extensionId, &error)
if let error = error { throw error }
return response
case "setExtensionAuthCode":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let authCode = args["auth_code"] as! String
GobackendSetExtensionAuthCodeByID(extensionId, authCode)
return nil
case "completeExtensionSessionGrant":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let grant = args["grant"] as! String
GobackendSetExtensionSessionGrantByID(extensionId, grant)
let response = GobackendInvokeExtensionActionJSON(extensionId, "completeGrant", &error)
if let error = error { throw error }
try requireSuccessfulExtensionAction(
extensionId: extensionId,
actionName: "completeGrant",
response: response
)
return true
case "setExtensionTokens":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let accessToken = args["access_token"] as! String
let refreshToken = args["refresh_token"] as? String ?? ""
let expiresIn = args["expires_in"] as? Int ?? 0
GobackendSetExtensionTokensByID(extensionId, accessToken, refreshToken, Int(expiresIn))
return nil
case "clearExtensionPendingAuth":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
GobackendClearExtensionPendingAuthByID(extensionId)
return nil
case "isExtensionAuthenticated":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let response = GobackendIsExtensionAuthenticatedByID(extensionId)
return response
case "getAllPendingAuthRequests":
let response = GobackendGetAllPendingAuthRequestsJSON(&error)
if let error = error { throw error }
return response
case "getPendingFFmpegCommand":
let args = call.arguments as! [String: Any]
let commandId = args["command_id"] as! String
let response = GobackendGetPendingFFmpegCommandJSON(commandId, &error)
if let error = error { throw error }
return response
case "setFFmpegCommandResult":
let args = call.arguments as! [String: Any]
let commandId = args["command_id"] as! String
let success = args["success"] as? Bool ?? false
let output = args["output"] as? String ?? ""
let errorMsg = args["error"] as? String ?? ""
GobackendSetFFmpegCommandResult(commandId, success, output, errorMsg)
return nil
case "getAllPendingFFmpegCommands":
let response = GobackendGetAllPendingFFmpegCommandsJSON(&error)
if let error = error { throw error }
return response
case "customSearchWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let query = args["query"] as! String
let optionsJson = args["options"] as? String ?? ""
let requestId = args["request_id"] as? String ?? ""
let response = GobackendCustomSearchWithExtensionJSONWithRequestID(extensionId, query, optionsJson, requestId, &error)
if let error = error { throw error }
return response
case "cancelExtensionRequest":
let args = call.arguments as! [String: Any]
let requestId = args["request_id"] as? String ?? ""
GobackendCancelExtensionRequestJSON(requestId)
return nil
case "handleURLWithExtension":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendHandleURLWithExtensionJSON(url, &error)
if let error = error { throw error }
return response
case "findURLHandler":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendFindURLHandlerJSON(url)
return response
case "getTrackPlatformLinks":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as? String ?? ""
let isrc = args["isrc"] as? String ?? ""
let response = GobackendGetTrackPlatformLinksJSON(spotifyId, isrc, &error)
if let error = error { throw error }
return response
case "fetchMusicBrainzTags":
let args = call.arguments as! [String: Any]
let isrc = args["isrc"] as? String ?? ""
let albumName = args["album_name"] as? String ?? ""
var genreError: NSError?
let genre = GobackendFetchMusicBrainzGenreByISRC(isrc, &genreError)
var artistError: NSError?
let albumArtist = GobackendFetchMusicBrainzAlbumArtistByISRC(isrc, albumName, &artistError)
let payload: [String: Any] = [
"genre": genreError == nil ? genre : "",
"album_artist": artistError == nil ? albumArtist : "",
]
let data = try JSONSerialization.data(withJSONObject: payload)
return String(data: data, encoding: .utf8) ?? "{}"
case "runPostProcessingV2":
let args = call.arguments as! [String: Any]
let inputJson = args["input"] as? String ?? ""
let metadataJson = args["metadata"] as? String ?? ""
let response = GobackendRunPostProcessingV2JSON(inputJson, metadataJson, &error)
if let error = error { throw error }
return response
case "initExtensionRepo":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
GobackendInitExtensionRepoJSON(cacheDir, &error)
if let error = error { throw error }
return nil
case "setRepoRegistryUrl":
let args = call.arguments as! [String: Any]
let registryUrl = args["registry_url"] as? String ?? ""
GobackendSetRepoRegistryURLJSON(registryUrl, &error)
if let error = error { throw error }
return nil
case "getRepoRegistryUrl":
let response = GobackendGetRepoRegistryURLJSON(&error)
if let error = error { throw error }
return response
case "clearRepoRegistryUrl":
GobackendClearRepoRegistryURLJSON(&error)
if let error = error { throw error }
return nil
case "getRepoExtensions":
let args = call.arguments as! [String: Any]
let forceRefresh = args["force_refresh"] as? Bool ?? false
let response = GobackendGetRepoExtensionsJSON(forceRefresh, &error)
if let error = error { throw error }
return response
case "searchRepoExtensions":
let args = call.arguments as! [String: Any]
let query = args["query"] as? String ?? ""
let category = args["category"] as? String ?? ""
let response = GobackendSearchRepoExtensionsJSON(query, category, &error)
if let error = error { throw error }
return response
case "getRepoCategories":
let response = GobackendGetRepoCategoriesJSON(&error)
if let error = error { throw error }
return response
case "downloadRepoExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let destDir = args["dest_dir"] as! String
let response = GobackendDownloadRepoExtensionJSON(extensionId, destDir, &error)
if let error = error { throw error }
return response
case "clearRepoCache":
GobackendClearRepoCacheJSON(&error)
if let error = error { throw error }
return nil
case "getExtensionHomeFeed":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
let requestId = args["request_id"] as? String ?? ""
let response = GobackendGetExtensionHomeFeedJSONWithRequestID(extensionId, requestId, &error)
if let error = error { throw error }
return response
case "setLibraryCoverCacheDir":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
GobackendSetLibraryCoverCacheDirJSON(cacheDir)
return nil
case "scanLibraryFolder":
let args = call.arguments as! [String: Any]
let folderPath = args["folder_path"] as! String
let response = GobackendScanLibraryFolderJSON(folderPath, &error)
if let error = error { throw error }
return bridgeJsonResult(response as String? ?? "[]")
case "scanLibraryFolderToNDJSONFile":
guard
let args = call.arguments as? [String: Any],
let folderPath = args["folder_path"] as? String,
!folderPath.isEmpty,
let outputPath = args["output_path"] as? String,
!outputPath.isEmpty
else {
throw invalidArgumentsError(call.method)
}
var count = 0
let succeeded = GobackendScanLibraryFolderToNDJSONFileJSON(
folderPath,
outputPath,
&count,
&error
)
if let error = error { throw error }
if !succeeded {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Library scan failed"]
)
}
return ["path": outputPath, "count": count]
case "scanLibraryFolderIncremental":
let args = call.arguments as! [String: Any]
let folderPath = args["folder_path"] as! String
let existingFiles = args["existing_files"] as? String ?? "{}"
let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error)
if let error = error { throw error }
return bridgeJsonResult(response as String? ?? "{}")
case "getLibraryScanProgress":
let response = GobackendGetLibraryScanProgressJSON()
return parseJsonPayload(response as String? ?? "{}")
case "cancelLibraryScan":
GobackendCancelLibraryScanJSON()
return nil
case "readAudioMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendReadAudioMetadataJSON(filePath, &error)
if let error = error { throw error }
return response
case "resolveIosBookmark":
let args = call.arguments as! [String: Any]
let bookmarkBase64 = args["bookmark"] as! String
return try resolveIosBookmark(bookmarkBase64)
case "startAccessingIosBookmark":
guard
let args = call.arguments as? [String: Any],
let bookmarkBase64 = args["bookmark"] as? String,
!bookmarkBase64.isEmpty
else {
throw invalidArgumentsError(call.method)
}
return try startAccessingIosBookmark(bookmarkBase64)
case "stopAccessingIosBookmark":
guard
let args = call.arguments as? [String: Any],
let token = args["token"] as? String,
!token.isEmpty
else {
throw invalidArgumentsError(call.method)
}
stopAccessingIosBookmark(token: token)
return nil
case "createIosBookmarkFromPath":
let args = call.arguments as! [String: Any]
let path = args["path"] as! String
return try createIosBookmarkFromPath(path)
case "setLyricsProviders":
let args = call.arguments as! [String: Any]
let providersJson = args["providers_json"] as? String ?? "[]"
GobackendSetLyricsProvidersJSON(providersJson, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "getLyricsProviders":
let response = GobackendGetLyricsProvidersJSON(&error)
if let error = error { throw error }
return response
case "getAvailableLyricsProviders":
let response = GobackendGetAvailableLyricsProvidersJSON(&error)
if let error = error { throw error }
return response
case "setLyricsFetchOptions":
let args = call.arguments as! [String: Any]
let optionsJson = args["options_json"] as? String ?? "{}"
GobackendSetLyricsFetchOptionsJSON(optionsJson, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "getLyricsFetchOptions":
let response = GobackendGetLyricsFetchOptionsJSON(&error)
if let error = error { throw error }
return response
case "parseCueSheet":
let args = call.arguments as! [String: Any]
let cuePath = args["cue_path"] as! String
let audioDir = args["audio_dir"] as? String ?? ""
let response = GobackendParseCueSheet(cuePath, audioDir, &error)
if let error = error { throw error }
return response
default:
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Method not implemented: \(call.method)"]
)
}
}
// MARK: - Native Folder Picker
/// Present a native folder picker and return `{path, bookmark}` where the
/// security-scoped bookmark is created inside the picker callback, while
/// the picker's access grant is still active. Returns nil on cancel.
private func pickIosDirectory(result: @escaping FlutterResult) {
if pendingDirectoryPickerResult != nil {
result(FlutterError(
code: "PICKER_ACTIVE",
message: "A folder picker is already active",
details: nil
))
return
}
guard var topController = window?.rootViewController else {
result(FlutterError(
code: "NO_VIEW_CONTROLLER",
message: "No view controller available to present the folder picker",
details: nil
))
return
}
while let presented = topController.presentedViewController {
topController = presented
}
pendingDirectoryPickerResult = result
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder])
picker.delegate = self
picker.allowsMultipleSelection = false
topController.present(picker, animated: true)
}
// MARK: - iOS Security-Scoped Bookmark Helpers
/// Create a security-scoped bookmark from a filesystem path (e.g. from FilePicker).
/// The path must currently be accessible (within the same picker session).
/// Returns base64-encoded bookmark data.
private func createIosBookmarkFromPath(_ path: String) throws -> String {
let url = URL(fileURLWithPath: path)
do {
#if os(macOS)
let options: URL.BookmarkCreationOptions = .withSecurityScope
#else
let options: URL.BookmarkCreationOptions = []
#endif
let bookmarkData = try url.bookmarkData(
options: options,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
return bookmarkData.base64EncodedString()
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark for path \(path): \(error.localizedDescription)"]
)
}
}
/// Resolve a base64-encoded security-scoped bookmark and return the resolved path.
/// Does NOT start accessing the resource.
private func resolveIosBookmark(_ bookmarkBase64: String) throws -> String {
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
)
}
var isStale = false
let url: URL
do {
#if os(macOS)
let options: URL.BookmarkResolutionOptions = .withSecurityScope
#else
let options: URL.BookmarkResolutionOptions = []
#endif
url = try URL(
resolvingBookmarkData: bookmarkData,
options: options,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
)
}
return url.path
}
private func invalidArgumentsError(_ method: String) -> NSError {
return NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid arguments for \(method)"]
)
}
/// Starts an independently owned security-scoped lease.
private func startAccessingIosBookmark(_ bookmarkBase64: String) throws -> [String: String] {
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
)
}
var isStale = false
let url: URL
do {
#if os(macOS)
let options: URL.BookmarkResolutionOptions = .withSecurityScope
#else
let options: URL.BookmarkResolutionOptions = []
#endif
url = try URL(
resolvingBookmarkData: bookmarkData,
options: options,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
)
}
guard url.startAccessingSecurityScopedResource() else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to start accessing security-scoped resource at \(url.path)"]
)
}
let token = UUID().uuidString
securityScopedAccessLock.lock()
securityScopedAccesses[token] = url
securityScopedAccessLock.unlock()
return ["path": url.path, "token": token]
}
/// Releases only the lease identified by the caller's token.
private func stopAccessingIosBookmark(token: String) {
securityScopedAccessLock.lock()
let url = securityScopedAccesses.removeValue(forKey: token)
securityScopedAccessLock.unlock()
url?.stopAccessingSecurityScopedResource()
}
}
@available(iOS 13.0, *)
extension AppDelegate: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return window ?? ASPresentationAnchor()
}
}
extension AppDelegate: UIDocumentPickerDelegate {
func documentPicker(
_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]
) {
guard let result = pendingDirectoryPickerResult else { return }
pendingDirectoryPickerResult = nil
guard let url = urls.first else {
result(nil)
return
}
// The bookmark must be created here, from the picker's own URL, while
// its security-scoped grant is active. A URL rebuilt from the path
// string later has no grant, so bookmark creation either fails or
// yields a bookmark that cannot re-open the folder.
let didStartAccess = url.startAccessingSecurityScopedResource()
defer {
if didStartAccess { url.stopAccessingSecurityScopedResource() }
}
do {
let bookmarkData = try url.bookmarkData(
options: [],
includingResourceValuesForKeys: nil,
relativeTo: nil
)
result([
"path": url.path,
"bookmark": bookmarkData.base64EncodedString(),
])
} catch {
result(FlutterError(
code: "BOOKMARK_FAILED",
message: "Failed to create bookmark for \(url.path): \(error.localizedDescription)",
details: nil
))
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
pendingDirectoryPickerResult?(nil)
pendingDirectoryPickerResult = nil
}
}
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
typealias ListenHandler = (_ arguments: Any?, _ events: @escaping FlutterEventSink) -> FlutterError?
typealias CancelHandler = (_ arguments: Any?) -> FlutterError?
private let onListenHandler: ListenHandler
private let onCancelHandler: CancelHandler
init(
onListen: @escaping ListenHandler,
onCancel: @escaping CancelHandler = { _ in nil }
) {
self.onListenHandler = onListen
self.onCancelHandler = onCancel
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
onListenHandler(arguments, events)
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
onCancelHandler(arguments)
}
}