Files
SpotiFLAC-Mobile/ios/Runner/CoreBackend.swift
T
zarzet aa99726439 feat(backend): migrate production backend to Rust
Replace the Go backend with the Rust workspace and route Android/iOS through
UniFFI bindings. Include the migrated extension runtime, network, providers,
media metadata, downloads and library operations, with version 5.0.0+147.

Remove Go sources, adapters, native build selection and CI dependencies.
Build Rust unconditionally and lock the iOS Rust pod using a relative path.
Retain legacy source and migration evidence in a local ignored archive.

Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch
equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and
diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI
build checkpoint; no new APK or iOS application build for this cleanup.

Known follow-up: URL/URLSearchParams globals are missing from the Rust JS
runtime. A controlled extension replay confirms a URL-resolution regression;
this commit does not fix that runtime gap.
2026-09-14 22:58:27 +07:00

812 lines
43 KiB
Swift

import Foundation
import CryptoKit
import Darwin
import ffmpegkit
import SpotiFLACBackend
struct CoreFFmpegCommand: Decodable {
let command_id: String
let arguments: [String]
var output_path: String? = nil
}
final class CoreFFmpegExecution {
let wait: (Int64) throws -> [CoreFFmpegCommand]
let active: (String) throws -> Bool
let complete: (String, Bool, String, String) throws -> Void
let close: () -> Void
private let lock = NSLock()
private var running = true
init(wait: @escaping (Int64) throws -> [CoreFFmpegCommand], active: @escaping (String) throws -> Bool,
complete: @escaping (String, Bool, String, String) throws -> Void, close: @escaping () -> Void = {}) {
self.wait = wait
self.active = active
self.complete = complete
self.close = close
}
func run(execute: @escaping ([String], @escaping () -> Bool) -> (Bool, String) = executeCoreFFmpeg,
block: () throws -> String) rethrows -> String {
DispatchQueue.global(qos: .userInitiated).async {
defer { self.close() }
while self.isRunning() {
guard let commands = try? self.wait(1_000) else { return }
// A different operation may own any command in this claimed batch.
for command in commands {
let cancelled = { (try? self.active(command.command_id)) != true }
let result: (Bool, String)
if cancelled() { result = (false, "cancelled") }
else if command.arguments.isEmpty { result = (false, "FFmpeg arguments are empty") }
else { result = executeCoreFFmpegCommand(command: command, cancelled: cancelled, execute: execute) }
try? self.complete(command.command_id, result.0, result.1, result.0 ? "" : result.1)
}
}
}
defer {
lock.lock()
running = false
lock.unlock()
}
return try block()
}
private func isRunning() -> Bool {
lock.lock()
defer { lock.unlock() }
return running
}
}
private enum CoreFFmpegStaging {
static let lock = NSLock()
static var directories = Set<String>()
static let prefix = ".spotiflac-ffmpeg-\(Bundle.main.bundleIdentifier ?? "spotiflac")-"
static func create(for target: URL) throws -> URL {
let directory = target.deletingLastPathComponent().resolvingSymlinksInPath()
lock.lock()
defer { lock.unlock() }
// One app process owns these stages. Sweep before its first command
// in this directory; subsequent/concurrent commands may own live files.
if !directories.contains(directory.path) {
for file in try FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey]
) where file.lastPathComponent.hasPrefix(prefix) {
let suffix = file.lastPathComponent.dropFirst(prefix.count)
let token = String(suffix.prefix(36))
let values = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
if UUID(uuidString: token) != nil, suffix.dropFirst(36).hasPrefix("."),
values.isRegularFile == true, values.isSymbolicLink != true {
try FileManager.default.removeItem(at: file)
}
}
directories.insert(directory.path)
}
let staged = directory.appendingPathComponent("\(prefix)\(UUID().uuidString).\(target.pathExtension)")
try Data().write(to: staged, options: .withoutOverwriting)
return staged
}
}
func executeCoreFFmpegCommand(command: CoreFFmpegCommand, cancelled: @escaping () -> Bool,
execute: ([String], @escaping () -> Bool) -> (Bool, String)) -> (Bool, String) {
if cancelled() { return (false, "cancelled") }
guard let output = command.output_path, !output.isEmpty else {
return execute(command.arguments, cancelled)
}
guard command.arguments.last == output else { return (false, "FFmpeg output does not match command") }
let target = URL(fileURLWithPath: output)
do {
let staged = try CoreFFmpegStaging.create(for: target)
defer { try? FileManager.default.removeItem(at: staged) }
var arguments = command.arguments
arguments[arguments.count - 1] = staged.path
let result = execute(arguments, cancelled)
if cancelled() { return (false, "cancelled") }
if !result.0 { return result }
// POSIX rename replaces the destination atomically on the same filesystem.
guard rename(staged.path, target.path) == 0 else {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
}
return result
} catch {
return (false, error.localizedDescription)
}
}
private func executeCoreFFmpeg(arguments: [String], cancelled: @escaping () -> Bool) -> (Bool, String) {
if cancelled() { return (false, "cancelled") }
let finished = DispatchSemaphore(value: 0)
guard let session = FFmpegKit.execute(withArgumentsAsync: arguments, withCompleteCallback: { _ in finished.signal() }) else {
return (false, "FFmpeg session could not start")
}
while finished.wait(timeout: .now() + 0.2) == .timedOut {
if cancelled() {
session.cancel()
_ = finished.wait(timeout: .now() + 5)
return (false, "cancelled")
}
}
if cancelled() { return (false, "cancelled") }
return (ReturnCode.isSuccess(session.getReturnCode()), session.getOutput() ?? "")
}
/// Owns one operation's directory access through finalization.
final class CoreDirectoryScope {
private let lock = NSLock()
private var release: (() -> Void)?
init(_ release: @escaping () -> Void) { self.release = release }
func close() {
lock.lock()
defer { lock.unlock() }
let action = release
release = nil
action?()
}
deinit { close() }
}
/// Native migration boundary; each process selects one stateful backend.
protocol CoreBackend {
var implementation: String { get }
var routesApplication: Bool { get }
func invokeApplication(method: String, arguments: Any?) throws -> Any?
func completeAuthCallback(state: String, code: String, sessionGrant: Bool, onResolved: (String) -> Void) throws
func openDownloadProgress() throws -> CoreDownloadProgress
func cancelActiveDownloads() throws -> String
func downloadByStrategy(requestJson: String) throws -> String
func runPostProcessing(inputJson: String, metadataJson: String) throws -> String
func buildFilename(template: String, metadataJson: String) throws -> String
func sanitizeFilename(filename: String) -> String
func fileMetadataImplementation(path: String) -> String
func readFileMetadata(path: String, hint: String) throws -> String
func editFileMetadata(path: String, metadataJson: String) throws -> String
func reEnrichFile(requestJson: String) throws -> String
func rewriteSplitArtistTags(path: String, artist: String, albumArtist: String) throws -> String
func extractCoverToFile(audioPath: String, outputPath: String) throws
func writeM4aFreeformTags(path: String, metadataJson: String) throws -> String
func ensureAc4Config(path: String, reference: String) throws -> String
func writeAc4Metadata(path: String, metadataJson: String, coverPath: String) throws -> String
func setLibraryCoverCacheDirectory(path: String) throws
func scanLibraryFolder(folder: String) throws -> String
func scanLibraryFolderToNdjsonFile(folder: String, output: String) throws -> Int
func scanLibraryFolderIncremental(folder: String, existing: String) throws -> String
func getLibraryScanProgress() throws -> String
func cancelLibraryScan() throws
func parseCueSheet(path: String, audioDirectory: String) throws -> String
func openDownloadDirectory(path: String) throws -> CoreDirectoryScope
func createTemporaryMediaFile(prefix: String, suffix: String) throws -> URL
func downloadCoverToFileSized(url: String, outputPath: String, maxDimension: Int64) throws
func releaseIdleResources() throws
func releaseMemoryUnderPressure() throws
}
extension CoreBackend {
var routesApplication: Bool { false }
func invokeApplication(method: String, arguments: Any?) throws -> Any? {
throw NSError(domain: "CoreBackend", code: 1, userInfo: [NSLocalizedDescriptionKey: "Application routing is unavailable for \(method)"])
}
}
final class RustCoreBackend: CoreBackend {
static let shared = RustCoreBackend()
let implementation = "rust"
let routesApplication = true
private let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("rust-core-pilot", isDirectory: true)
private let ownerLock = NSLock()
private var manager: ExtensionManager?
private var repository: ExtensionRepository?
private var requests: CancellationRegistry?
private var identity: [String]?
private var loggingEnabled = false
private var allowPrivateNetwork = false
private var allowHttpFallback = false
private var fallbackProviders: [String]?
private var directoryScopes: [String: CoreDirectoryScope] = [:]
private var libraryCoverScope: CoreDirectoryScope?
private init() {}
private func withFFmpegCommands(_ current: ExtensionManager, block: () throws -> String) throws -> String {
let commands = try current.environment().ffmpegCommands()
let execution = CoreFFmpegExecution(wait: { timeout in
try JSONDecoder().decode([CoreFFmpegCommand].self, from: Data(commands.waitPending(timeoutMs: timeout).utf8))
}, active: { id in
try !commands.getCommand(commandId: id).isEmpty
}, complete: { id, success, output, error in
_ = try commands.complete(commandId: id, success: success, output: output, error: error)
})
return try execution.run(block: block)
}
func downloadByStrategy(requestJson: String) throws -> String {
let current = try owner()
return try withFFmpegCommands(current) { try current.downloadByStrategy(requestJson: requestJson) }
}
func runPostProcessing(inputJson: String, metadataJson: String) throws -> String {
let current = try owner()
return try withFFmpegCommands(current) {
try current.runPostProcessing(inputJson: inputJson, metadataJson: metadataJson, timeoutMs: 120_000)
}
}
private func owner() throws -> ExtensionManager {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager else { throw failure("Rust backend is not initialized") }
return manager
}
private func requestRegistryLocked() -> CancellationRegistry {
if let requests = requests { return requests }
let created = CancellationRegistry(domain: .extensionRequest)
requests = created
return created
}
private func acquireRequest(_ id: String) throws -> (ExtensionManager, RequestLease) {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager else { throw failure("Rust backend is not initialized") }
return (manager, try requestRegistryLocked().acquire(id: id))
}
private func repositoryOwner() throws -> ExtensionRepository {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let repository = repository else { throw failure("Extension repository is not initialized") }
return repository
}
private func initializeRepository(_ cachePath: String) throws {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager else { throw failure("Rust backend is not initialized") }
if repository != nil { return }
guard cachePath.hasPrefix("/") else { throw failure("Repository cache directory must be absolute") }
let cache = URL(fileURLWithPath: cachePath).resolvingSymlinksInPath().standardizedFileURL.path
repository = try ExtensionRepository(manager: manager, cacheDirectory: cache)
}
private func failure(_ message: String) -> NSError {
NSError(domain: "RustCoreBackend", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
}
private func directoryAliases(_ value: String, sources: String, data: String) throws -> [String] {
guard value.hasPrefix("/") else { throw failure("Output directories must be absolute") }
let url = URL(fileURLWithPath: value)
let path = url.resolvingSymlinksInPath().standardizedFileURL.path
guard path != "/", ![sources, data].contains(where: {
path == $0 || path.hasPrefix($0 + "/") || $0.hasPrefix(path + "/")
}) else { throw failure("Output directory overlaps extension storage") }
let original = url.standardizedFileURL.path
return path == original ? [path] : [path, original]
}
private func openDownloadDirectoryLocked(path: String) throws -> CoreDirectoryScope {
guard let manager = manager, let storage = identity else { throw failure("Rust backend is not initialized") }
let paths = try directoryAliases(path, sources: storage[0], data: storage[1])
let scope = try manager.environment().grantDownloadDirectories(directories: paths)
return CoreDirectoryScope { scope.release() }
}
func openDownloadDirectory(path: String) throws -> CoreDirectoryScope {
ownerLock.lock()
defer { ownerLock.unlock() }
return try openDownloadDirectoryLocked(path: path)
}
private func withLibraryDirectories<T>(_ paths: [String], block: (ExtensionManager) throws -> T) throws -> T {
let (current, scope): (ExtensionManager, CoreDirectoryScope) = try {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager, let storage = identity else { throw failure("Rust backend is not initialized") }
let aliases = try paths.flatMap { try directoryAliases($0, sources: storage[0], data: storage[1]) }
let grant = try manager.environment().grantDownloadDirectories(directories: Array(Set(aliases)))
return (manager, CoreDirectoryScope { grant.release() })
}()
defer { scope.close() }
// Progress and cancellation must remain available while the scan runs.
return try block(current)
}
func setLibraryCoverCacheDirectory(path: String) throws {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager else { throw failure("Rust backend is not initialized") }
guard path.isEmpty || path.hasPrefix("/") else { throw failure("Library cover directory must be absolute") }
let directory = path.isEmpty ? nil : URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL
if let directory = directory { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) }
let next = try directory.map { try openDownloadDirectoryLocked(path: $0.path) }
do { try manager.setLibraryCoverCacheDirectory(directory: directory?.path ?? "") }
catch { next?.close(); throw error }
libraryCoverScope?.close()
libraryCoverScope = next
}
func scanLibraryFolder(folder: String) throws -> String {
try withLibraryDirectories([folder]) {
try $0.scanLibraryFolder(folder: URL(fileURLWithPath: folder).resolvingSymlinksInPath().standardizedFileURL.path, lease: nil)
}
}
func scanLibraryFolderToNdjsonFile(folder: String, output: String) throws -> Int {
guard output.hasPrefix("/"), URL(fileURLWithPath: output).pathExtension.lowercased() == "ndjson" else {
throw failure("Library scan output must be an absolute NDJSON path")
}
return try withLibraryDirectories([folder]) { current in
// Application support also contains extension storage. Stage inside
// the existing private root instead of granting that whole directory.
let staged = root.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("library_scan_\(UUID().uuidString).ndjson")
defer { try? FileManager.default.removeItem(at: staged) }
let count = try current.scanLibraryFolderToNdjsonFile(
folder: URL(fileURLWithPath: folder).resolvingSymlinksInPath().standardizedFileURL.path,
output: staged.resolvingSymlinksInPath().path, lease: nil)
guard rename(staged.path, output) == 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) }
return Int(count)
}
}
func scanLibraryFolderIncremental(folder: String, existing: String) throws -> String {
try withLibraryDirectories([folder]) {
try $0.scanLibraryFolderIncremental(folder: URL(fileURLWithPath: folder).resolvingSymlinksInPath().standardizedFileURL.path,
existingJson: existing, lease: nil)
}
}
func getLibraryScanProgress() throws -> String {
ownerLock.lock()
let current = manager
ownerLock.unlock()
return try current?.getLibraryScanProgress() ?? "{}"
}
func cancelLibraryScan() throws {
ownerLock.lock()
let current = manager
ownerLock.unlock()
try current?.cancelLibraryScan()
}
func parseCueSheet(path: String, audioDirectory: String) throws -> String {
let cue = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL
let audio = audioDirectory.isEmpty ? cue.deletingLastPathComponent() : URL(fileURLWithPath: audioDirectory).resolvingSymlinksInPath().standardizedFileURL
return try withLibraryDirectories([cue.deletingLastPathComponent().path, audio.path]) {
try $0.parseCueFileJson(path: cue.path, audioDirectory: audio.path, lease: nil)
}
}
func createTemporaryMediaFile(prefix: String, suffix: String) throws -> URL {
_ = try owner()
let file = root.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("\(prefix)\(UUID().uuidString)\(suffix)")
try Data().write(to: file, options: .withoutOverwriting)
return file
}
func downloadCoverToFileSized(url: String, outputPath: String, maxDimension: Int64) throws {
try owner().downloadCoverToFileSized(url: url,
outputPath: URL(fileURLWithPath: outputPath).resolvingSymlinksInPath().standardizedFileURL.path,
maxDimension: maxDimension, lease: nil)
}
func releaseIdleResources() throws {
try owner().releaseMemory(underPressure: false)
}
func releaseMemoryUnderPressure() throws {
try owner().releaseMemory(underPressure: true)
}
private func initializeOwner(_ arguments: [String: Any]) throws {
guard let sourcePath = arguments["extensions_dir"] as? String,
let dataPath = arguments["data_dir"] as? String,
let key = arguments["master_key"] as? String else {
throw failure("Extension storage arguments are required")
}
guard sourcePath.hasPrefix("/"), dataPath.hasPrefix("/") else {
throw failure("Extension storage directories must be absolute")
}
let sources = URL(fileURLWithPath: sourcePath).resolvingSymlinksInPath().standardizedFileURL.path
let data = URL(fileURLWithPath: dataPath).resolvingSymlinksInPath().standardizedFileURL.path
let keyHash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
let requested = [sources, data, keyHash]
ownerLock.lock()
defer { ownerLock.unlock() }
let files = root.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: files, withIntermediateDirectories: true)
guard arguments["allowed_directories"] == nil || arguments["allowed_directories"] is [String] else {
throw failure("Invalid output directories")
}
var allowedDirectories = [files.resolvingSymlinksInPath().path, files.standardizedFileURL.path]
for value in arguments["allowed_directories"] as? [String] ?? [] {
for alias in try directoryAliases(value, sources: sources, data: data) where !allowedDirectories.contains(alias) {
allowedDirectories.append(alias)
}
}
if manager != nil {
guard identity == requested else { throw failure("Rust backend is already initialized with different storage") }
try manager!.environment().setAllowedDownloadDirectories(directories: allowedDirectories)
return
}
let created = try ExtensionManager.withLyricsSettings(
sourceDirectory: sources,
dataDirectory: data,
masterKey: key,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1",
timeoutMs: 30000,
providersJson: arguments["lyrics_providers_json"] as? String ?? "[]",
optionsJson: arguments["lyrics_options_json"] as? String ?? "{}"
)
do {
let environment = created.environment()
try environment.setAllowedDownloadDirectories(directories: allowedDirectories)
try environment.setAllowPrivateNetwork(allow: allowPrivateNetwork)
try environment.setNetworkCompatibilityOptions(allowHttp: allowHttpFallback, insecureTls: false)
try environment.logBuffer().setEnabled(enabled: loggingEnabled)
try created.setFallbackProviders(ids: fallbackProviders)
} catch {
created.shutdown()
throw error
}
identity = requested
manager = created
}
private func shutdownOwner() {
ownerLock.lock()
defer { ownerLock.unlock() }
requests?.shutdown()
requests = nil
repository?.shutdown()
repository = nil
manager?.shutdown()
manager = nil
directoryScopes.values.forEach { $0.close() }
directoryScopes.removeAll()
libraryCoverScope?.close()
libraryCoverScope = nil
identity = nil
}
func fileMetadataImplementation(path: String) -> String { "rust" }
func readFileMetadata(path: String, hint: String) throws -> String {
try SpotiFLACBackend.readFileMetadata(path: path, hint: hint, lease: nil)
}
func editFileMetadata(path: String, metadataJson: String) throws -> String {
try owner().editFileMetadata(path: URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path, metadataJson: metadataJson, lease: nil)
}
private func mediaPath(_ path: String) -> String {
path.isEmpty ? "" : URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path
}
func reEnrichFile(requestJson: String) throws -> String {
guard var request = try JSONSerialization.jsonObject(with: Data(requestJson.utf8)) as? [String: Any] else {
throw failure("Re-enrich request must be an object")
}
if request["preview_only"] as? Bool != true,
let path = request["file_path"] as? String, path.hasPrefix("/") {
request["file_path"] = mediaPath(path)
}
let data = try JSONSerialization.data(withJSONObject: request)
return try owner().reenrichFile(requestJson: String(decoding: data, as: UTF8.self), lease: nil)
}
func rewriteSplitArtistTags(path: String, artist: String, albumArtist: String) throws -> String {
try owner().rewriteSplitArtistTags(path: mediaPath(path), artist: artist, albumArtist: albumArtist, lease: nil)
}
func extractCoverToFile(audioPath: String, outputPath: String) throws {
try owner().extractCoverToFile(audioPath: mediaPath(audioPath), outputPath: mediaPath(outputPath), lease: nil)
}
func writeM4aFreeformTags(path: String, metadataJson: String) throws -> String {
try owner().writeM4aFreeformTags(path: mediaPath(path), metadataJson: metadataJson, lease: nil)
}
func ensureAc4Config(path: String, reference: String) throws -> String {
try owner().ensureAc4Config(path: mediaPath(path), reference: mediaPath(reference), lease: nil)
}
func writeAc4Metadata(path: String, metadataJson: String, coverPath: String) throws -> String {
try owner().writeAc4Metadata(path: mediaPath(path), metadataJson: metadataJson, coverPath: mediaPath(coverPath), lease: nil)
}
func completeAuthCallback(state: String, code: String, sessionGrant: Bool, onResolved: (String) -> Void) throws {
let current = try owner()
let environment = current.environment()
let id = try (sessionGrant ? environment.resolveCallbackState(state: state) : environment.consumeCallbackState(state: state))
onResolved(id)
if sessionGrant {
try completeSessionGrant(current, id: id, grant: code)
} else {
try environment.setAuthCode(extensionId: id, code: code)
_ = try current.invokeAction(extensionId: id, action: "completeSpotifyLogin")
}
}
func openDownloadProgress() throws -> CoreDownloadProgress {
let subscription = try owner().environment().downloadState().subscribeProgress()
return CoreDownloadProgress(
wait: { try subscription.waitDelta(since: $0, timeoutMs: $1) },
close: { subscription.stop() }
)
}
func cancelActiveDownloads() throws -> String {
let ids = try owner().environment().downloadState().cancelActiveDownloads()
return String(decoding: try JSONSerialization.data(withJSONObject: ids), as: UTF8.self)
}
private func completeSessionGrant(_ current: ExtensionManager, id: String, grant: String) throws {
try current.environment().setSessionGrant(extensionId: id, grant: grant)
let response = try current.invokeAction(extensionId: id, action: "completeGrant")
try requireSuccessfulExtensionAction(extensionId: id, actionName: "completeGrant", response: response)
}
func invokeApplication(method: String, arguments: Any?) throws -> Any? {
let args = arguments as? [String: Any] ?? [:]
func string(_ key: String, _ fallback: String = "") -> String { args[key] as? String ?? fallback }
func ids(_ raw: String) throws -> [String] {
guard let value = try JSONSerialization.jsonObject(with: Data(raw.utf8)) as? [String] else {
throw failure("Expected an array of extension IDs")
}
return value
}
switch method {
case "cancelExtensionRequest":
ownerLock.lock()
defer { ownerLock.unlock() }
try requestRegistryLocked().cancel(id: string("request_id"))
return nil
case "customSearchWithExtension", "getExtensionHomeFeed":
let (current, lease) = try acquireRequest(string("request_id"))
defer { lease.release() }
if method == "customSearchWithExtension" {
return try current.customSearchJson(extensionId: string("extension_id"), query: string("query"), optionsJson: string("options"), lease: lease)
}
return try current.getExtensionHomeFeedJson(extensionId: string("extension_id"), lease: lease)
case "initExtensionSystem": try initializeOwner(args); return nil
case "setDownloadDirectory":
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager, let storage = identity else { throw failure("Rust backend is not initialized") }
let files = root.appendingPathComponent("files", isDirectory: true)
var paths = [files.resolvingSymlinksInPath().path, files.standardizedFileURL.path]
let path = string("path")
if !path.isEmpty { paths += try directoryAliases(path, sources: storage[0], data: storage[1]) }
try manager.environment().setAllowedDownloadDirectories(directories: paths)
return nil
case "acquireDownloadDirectory":
ownerLock.lock()
defer { ownerLock.unlock() }
let scope = try openDownloadDirectoryLocked(path: string("path"))
let token = UUID().uuidString
directoryScopes[token] = scope
return token
case "releaseDownloadDirectory":
ownerLock.lock()
let scope = directoryScopes.removeValue(forKey: string("token"))
ownerLock.unlock()
scope?.close()
return nil
case "initExtensionRepo": try initializeRepository(string("cache_dir")); return nil
case "getRepoRegistryUrl": return try repositoryOwner().registryUrl()
case "setRepoRegistryUrl": try repositoryOwner().setRegistryUrl(url: string("registry_url")); return nil
case "clearRepoRegistryUrl": try repositoryOwner().clearRegistryUrl(); return nil
case "getRepoExtensions": return try repositoryOwner().extensions(forceRefresh: args["force_refresh"] as? Bool ?? false)
case "searchRepoExtensions": return try repositoryOwner().search(query: string("query"), category: string("category"))
case "getRepoCategories":
return String(decoding: try JSONSerialization.data(withJSONObject: repositoryOwner().categories()), as: UTF8.self)
case "downloadRepoExtension": return try repositoryOwner().download(extensionId: string("extension_id"), destinationDirectory: string("dest_dir"))
case "clearRepoCache": try repositoryOwner().clearCache(); return nil
case "cleanupExtensions": shutdownOwner(); return nil
case "buildFilename": return try buildFilename(template: string("template"), metadataJson: string("metadata", "{}"))
case "sanitizeFilename": return sanitizeFilename(filename: string("filename"))
case "readFileMetadata": return try readFileMetadata(path: string("file_path"), hint: string("display_name"))
case "editFileMetadata": return try editFileMetadata(path: string("file_path"), metadataJson: string("metadata_json", "{}"))
case "getLogsSince":
ownerLock.lock()
defer { ownerLock.unlock() }
return try manager?.environment().logBuffer().since(index: (args["index"] as? NSNumber)?.int64Value ?? 0) ?? "{\"logs\":[],\"next_index\":0}"
case "clearLogs":
ownerLock.lock()
defer { ownerLock.unlock() }
try manager?.environment().logBuffer().clear()
return nil
case "setLoggingEnabled", "setAllowPrivateNetwork", "setDownloadFallbackExtensionIds", "setLyricsProviders", "setLyricsFetchOptions", "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions":
ownerLock.lock()
defer { ownerLock.unlock() }
switch method {
case "setLoggingEnabled":
let enabled = args["enabled"] as? Bool ?? false
try manager?.environment().logBuffer().setEnabled(enabled: enabled)
loggingEnabled = enabled
case "setAllowPrivateNetwork":
let allowed = args["allowed"] as? Bool ?? false
try manager?.environment().setAllowPrivateNetwork(allow: allowed)
allowPrivateNetwork = allowed
case "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions":
let allowed = args["allow_http"] as? Bool ?? false
let insecureTLS = args["insecure_tls"] as? Bool ?? false
try manager?.environment().setNetworkCompatibilityOptions(allowHttp: allowed, insecureTls: insecureTLS)
allowHttpFallback = allowed
case "setDownloadFallbackExtensionIds":
let raw = string("extension_ids").trimmingCharacters(in: .whitespacesAndNewlines)
let value = raw.isEmpty || raw == "null" ? nil : try ids(raw)
try manager?.setFallbackProviders(ids: value)
fallbackProviders = value
case "setLyricsProviders":
let raw = string("providers_json", "[]")
if let manager = manager { try manager.setLyricsProvidersJson(providersJson: raw) }
else { _ = try ids(raw) }
return "{\"success\":true}"
case "setLyricsFetchOptions":
let raw = string("options_json", "{}")
if let manager = manager { try manager.setLyricsFetchOptionsJson(optionsJson: raw) }
else { _ = try JSONSerialization.jsonObject(with: Data(raw.utf8)) }
return "{\"success\":true}"
default: break
}
return nil
default: break
}
let current = try owner()
switch method {
case "loadExtensionsFromDir":
let path = URL(fileURLWithPath: string("dir_path")).resolvingSymlinksInPath().standardizedFileURL.path
ownerLock.lock()
let matches = identity?.first == path
ownerLock.unlock()
guard matches else { throw failure("Extension source directory does not match the initialized owner") }
return try current.loadAll()
case "loadExtensionFromPath": return try current.install(packagePath: string("file_path"))
case "upgradeExtension": return try current.upgrade(packagePath: string("file_path"))
case "checkExtensionUpgrade": return try current.checkUpgrade(packagePath: string("file_path"))
case "getInstalledExtensions": return try current.installed()
case "setExtensionEnabled": try current.setEnabled(extensionId: string("extension_id"), enabled: args["enabled"] as? Bool ?? false); return nil
case "unloadExtension": try current.unload(extensionId: string("extension_id")); return nil
case "removeExtension": try current.remove(extensionId: string("extension_id")); return nil
case "getExtensionSettings": return try current.environment().settings(extensionId: string("extension_id"))
case "setExtensionSettings": try current.updateSettings(extensionId: string("extension_id"), settingsJson: string("settings", "{}")); return nil
case "invokeExtensionAction": return try current.invokeAction(extensionId: string("extension_id"), action: string("action"))
case "checkExtensionHealth": return try current.checkExtensionHealthJson(extensionId: string("extension_id"))
case "searchTracksWithMetadataProviders":
return try current.searchMetadataProviders(query: string("query"), limit: (args["limit"] as? NSNumber)?.int64Value ?? 20, includeExtensions: args["include_extensions"] as? Bool ?? true, itemId: "", timeoutMs: 30000)
case "searchTracksWithMetadataProvider":
return try current.searchMetadataProvider(extensionId: string("extension_id"), query: string("query"), limit: (args["limit"] as? NSNumber)?.int64Value ?? 20, timeoutMs: 30000)
case "getProviderMetadata":
return try current.getProviderMetadataJson(providerId: string("provider_id"), resourceType: string("resource_type"), resourceId: string("resource_id"), lease: nil)
case "findCollectionAcrossExtensions":
return try current.findCollectionAcrossExtensionsJson(requestJson: arguments as? String ?? "{}", lease: nil)
case "enrichTrackWithExtension": return try current.enrichTrackJson(extensionId: string("extension_id"), trackJson: string("track", "{}"))
case "handleURLWithExtension": return try current.handleUrlJson(url: string("url"))
case "findURLHandler": return try current.findUrlHandler(url: string("url")) ?? ""
case "searchDeezerByISRC": return try current.searchDeezerByIsrcForItemId(isrc: string("isrc"), itemId: string("item_id"), lease: nil)
case "getDeezerExtendedMetadata": return try current.getDeezerExtendedMetadata(trackId: string("track_id"), lease: nil)
case "convertSpotifyToDeezer": return try current.convertSpotifyToDeezer(resourceType: string("resource_type"), spotifyId: string("spotify_id"), lease: nil)
case "getSpotifyIDFromDeezerTrack": return try current.getSpotifyIdFromDeezerTrack(trackId: string("deezer_track_id"), lease: nil)
case "getTidalURLFromDeezerTrack": return try current.getTidalUrlFromDeezerTrack(trackId: string("deezer_track_id"), lease: nil)
case "getTrackPlatformLinks": return try current.getTrackPlatformLinksJson(spotifyId: string("spotify_id"), isrc: string("isrc"), lease: nil)
case "fetchMusicBrainzTags":
let genre = (try? current.fetchMusicBrainzGenreByIsrc(isrc: string("isrc"), lease: nil)) ?? ""
let albumArtist = (try? current.fetchMusicBrainzAlbumArtistByIsrc(isrc: string("isrc"), albumName: string("album_name"), lease: nil)) ?? ""
return String(decoding: try JSONSerialization.data(withJSONObject: ["genre": genre, "album_artist": albumArtist]), as: UTF8.self)
case "getTrackCacheSize": return Int(try current.getTrackCacheSize())
case "readAudioMetadata": return try current.readAudioMetadata(path: string("file_path"), hint: "", cacheKey: "", lease: nil)
case "clearTrackCache": try current.clearTrackIdCache(); return nil
case "setMetadataLanguage": try current.setMetadataLanguage(tag: string("tag")); return nil
case "downloadByStrategy", "downloadWithExtensions":
guard let request = arguments as? String else {
throw NSError(domain: "CoreBackend", code: 1, userInfo: [NSLocalizedDescriptionKey: "Download request must be a JSON string"])
}
return try withFFmpegCommands(current) {
if method == "downloadWithExtensions" { return try current.downloadWithExtensionsJson(requestJson: request) }
return try current.downloadByStrategy(requestJson: request)
}
case "getAllDownloadProgress": return try current.environment().downloadState().allProgress()
case "cleanupConnections": try current.environment().cleanupConnections(); return nil
case "clearItemProgress": try current.environment().downloadState().clearItemProgress(itemId: string("item_id")); return nil
case "cancelDownload": try current.environment().downloadState().cancelDownload(itemId: string("item_id")); return nil
case "resetDownloadCancel": try current.environment().downloadState().resetDownloadCancel(itemId: string("item_id")); return nil
case "getExtensionPendingAuth": return try current.getExtensionPendingAuthJson(extensionId: string("extension_id"))
case "setExtensionAuthCode": try current.environment().setAuthCode(extensionId: string("extension_id"), code: string("auth_code")); return nil
case "completeExtensionSessionGrant":
try completeSessionGrant(current, id: string("extension_id"), grant: string("grant"))
return true
case "setExtensionTokens":
try current.environment().setAuthTokens(extensionId: string("extension_id"), accessToken: string("access_token"), refreshToken: string("refresh_token"), expiresIn: (args["expires_in"] as? NSNumber)?.int64Value ?? 0)
return nil
case "clearExtensionPendingAuth": try current.environment().clearPendingAuth(extensionId: string("extension_id")); return nil
case "isExtensionAuthenticated": return try current.environment().isAuthenticated(extensionId: string("extension_id"))
case "getAllPendingAuthRequests": return try current.environment().allPendingAuth()
case "getLyricsLRC", "getLyricsLRCWithSource", "fetchAndSaveLyrics":
let request = LyricsRequest(
spotifyId: string("spotify_id"),
track: string("track_name"),
artist: string("artist_name"),
filePath: string(method == "fetchAndSaveLyrics" ? "audio_file_path" : "file_path"),
durationMs: (args["duration_ms"] as? NSNumber)?.int64Value ?? 0
)
if method == "getLyricsLRC" { return try current.getLyricsLrc(request: request, lease: nil) }
if method == "getLyricsLRCWithSource" { return try current.getLyricsLrcWithSource(request: request, lease: nil) }
try current.fetchAndSaveLyrics(request: request, outputPath: string("output_path"), lease: nil)
return "{\"success\":true}"
case "getPendingFFmpegCommand":
return try current.environment().ffmpegCommands().getCommand(commandId: string("command_id"))
case "getAllPendingFFmpegCommands":
return try current.environment().ffmpegCommands().pending()
case "setFFmpegCommandResult":
_ = try current.environment().ffmpegCommands().complete(
commandId: string("command_id"),
success: args["success"] as? Bool ?? false,
output: string("output"),
error: string("error")
)
return nil
case "runPostProcessingV2":
return try withFFmpegCommands(current) {
try current.runPostProcessing(inputJson: string("input"), metadataJson: string("metadata"), timeoutMs: 120_000)
}
case "embedLyricsToFile":
return try current.embedLyricsToFile(path: string("file_path"), lyrics: string("lyrics"), lease: nil)
case "setProviderPriority", "setMetadataProviderPriority":
try current.setProviderPriority(kind: method == "setProviderPriority" ? "download" : "metadata", ids: ids(string("priority", "[]")))
return nil
case "getProviderPriority", "getMetadataProviderPriority":
let priorities = try JSONSerialization.jsonObject(with: Data(current.providerPriorities().utf8)) as! [String: Any]
let value = priorities[method == "getProviderPriority" ? "download" : "metadata"] as? [String] ?? []
return String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self)
case "getLyricsProviders": return try current.getLyricsProvidersJson()
case "getLyricsFetchOptions": return try current.getLyricsFetchOptionsJson()
case "getAvailableLyricsProviders": return try current.getAvailableLyricsProvidersJson()
default: throw failure("Rust application method is not connected yet: \(method)")
}
}
func buildFilename(template: String, metadataJson: String) throws -> String {
try SpotiFLACBackend.buildFilename(template: template, metadataJson: metadataJson)
}
func sanitizeFilename(filename: String) -> String {
SpotiFLACBackend.sanitizeFilename(filename: filename)
}
}
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)"]
)
}
func createCoreBackend() -> CoreBackend {
return RustCoreBackend.shared
}