mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-26 22:11:03 +02:00
196fd0f651
The captcha/OAuth flow relied on the OS routing the spotiflac:// callback back into the app. In sideload containers (LiveContainer) the guest app's URL scheme is never registered with iOS, so after solving the challenge the redirect went nowhere and the user could not return to the app (LiveContainer#242/#162 — unresolved upstream). ASWebAuthenticationSession intercepts the callback scheme in-process, so no OS-level scheme registration is involved: the session sheet closes itself on completion and the callback URL is fed into the same deep-link handler the OS path uses. Verification challenges, the help dialog's open-browser action, and extension OAuth logins all prefer the session on iOS, falling back to url_launcher if it fails to start. Safari's cookie store is shared so captcha providers see an established browsing context.
1478 lines
60 KiB
Swift
1478 lines
60 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 var downloadProgressTimer: DispatchSourceTimer?
|
|
private var downloadProgressEventSink: FlutterEventSink?
|
|
private var lastDownloadProgressPayload: String?
|
|
private var lastDownloadProgressSeq: Int64 = 0
|
|
private var libraryScanProgressTimer: DispatchSourceTimer?
|
|
private var libraryScanProgressEventSink: FlutterEventSink?
|
|
private var lastLibraryScanProgressPayload: String?
|
|
private var backendChannel: FlutterMethodChannel?
|
|
private var pendingSessionGrantEvents: [[String: Any]] = []
|
|
|
|
/// Currently accessed security-scoped URL for library folder
|
|
private var activeSecurityScopedURL: URL?
|
|
|
|
/// Pending Flutter result for the native folder picker
|
|
private var pendingDirectoryPickerResult: FlutterResult?
|
|
|
|
/// Whether a download queue is active; while true a background task is
|
|
/// started on each background entry to extend execution time. 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=<extension_id>
|
|
/// - 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
|
|
}
|
|
streamQueue.async {
|
|
var err: NSError?
|
|
var response: String?
|
|
if isSessionGrant {
|
|
GobackendSetExtensionSessionGrantByID(state, code)
|
|
response = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err)
|
|
} else {
|
|
GobackendSetExtensionAuthCodeByID(state, code)
|
|
response = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
|
}
|
|
if err == nil && isSessionGrant {
|
|
do {
|
|
try self.requireSuccessfulExtensionAction(
|
|
extensionId: state,
|
|
actionName: "completeGrant",
|
|
response: response
|
|
)
|
|
} catch {
|
|
err = error as NSError
|
|
}
|
|
}
|
|
if let err = err {
|
|
NSLog(
|
|
"SpotiFLAC: Extension callback complete failed: \(err.localizedDescription)")
|
|
} else if isSessionGrant {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.notifySessionGrantCompleted(extensionId: state)
|
|
}
|
|
}
|
|
}
|
|
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) {
|
|
stopDownloadProgressStream()
|
|
downloadProgressEventSink = eventSink
|
|
lastDownloadProgressPayload = nil
|
|
lastDownloadProgressSeq = 0
|
|
|
|
let timer = DispatchSource.makeTimerSource(queue: streamQueue)
|
|
timer.schedule(deadline: .now(), repeating: .milliseconds(800))
|
|
timer.setEventHandler { [weak self] in
|
|
guard let self else { return }
|
|
let payload = GobackendGetAllDownloadProgressDelta(self.lastDownloadProgressSeq) as String? ?? ""
|
|
if payload.isEmpty || payload == self.lastDownloadProgressPayload {
|
|
return
|
|
}
|
|
self.updateDownloadProgressSeq(payload)
|
|
self.lastDownloadProgressPayload = payload
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.downloadProgressEventSink?(self?.parseJsonPayload(payload))
|
|
}
|
|
}
|
|
downloadProgressTimer = timer
|
|
timer.resume()
|
|
}
|
|
|
|
private func stopDownloadProgressStream() {
|
|
downloadProgressTimer?.setEventHandler {}
|
|
downloadProgressTimer?.cancel()
|
|
downloadProgressTimer = nil
|
|
downloadProgressEventSink = nil
|
|
lastDownloadProgressPayload = nil
|
|
lastDownloadProgressSeq = 0
|
|
}
|
|
|
|
private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) {
|
|
stopLibraryScanProgressStream()
|
|
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 else { return }
|
|
let payload = GobackendGetLibraryScanProgressJSON() as String? ?? "{}"
|
|
if payload == self.lastLibraryScanProgressPayload {
|
|
return
|
|
}
|
|
self.lastLibraryScanProgressPayload = payload
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.libraryScanProgressEventSink?(self?.parseJsonPayload(payload))
|
|
}
|
|
}
|
|
libraryScanProgressTimer = timer
|
|
timer.resume()
|
|
}
|
|
|
|
private func stopLibraryScanProgressStream() {
|
|
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 updateDownloadProgressSeq(_ payload: String) {
|
|
guard let data = payload.data(using: .utf8) else { return }
|
|
do {
|
|
if let obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any],
|
|
let seq = obj["seq"] as? NSNumber,
|
|
seq.int64Value > lastDownloadProgressSeq {
|
|
lastDownloadProgressSeq = seq.int64Value
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
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
|
|
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
|
|
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 "checkAvailability":
|
|
let args = call.arguments as! [String: Any]
|
|
let spotifyId = args["spotify_id"] as! String
|
|
let isrc = args["isrc"] as! String
|
|
let response = GobackendCheckAvailability(spotifyId, isrc, &error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
case "downloadByStrategy":
|
|
let requestJson = call.arguments as! String
|
|
let response = GobackendDownloadByStrategy(requestJson, &error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
case "getDownloadProgress":
|
|
let response = GobackendGetDownloadProgress()
|
|
return parseJsonPayload(response as String? ?? "{}")
|
|
|
|
case "getAllDownloadProgress":
|
|
let response = GobackendGetAllDownloadProgress()
|
|
return parseJsonPayload(response as String? ?? "{}")
|
|
|
|
case "initItemProgress":
|
|
let args = call.arguments as! [String: Any]
|
|
let itemId = args["item_id"] as! String
|
|
GobackendInitItemProgress(itemId)
|
|
return nil
|
|
|
|
case "finishItemProgress":
|
|
let args = call.arguments as! [String: Any]
|
|
let itemId = args["item_id"] as! String
|
|
GobackendFinishItemProgress(itemId)
|
|
return nil
|
|
|
|
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 "checkDuplicate":
|
|
let args = call.arguments as! [String: Any]
|
|
let outputDir = args["output_dir"] as! String
|
|
let isrc = args["isrc"] as! String
|
|
let response = GobackendCheckDuplicate(outputDir, isrc, &error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
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 "fetchLyrics":
|
|
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 durationMs = args["duration_ms"] as? Int64 ?? 0
|
|
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, durationMs, &error)
|
|
if let error = error { throw error }
|
|
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 maxQuality = args["max_quality"] as? Bool ?? true
|
|
GobackendDownloadCoverToFile(coverURL, outputPath, maxQuality, &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 = GobackendReadFileMetadata(filePath, &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 "getDeezerRelatedArtists":
|
|
let args = call.arguments as! [String: Any]
|
|
let artistId = args["artist_id"] as! String
|
|
let limit = args["limit"] as? Int ?? 12
|
|
let response = GobackendGetDeezerRelatedArtists(artistId, Int(limit), &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 "checkAvailabilityFromDeezerID":
|
|
let args = call.arguments as! [String: Any]
|
|
let deezerTrackId = args["deezer_track_id"] as! String
|
|
let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
case "checkAvailabilityByPlatformID":
|
|
let args = call.arguments as! [String: Any]
|
|
let platform = args["platform"] as! String
|
|
let entityType = args["entity_type"] as! String
|
|
let entityId = args["entity_id"] as! String
|
|
let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &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 "preWarmTrackCache":
|
|
let args = call.arguments as! [String: Any]
|
|
let tracksJson = args["tracks"] as! String
|
|
let _ = GobackendPreWarmTrackCacheJSON(tracksJson, &error)
|
|
if let error = error { throw error }
|
|
return nil
|
|
|
|
case "getTrackCacheSize":
|
|
let response = GobackendGetTrackCacheSize()
|
|
return response
|
|
|
|
case "clearTrackCache":
|
|
GobackendClearTrackCache()
|
|
return nil
|
|
|
|
case "getLogs":
|
|
let response = GobackendGetLogs()
|
|
return response
|
|
|
|
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 "getLogCount":
|
|
let response = GobackendGetLogCount()
|
|
return response
|
|
|
|
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
|
|
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 "searchTracksWithExtensions":
|
|
let args = call.arguments as! [String: Any]
|
|
let query = args["query"] as! String
|
|
let limit = args["limit"] as? Int ?? 20
|
|
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &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 "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 "getSearchProviders":
|
|
let response = GobackendGetSearchProvidersJSON(&error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
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 "getURLHandlers":
|
|
let response = GobackendGetURLHandlersJSON(&error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
case "runPostProcessing":
|
|
let args = call.arguments as! [String: Any]
|
|
let filePath = args["file_path"] as! String
|
|
let metadataJson = args["metadata"] as? String ?? ""
|
|
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
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 "getPostProcessingProviders":
|
|
let response = GobackendGetPostProcessingProvidersJSON(&error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
case "initExtensionStore":
|
|
let args = call.arguments as! [String: Any]
|
|
let cacheDir = args["cache_dir"] as! String
|
|
GobackendInitExtensionStoreJSON(cacheDir, &error)
|
|
if let error = error { throw error }
|
|
return nil
|
|
|
|
case "setStoreRegistryUrl":
|
|
let args = call.arguments as! [String: Any]
|
|
let registryUrl = args["registry_url"] as? String ?? ""
|
|
GobackendSetStoreRegistryURLJSON(registryUrl, &error)
|
|
if let error = error { throw error }
|
|
return nil
|
|
|
|
case "getStoreRegistryUrl":
|
|
let response = GobackendGetStoreRegistryURLJSON(&error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
case "clearStoreRegistryUrl":
|
|
GobackendClearStoreRegistryURLJSON(&error)
|
|
if let error = error { throw error }
|
|
return nil
|
|
|
|
case "getStoreExtensions":
|
|
let args = call.arguments as! [String: Any]
|
|
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
|
let response = GobackendGetStoreExtensionsJSON(forceRefresh, &error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
case "searchStoreExtensions":
|
|
let args = call.arguments as! [String: Any]
|
|
let query = args["query"] as? String ?? ""
|
|
let category = args["category"] as? String ?? ""
|
|
let response = GobackendSearchStoreExtensionsJSON(query, category, &error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
case "getStoreCategories":
|
|
let response = GobackendGetStoreCategoriesJSON(&error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
case "downloadStoreExtension":
|
|
let args = call.arguments as! [String: Any]
|
|
let extensionId = args["extension_id"] as! String
|
|
let destDir = args["dest_dir"] as! String
|
|
let response = GobackendDownloadStoreExtensionJSON(extensionId, destDir, &error)
|
|
if let error = error { throw error }
|
|
return response
|
|
|
|
case "clearStoreCache":
|
|
GobackendClearStoreCacheJSON(&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 "getExtensionBrowseCategories":
|
|
let args = call.arguments as! [String: Any]
|
|
let extensionId = args["extension_id"] as! String
|
|
let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &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 "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":
|
|
let args = call.arguments as! [String: Any]
|
|
let bookmarkBase64 = args["bookmark"] as! String
|
|
return try startAccessingIosBookmark(bookmarkBase64)
|
|
|
|
case "stopAccessingIosBookmark":
|
|
stopAccessingIosBookmark()
|
|
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
|
|
}
|
|
|
|
/// Resolve a base64-encoded bookmark, start accessing the security-scoped resource,
|
|
/// and return the resolved filesystem path. The resource stays accessed until
|
|
/// `stopAccessingIosBookmark()` is called.
|
|
private func startAccessingIosBookmark(_ bookmarkBase64: String) throws -> String {
|
|
// Stop any previously accessed resource first
|
|
stopAccessingIosBookmark()
|
|
|
|
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)"]
|
|
)
|
|
}
|
|
|
|
activeSecurityScopedURL = url
|
|
return url.path
|
|
}
|
|
|
|
/// Stop accessing the currently active security-scoped resource, if any.
|
|
private func stopAccessingIosBookmark() {
|
|
if let url = activeSecurityScopedURL {
|
|
url.stopAccessingSecurityScopedResource()
|
|
activeSecurityScopedURL = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|