mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
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:
1 parent
1da0da18a6
commit
aa99726439
452 files changed
+64417
-65088
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 */,
|
||||
|
||||
+112
-733
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user