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.
This commit is contained in:
zarzet committed 2026-09-14 22:58:27 +07:00
1 parent 1da0da18a6
commit aa99726439
452 files changed
+64417 -65088

No files matched your search

-4
View File
@@ -1,6 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
// Go backend framework (Gobackend.xcframework)
FRAMEWORK_SEARCH_PATHS=$(inherited) $(PROJECT_DIR)/Frameworks
OTHER_LDFLAGS=$(inherited) -framework Gobackend
-4
View File
@@ -1,6 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
// Go backend framework (Gobackend.xcframework)
FRAMEWORK_SEARCH_PATHS=$(inherited) $(PROJECT_DIR)/Frameworks
OTHER_LDFLAGS=$(inherited) -framework Gobackend
+10 -3
View File
@@ -63,10 +63,17 @@ target 'Runner' do
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
rust_pod_dir = File.expand_path('../rust_backend/target/ios', __dir__)
unless File.directory?(File.join(rust_pod_dir, 'SpotiFLACBackendFFI.xcframework')) &&
File.file?(File.join(rust_pod_dir, 'SpotiFLACBackend.podspec')) &&
File.file?(File.join(rust_pod_dir, 'SpotiFLACBackend.swift'))
raise 'Run bash scripts/build_rust_backend.sh ios before installing Rust application pods'
end
pod 'SpotiFLACBackend', :path => '../rust_backend/target/ios'
target 'RunnerTests' do
inherit! :search_paths
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
+7 -2
View File
@@ -2,21 +2,26 @@ PODS:
- Flutter (1.0.0)
- open_filex (0.0.2):
- Flutter
- SpotiFLACBackend (0.1.0)
DEPENDENCIES:
- Flutter (from `Flutter`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- SpotiFLACBackend (from `../rust_backend/target/ios`)
EXTERNAL SOURCES:
Flutter:
:path: Flutter
open_filex:
:path: ".symlinks/plugins/open_filex/ios"
SpotiFLACBackend:
:path: "../rust_backend/target/ios"
SPEC CHECKSUMS:
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
SpotiFLACBackend: e0c81656e8e4c31e86b9f14bbb8e245fb3a14011
PODFILE CHECKSUM: 56111b15e504c4f967a8ce631d1fc11455de8985
PODFILE CHECKSUM: 6ca58d78915f3e4fcb64938b9167b26e33d44a8a
COCOAPODS: 1.16.2
+10 -12
View File
@@ -7,20 +7,19 @@
objects = {
/* Begin PBXBuildFile section */
D08A594730CF000100000001 /* DownloadProgressSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08A594730CF000100000002 /* DownloadProgressSubscription.swift */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
68CFDDBCDE3A5678CC34B903 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4DCB20F642541E82BB2C12EC /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
A11CE1012F00000000000001 /* Gobackend.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CE1032F00000000000001 /* Gobackend.xcframework */; };
A11CE1022F00000000000001 /* Gobackend.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A11CE1032F00000000000001 /* Gobackend.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
A11CE0012F00000000000001 /* ExtensionCallbackParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11CE0022F00000000000001 /* ExtensionCallbackParser.swift */; };
BD09D99380BF342C986CFAD6 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 0448E6A8F05C4FAF8461F7C9 /* libPods-RunnerTests.a */; };
A7FCE2361461C6C5FBC10719 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 403DAD1CF5A7C09D11A461EF /* Pods_RunnerTests.framework */; };
C03400000000000000000001 /* CoreBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03400000000000000000002 /* CoreBackend.swift */; };
D08A594730CF000100000001 /* DownloadProgressSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08A594730CF000100000002 /* DownloadProgressSubscription.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -40,7 +39,6 @@
dstPath = "";
dstSubfolderSpec = 10;
files = (
A11CE1022F00000000000001 /* Gobackend.xcframework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
@@ -48,13 +46,12 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
D08A594730CF000100000002 /* DownloadProgressSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadProgressSubscription.swift; sourceTree = "<group>"; };
0448E6A8F05C4FAF8461F7C9 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
403DAD1CF5A7C09D11A461EF /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
4DCB20F642541E82BB2C12EC /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -69,11 +66,12 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
97EA1417A7115E3D3E4EBA2A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
A11CE1032F00000000000001 /* Gobackend.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Frameworks/Gobackend.xcframework; sourceTree = "<group>"; };
A11CE0022F00000000000001 /* ExtensionCallbackParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionCallbackParser.swift; sourceTree = "<group>"; };
B38518FC01BA7990B1B952FC /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
B7F67C6787B0752DCCCC62F3 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
C03400000000000000000002 /* CoreBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreBackend.swift; sourceTree = "<group>"; };
CE79C187C33AB874567BE0FA /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
D08A594730CF000100000002 /* DownloadProgressSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadProgressSubscription.swift; sourceTree = "<group>"; };
FB3B03A8181A430FB2E2796D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -82,7 +80,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A11CE1012F00000000000001 /* Gobackend.xcframework in Frameworks */,
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
68CFDDBCDE3A5678CC34B903 /* Pods_Runner.framework in Frameworks */,
);
@@ -92,7 +89,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BD09D99380BF342C986CFAD6 /* libPods-RunnerTests.a in Frameworks */,
A7FCE2361461C6C5FBC10719 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -164,6 +161,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
C03400000000000000000002 /* CoreBackend.swift */,
D08A594730CF000100000002 /* DownloadProgressSubscription.swift */,
A11CE0022F00000000000001 /* ExtensionCallbackParser.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
@@ -174,9 +172,8 @@
C6C88C23490A8D792BED2082 /* Frameworks */ = {
isa = PBXGroup;
children = (
A11CE1032F00000000000001 /* Gobackend.xcframework */,
4DCB20F642541E82BB2C12EC /* Pods_Runner.framework */,
0448E6A8F05C4FAF8461F7C9 /* libPods-RunnerTests.a */,
403DAD1CF5A7C09D11A461EF /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -400,6 +397,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
C03400000000000000000001 /* CoreBackend.swift in Sources */,
D08A594730CF000100000001 /* DownloadProgressSubscription.swift in Sources */,
A11CE0012F00000000000001 /* ExtensionCallbackParser.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
File diff suppressed because it is too large. Load diff
+811
View File
@@ -0,0 +1,811 @@
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
}
+65 -5
View File
@@ -1,5 +1,21 @@
import Foundation
final class CoreDownloadProgress {
private let waiter: (Int64, Int64) throws -> String
private let closer: () -> Void
init(wait: @escaping (Int64, Int64) throws -> String, close: @escaping () -> Void = {}) {
waiter = wait
closer = close
}
func waitDelta(_ sequence: Int64, _ timeout: Int64) throws -> String {
try waiter(sequence, timeout)
}
func close() { closer() }
}
/// Each listener owns its cursor and pending waiter. Lifecycle and delivery run
/// on the main queue; a cancelled wait may finish, but can only touch its own state.
final class DownloadProgressSubscription {
@@ -8,6 +24,7 @@ final class DownloadProgressSubscription {
let queue = DispatchQueue(label: "com.zarz.spotiflac.download_progress_subscription", qos: .utility)
private let lock = NSLock()
private var cancelled = false
private var source: CoreDownloadProgress?
// Accessed only on this session's queue.
var sequence: Int64 = 0
var lastPayload: String?
@@ -15,7 +32,37 @@ final class DownloadProgressSubscription {
func cancel() {
lock.lock()
cancelled = true
let closing = source
source = nil
lock.unlock()
closing?.close()
}
func connection(_ connect: () throws -> CoreDownloadProgress) throws -> CoreDownloadProgress? {
lock.lock()
let stopped = cancelled
let existing = source
lock.unlock()
if stopped { return nil }
if let existing { return existing }
let opened = try connect()
lock.lock()
if cancelled {
lock.unlock()
opened.close()
return nil
}
source = opened
lock.unlock()
return opened
}
func disconnect() {
lock.lock()
let closing = source
source = nil
lock.unlock()
closing?.close()
}
var isCancelled: Bool {
@@ -25,13 +72,17 @@ final class DownloadProgressSubscription {
}
}
private let waiter: Waiter
private let connect: () throws -> CoreDownloadProgress
private let interval: TimeInterval
private var current: Session?
init(interval: TimeInterval = 0.25, waiter: @escaping Waiter) {
convenience init(interval: TimeInterval = 0.25, waiter: @escaping Waiter) {
self.init(interval: interval, connect: { CoreDownloadProgress(wait: waiter) })
}
init(interval: TimeInterval = 0.25, connect: @escaping () throws -> CoreDownloadProgress) {
self.interval = interval
self.waiter = waiter
self.connect = connect
}
func start(_ receive: @escaping (Any) -> Void) {
@@ -51,10 +102,19 @@ final class DownloadProgressSubscription {
deinit { current?.cancel() }
private func poll(_ session: Session, receive: @escaping (Any) -> Void) {
let waiter = self.waiter
let connect = self.connect
session.queue.async { [weak self] in
guard !session.isCancelled else { return }
let payload = waiter(session.sequence, 15_000)
let payload: String
do {
guard let source = try session.connection(connect) else { return }
payload = try source.waitDelta(session.sequence, 15_000)
} catch {
session.disconnect()
session.sequence = 0
session.lastPayload = nil
payload = ""
}
guard !session.isCancelled else { return }
if !payload.isEmpty && payload != session.lastPayload,
let data = payload.data(using: .utf8),
+299
View File
@@ -3,6 +3,305 @@ import XCTest
@testable import Runner
class RunnerTests: XCTestCase {
private func isSymbolicLink(_ url: URL) -> Bool {
guard let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]) else { return false }
return values.isSymbolicLink == true
}
func testFFmpegPublishesOnlySuccessfulUncancelledOutput() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false)
defer { try? FileManager.default.removeItem(at: root) }
let target = root.appendingPathComponent("output.flac")
for outcome in ["cancel", "failure", "success"] {
try Data("original".utf8).write(to: target)
var cancelled = false
var staged: URL?
let command = CoreFFmpegCommand(command_id: outcome, arguments: ["-i", "input.wav", "-y", target.path], output_path: target.path)
let result = executeCoreFFmpegCommand(command: command, cancelled: { cancelled }, execute: { arguments, _ in
XCTAssertEqual(arguments[1], "input.wav")
staged = URL(fileURLWithPath: arguments.last!)
XCTAssertNotEqual(staged, target)
XCTAssertEqual(staged?.pathExtension, "flac")
do { try Data("converted".utf8).write(to: staged!) }
catch { XCTFail("staging write failed: \(error)"); return (false, "write failed") }
cancelled = outcome == "cancel"
return (outcome != "failure", "result")
})
XCTAssertEqual(result.0, outcome == "success")
XCTAssertEqual(try String(contentsOf: target, encoding: .utf8), outcome == "success" ? "converted" : "original")
XCTAssertFalse(FileManager.default.fileExists(atPath: staged!.path))
}
}
func testFFmpegCallerReturnKeepsOtherClaimedCommandAlive() {
let started = DispatchSemaphore(value: 0)
let release = DispatchSemaphore(value: 0)
let closed = expectation(description: "command handle released")
let lock = NSLock()
var isClosed = false
let execution = CoreFFmpegExecution(wait: { _ in
[CoreFFmpegCommand(command_id: "other", arguments: ["convert"])]
}, active: { _ in true }, complete: { id, success, _, _ in
XCTAssertEqual(id, "other")
XCTAssertTrue(success)
}, close: {
lock.lock()
isClosed = true
lock.unlock()
closed.fulfill()
})
let result = execution.run(execute: { _, cancelled in
started.signal()
XCTAssertEqual(release.wait(timeout: .now() + 2), .success)
XCTAssertFalse(cancelled())
return (true, "finished")
}) {
XCTAssertEqual(started.wait(timeout: .now() + 2), .success)
return "caller finished"
}
XCTAssertEqual(result, "caller finished")
lock.lock()
XCTAssertFalse(isClosed)
lock.unlock()
release.signal()
wait(for: [closed], timeout: 2)
}
func testFFmpegFirstExecutionSweepsOnlyOwnOrphanFilesAndPreservesOtherEntries() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false)
defer { try? FileManager.default.removeItem(at: root) }
let prefix = ".spotiflac-ffmpeg-\(Bundle.main.bundleIdentifier ?? "spotiflac")-"
let ownOrphan = root.appendingPathComponent("\(prefix)\(UUID().uuidString).flac")
try Data("orphan".utf8).write(to: ownOrphan)
let foreignFile = root.appendingPathComponent(".spotiflac-ffmpeg-foreign-\(UUID().uuidString).flac")
try Data("foreign".utf8).write(to: foreignFile)
let malformedFile = root.appendingPathComponent("\(prefix)not-a-uuid.flac")
try Data("malformed".utf8).write(to: malformedFile)
let preservedDirectory = root.appendingPathComponent("\(prefix)\(UUID().uuidString).flac")
try FileManager.default.createDirectory(at: preservedDirectory, withIntermediateDirectories: false)
let directoryContent = preservedDirectory.appendingPathComponent("content.txt")
try Data("directory content".utf8).write(to: directoryContent)
let symlinkTarget = root.appendingPathComponent("symlink-target.txt")
try Data("symlink content".utf8).write(to: symlinkTarget)
let symlink = root.appendingPathComponent("\(prefix)\(UUID().uuidString).flac")
try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: symlinkTarget)
let target = root.appendingPathComponent("output.flac")
let command = CoreFFmpegCommand(
command_id: "sweep",
arguments: ["convert", target.path],
output_path: target.path
)
var staged: URL?
let result = executeCoreFFmpegCommand(command: command, cancelled: { false }) { arguments, _ in
XCTAssertFalse(FileManager.default.fileExists(atPath: ownOrphan.path))
XCTAssertEqual(try? String(contentsOf: foreignFile, encoding: .utf8), "foreign")
XCTAssertEqual(try? String(contentsOf: malformedFile, encoding: .utf8), "malformed")
XCTAssertTrue(FileManager.default.fileExists(atPath: preservedDirectory.path))
XCTAssertEqual(try? String(contentsOf: directoryContent, encoding: .utf8), "directory content")
XCTAssertTrue(isSymbolicLink(symlink))
XCTAssertEqual(try? String(contentsOf: symlink, encoding: .utf8), "symlink content")
staged = URL(fileURLWithPath: arguments.last!)
do {
try Data("published".utf8).write(to: staged!)
} catch {
XCTFail("staging write failed: \(error)")
return (false, "write failed")
}
return (true, "converted")
}
XCTAssertTrue(result.0)
XCTAssertEqual(try String(contentsOf: target, encoding: .utf8), "published")
XCTAssertFalse(FileManager.default.fileExists(atPath: staged!.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: ownOrphan.path))
XCTAssertEqual(try String(contentsOf: foreignFile, encoding: .utf8), "foreign")
XCTAssertEqual(try String(contentsOf: malformedFile, encoding: .utf8), "malformed")
XCTAssertEqual(try String(contentsOf: directoryContent, encoding: .utf8), "directory content")
XCTAssertTrue(isSymbolicLink(symlink))
XCTAssertEqual(try String(contentsOf: symlink, encoding: .utf8), "symlink content")
}
func testFFmpegOverlappingExecutionsPreserveLiveStagingAndPublishBothOutputs() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false)
defer { try? FileManager.default.removeItem(at: root) }
let firstTarget = root.appendingPathComponent("first.flac")
let secondTarget = root.appendingPathComponent("second.flac")
let firstCommand = CoreFFmpegCommand(
command_id: "first",
arguments: ["convert-first", firstTarget.path],
output_path: firstTarget.path
)
let secondCommand = CoreFFmpegCommand(
command_id: "second",
arguments: ["convert-second", secondTarget.path],
output_path: secondTarget.path
)
var firstStage: URL?
var secondStage: URL?
let result = executeCoreFFmpegCommand(command: firstCommand, cancelled: { false }) { arguments, _ in
firstStage = URL(fileURLWithPath: arguments.last!)
do {
try Data("first staged".utf8).write(to: firstStage!)
} catch {
XCTFail("first staging write failed: \(error)")
return (false, "write failed")
}
let nestedResult = executeCoreFFmpegCommand(command: secondCommand, cancelled: { false }) { nestedArguments, _ in
secondStage = URL(fileURLWithPath: nestedArguments.last!)
XCTAssertTrue(FileManager.default.fileExists(atPath: firstStage!.path))
XCTAssertEqual(try? String(contentsOf: firstStage!, encoding: .utf8), "first staged")
do {
try Data("second published".utf8).write(to: secondStage!)
} catch {
XCTFail("second staging write failed: \(error)")
return (false, "write failed")
}
return (true, "second complete")
}
XCTAssertTrue(nestedResult.0)
XCTAssertEqual(try? String(contentsOf: secondTarget, encoding: .utf8), "second published")
XCTAssertTrue(FileManager.default.fileExists(atPath: firstStage!.path))
XCTAssertEqual(try? String(contentsOf: firstStage!, encoding: .utf8), "first staged")
return (true, "first complete")
}
XCTAssertTrue(result.0)
XCTAssertEqual(try String(contentsOf: firstTarget, encoding: .utf8), "first staged")
XCTAssertEqual(try String(contentsOf: secondTarget, encoding: .utf8), "second published")
XCTAssertFalse(FileManager.default.fileExists(atPath: firstStage!.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: secondStage!.path))
}
func testFFmpegCancellationAndInvalidArgumentsDoNotStopOtherCommands() {
let started = DispatchSemaphore(value: 0)
let release = DispatchSemaphore(value: 0)
let completed = DispatchSemaphore(value: 0)
let closed = expectation(description: "pump closed")
let lock = NSLock()
var active = Set(["empty", "a", "b"])
var claimed = false
let execution = CoreFFmpegExecution(wait: { _ in
lock.lock()
defer { lock.unlock() }
if claimed { return [] }
claimed = true
return [CoreFFmpegCommand(command_id: "empty", arguments: []),
CoreFFmpegCommand(command_id: "a", arguments: ["a"]),
CoreFFmpegCommand(command_id: "b", arguments: ["b"])]
}, active: { id in
lock.lock()
defer { lock.unlock() }
return active.contains(id)
}, complete: { id, success, _, error in
if id == "empty" { XCTAssertEqual(error, "FFmpeg arguments are empty") }
if id == "a" { XCTAssertFalse(success) }
if id == "b" {
XCTAssertTrue(success)
completed.signal()
}
}, close: { closed.fulfill() })
_ = execution.run(execute: { arguments, cancelled in
if arguments == ["a"] {
started.signal()
XCTAssertEqual(release.wait(timeout: .now() + 2), .success)
XCTAssertTrue(cancelled())
return (false, "cancelled")
}
XCTAssertEqual(arguments, ["b"])
XCTAssertFalse(cancelled())
return (true, "finished")
}) {
XCTAssertEqual(started.wait(timeout: .now() + 2), .success)
lock.lock()
active.remove("a")
lock.unlock()
release.signal()
XCTAssertEqual(completed.wait(timeout: .now() + 2), .success)
return "finished"
}
wait(for: [closed], timeout: 2)
}
func testFFmpegClosedRegistryReleasesHandle() {
let closed = DispatchSemaphore(value: 0)
let execution = CoreFFmpegExecution(wait: { _ in
throw NSError(domain: "closed owner", code: 1)
}, active: { _ in false }, complete: { _, _, _, _ in
XCTFail("Closed registry produced a command")
}, close: { closed.signal() })
_ = execution.run {
XCTAssertEqual(closed.wait(timeout: .now() + 2), .success)
return "finished"
}
}
func testProgressReconnectResetsCursorAfterOwnerShutdown() {
let first = expectation(description: "first owner delivered")
let replacement = expectation(description: "replacement owner delivered")
let lock = NSLock()
var connections = 0
var closes = 0
var cursors = [Int64]()
let stream = DownloadProgressSubscription(interval: 0.01, connect: {
lock.lock()
connections += 1
let owner = connections
lock.unlock()
return CoreDownloadProgress(wait: { sequence, _ in
lock.lock()
cursors.append(sequence)
lock.unlock()
if owner == 1 && sequence == 0 { return "{\"seq\":99,\"reset\":true,\"items\":{}}" }
if owner == 1 { throw NSError(domain: "closed owner", code: 1) }
return "{\"seq\":1,\"reset\":true,\"items\":{}}"
}, close: {
lock.lock()
closes += 1
lock.unlock()
})
})
stream.start { event in
let sequence = (event as? [String: Any])?["seq"] as? Int
if sequence == 99 { first.fulfill() }
if sequence == 1 { replacement.fulfill() }
}
wait(for: [first, replacement], timeout: 2)
stream.stop()
lock.lock()
XCTAssertEqual(connections, 2)
XCTAssertEqual(closes, 2)
XCTAssertEqual(Array(cursors.prefix(3)), [0, 99, 0])
lock.unlock()
}
func testProgressStopClosesBlockedOwnerSubscription() {
let started = expectation(description: "wait started")
let stopped = expectation(description: "wait stopped")
let released = DispatchSemaphore(value: 0)
let stream = DownloadProgressSubscription(connect: {
CoreDownloadProgress(wait: { _, _ in
started.fulfill()
XCTAssertEqual(released.wait(timeout: .now() + 2), .success)
stopped.fulfill()
return "{\"seq\":1,\"items\":{}}"
}, close: { released.signal() })
})
stream.start { _ in XCTFail("Stopped listener received an event") }
wait(for: [started], timeout: 2)
stream.stop()
wait(for: [stopped], timeout: 2)
}
func testProgressRestartDoesNotAcceptCancelledWaiterState() {
let oldStarted = expectation(description: "old waiter started")
let replacementDelivered = expectation(description: "replacement snapshot")