mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
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.
812 lines
43 KiB
Swift
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
|
|
}
|