Files
SpotiFLAC-Mobile/ios/Runner/AppDelegate.swift
T
Bartolo d957dde639 fix(ios): adopt the UIScene lifecycle required on iOS 27
On iOS 27 an app without a scene manifest launches to a blank launch
screen (and trips UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption
under the debugger). Add the manifest with a FlutterSceneDelegate subclass.

Under scenes the app delegate has no window at launch, so the backend
method/event channels and plugins are now registered in
didInitializeImplicitFlutterEngine. SceneDelegate forwards what UIKit now
sends to the scene instead: extension OAuth/session-grant redirect URLs
and the background/foreground transitions that keep downloads alive. The
folder picker and ASWebAuthenticationSession anchor use the active scene's
window.
2026-09-23 18:34:28 +02:00

884 lines
35 KiB
Swift

import AuthenticationServices
import Flutter
import UIKit
import UniformTypeIdentifiers
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private let CHANNEL = "com.zarz.spotiflac/backend"
private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream"
private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream"
private let LARGE_JSON_RESULT_FILE_KEY = "__json_file"
private let LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
private let streamQueue = DispatchQueue(label: "com.zarz.spotiflac.progress_stream", qos: .utility)
private lazy var downloadProgressSubscription = DownloadProgressSubscription(connect: { [weak self] in
guard let self else { throw NSError(domain: "CoreBackend", code: 1) }
return try self.coreBackend.openDownloadProgress()
})
private var libraryScanProgressTimer: DispatchSourceTimer?
private var libraryScanProgressEventSink: FlutterEventSink?
private var lastLibraryScanProgressPayload: String?
private var libraryScanProgressGeneration: UInt64 = 0
private var backendChannel: FlutterMethodChannel?
private let coreBackend: CoreBackend = createCoreBackend()
private var pendingSessionGrantEvents: [[String: Any]] = []
private let securityScopedAccessLock = NSLock()
private var securityScopedAccesses: [String: (URL, CoreDirectoryScope)] = [:]
/// Pending Flutter result for the native folder picker
private var pendingDirectoryPickerResult: FlutterResult?
/// Whether a download queue is active; while true a background assertion
/// is acquired before work starts and renewed on later background entries.
/// Main-thread only.
private var downloadsActive = false
private var downloadBackgroundTask: UIBackgroundTaskIdentifier = .invalid
/// Strong reference to the in-flight ASWebAuthenticationSession; the
/// session is deallocated (and its sheet dismissed) without it.
private var activeWebAuthSession: AnyObject?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// With the UIScene lifecycle (required from iOS 27) there is no root
// view controller yet: channels and plugins are registered once the
// storyboard's implicit engine exists, in
// didInitializeImplicitFlutterEngine below.
if let url = launchOptions?[.url] as? URL {
_ = handleExtensionOAuthRedirect(url: url)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
let messenger = engineBridge.applicationRegistrar.messenger()
let channel = FlutterMethodChannel(
name: CHANNEL,
binaryMessenger: messenger
)
backendChannel = channel
if !pendingSessionGrantEvents.isEmpty {
let events = pendingSessionGrantEvents
pendingSessionGrantEvents.removeAll()
for event in events {
channel.invokeMethod("extensionSessionGrantCompleted", arguments: event)
}
}
let downloadProgressEvents = FlutterEventChannel(
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
binaryMessenger: messenger
)
let libraryScanProgressEvents = FlutterEventChannel(
name: LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL,
binaryMessenger: messenger
)
channel.setMethodCallHandler { [weak self] call, result in
self?.handleMethodCall(call: call, result: result)
}
downloadProgressEvents.setStreamHandler(
ClosureStreamHandler(
onListen: { [weak self] _, events in
self?.startDownloadProgressStream(events)
return nil
},
onCancel: { [weak self] _ in
self?.stopDownloadProgressStream()
return nil
}
)
)
libraryScanProgressEvents.setStreamHandler(
ClosureStreamHandler(
onListen: { [weak self] _, events in
self?.startLibraryScanProgressStream(events)
return nil
},
onCancel: { [weak self] _ in
self?.stopLibraryScanProgressStream()
return nil
}
)
)
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
/// The window of the foreground scene. Under the UIScene lifecycle the
/// app delegate's own `window` is never set.
var activeWindow: UIWindow? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let active = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
return active?.windows.first { $0.isKeyWindow } ?? active?.windows.first ?? window
}
/// Extension return URLs:
/// - OAuth: spotiflac://callback?code=...&state=<one_time_nonce>
/// - Signed session: spotiflac://session-grant?grant=...&state=<one_time_nonce>
@discardableResult
func handleExtensionOAuthRedirect(url: URL) -> Bool {
guard let route = ExtensionCallbackParser.parse(url) else { return false }
streamQueue.async {
var extensionId = ""
do {
try self.coreBackend.completeAuthCallback(state: route.state, code: route.code, sessionGrant: route.isSessionGrant) {
extensionId = $0
}
if route.isSessionGrant {
DispatchQueue.main.async { [weak self] in
self?.notifySessionGrantCompleted(extensionId: extensionId)
}
}
} catch {
if extensionId.isEmpty {
NSLog("SpotiFLAC Mobile: Rejected invalid or expired extension callback")
} else {
NSLog("SpotiFLAC Mobile: Extension callback failed (code \((error as NSError).code))")
}
}
}
return true
}
private func notifySessionGrantCompleted(extensionId: String) {
let payload: [String: Any] = [
"extension_id": extensionId,
"success": true,
]
if let channel = backendChannel {
channel.invokeMethod("extensionSessionGrantCompleted", arguments: payload)
} else {
pendingSessionGrantEvents.append(payload)
}
}
override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if handleExtensionOAuthRedirect(url: url) {
return true
}
return super.application(app, open: url, options: options)
}
deinit {
stopDownloadProgressStream()
stopLibraryScanProgressStream()
}
private func startDownloadProgressStream(_ eventSink: @escaping FlutterEventSink) {
downloadProgressSubscription.start(eventSink)
}
private func stopDownloadProgressStream() {
downloadProgressSubscription.stop()
}
private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) {
stopLibraryScanProgressStream()
libraryScanProgressGeneration &+= 1
let generation = libraryScanProgressGeneration
libraryScanProgressEventSink = eventSink
lastLibraryScanProgressPayload = nil
let timer = DispatchSource.makeTimerSource(queue: streamQueue)
timer.schedule(deadline: .now(), repeating: .milliseconds(800))
timer.setEventHandler { [weak self] in
guard let self, self.libraryScanProgressGeneration == generation else { return }
let payload = (try? self.coreBackend.getLibraryScanProgress()) ?? "{}"
if payload == self.lastLibraryScanProgressPayload {
return
}
self.lastLibraryScanProgressPayload = payload
DispatchQueue.main.async { [weak self] in
guard let self, self.libraryScanProgressGeneration == generation else { return }
eventSink(self.parseJsonPayload(payload))
}
}
libraryScanProgressTimer = timer
timer.resume()
}
private func stopLibraryScanProgressStream() {
libraryScanProgressGeneration &+= 1
libraryScanProgressTimer?.setEventHandler {}
libraryScanProgressTimer?.cancel()
libraryScanProgressTimer = nil
libraryScanProgressEventSink = nil
lastLibraryScanProgressPayload = nil
}
private func parseJsonPayload(_ payload: String) -> Any {
guard let data = payload.data(using: .utf8) else {
return payload
}
do {
return try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
} catch {
return payload
}
}
private func bridgeJsonResult(_ payload: String) -> Any {
if payload.utf8.count < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES {
return payload
}
do {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("bridge_json_\(UUID().uuidString).json")
try payload.write(to: url, atomically: true, encoding: .utf8)
return [LARGE_JSON_RESULT_FILE_KEY: url.path]
} catch {
NSLog("SpotiFLAC: failed to spill large bridge JSON result to file: \(error.localizedDescription)")
return payload
}
}
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
let osMethods: Set<String> = ["getBackendImplementations", "startWebAuthSession", "beginBackgroundDownloadTask", "endBackgroundDownloadTask",
"pickIosDirectory", "createIosBookmarkFromPath", "resolveIosBookmark", "startAccessingIosBookmark", "stopAccessingIosBookmark", "downloadCoverToFile", "releaseMemory", "releaseMemoryUnderPressure",
"setLibraryCoverCacheDir", "scanLibraryFolder", "scanLibraryFolderToNDJSONFile", "scanLibraryFolderIncremental",
"getLibraryScanProgress", "cancelLibraryScan", "parseCueSheet", "extractCoverToFile",
"rewriteSplitArtistTags", "writeM4AFreeformTags", "ensureAC4Config", "writeAC4Metadata", "reEnrichFile"]
if coreBackend.routesApplication && !osMethods.contains(call.method) {
DispatchQueue.global(qos: .userInitiated).async {
do {
let response = try self.coreBackend.invokeApplication(method: call.method, arguments: call.arguments)
DispatchQueue.main.async { result(response) }
} catch {
DispatchQueue.main.async { result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil)) }
}
}
return
}
switch call.method {
case "beginBackgroundDownloadTask":
downloadsActive = true
// Request the assertion before the long-running Go call starts.
// Waiting for applicationDidEnterBackground is too late: iOS may
// suspend the process before the assertion is granted.
beginBackgroundDownloadTask()
result(nil)
return
case "endBackgroundDownloadTask":
downloadsActive = false
endBackgroundDownloadTask()
result(nil)
return
case "pickIosDirectory":
pickIosDirectory(result: result)
return
case "startWebAuthSession":
let args = call.arguments as? [String: Any] ?? [:]
let urlString = (args["url"] as? String) ?? ""
let callbackScheme = (args["callback_scheme"] as? String) ?? "spotiflac"
startWebAuthSession(
urlString: urlString,
callbackScheme: callbackScheme,
result: result
)
return
default:
break
}
DispatchQueue.global(qos: .userInitiated).async {
do {
let response = try self.invokeGoMethod(call: call)
DispatchQueue.main.async {
result(response)
}
} catch {
DispatchQueue.main.async {
result(FlutterError(code: "ERROR", message: error.localizedDescription, details: nil))
}
}
}
}
/// Runs a verification/OAuth page inside ASWebAuthenticationSession. The
/// session intercepts the callback scheme in-process — no OS-level URL
/// scheme registration is involved — so the flow completes even where the
/// app's scheme is not registered with iOS (sideload containers such as
/// LiveContainer). The callback URL is fed into the same deep-link handler
/// the OS path uses. Returns whether the session was presented; completion
/// is delivered later through the existing grant-event plumbing.
private func startWebAuthSession(
urlString: String,
callbackScheme: String,
result: @escaping FlutterResult
) {
guard #available(iOS 13.0, *) else {
result(false)
return
}
guard let url = URL(string: urlString), url.scheme?.lowercased() == "https" else {
result(false)
return
}
let scheme = callbackScheme.isEmpty ? "spotiflac" : callbackScheme
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: scheme
) { [weak self] callbackURL, error in
self?.activeWebAuthSession = nil
guard let callbackURL = callbackURL else {
if let error = error {
NSLog("SpotiFLAC: web auth session ended: \(error.localizedDescription)")
}
return
}
_ = self?.handleExtensionOAuthRedirect(url: callbackURL)
}
session.presentationContextProvider = self
// Share Safari's cookie store so captcha providers see an established
// browsing context instead of a blank ephemeral one.
session.prefersEphemeralWebBrowserSession = false
activeWebAuthSession = session
let started = session.start()
if !started {
activeWebAuthSession = nil
}
result(started)
}
override func applicationDidEnterBackground(_ application: UIApplication) {
super.applicationDidEnterBackground(application)
didEnterBackground()
}
override func applicationWillEnterForeground(_ application: UIApplication) {
super.applicationWillEnterForeground(application)
willEnterForeground()
}
/// Called by the application callbacks above and, under the UIScene
/// lifecycle (where UIKit no longer sends those), by SceneDelegate.
func didEnterBackground() {
if downloadsActive {
beginBackgroundDownloadTask()
}
}
func willEnterForeground() {
endBackgroundDownloadTask()
}
private func beginBackgroundDownloadTask() {
if downloadBackgroundTask != .invalid { return }
downloadBackgroundTask = UIApplication.shared.beginBackgroundTask(
withName: "SpotiFLACDownloads"
) { [weak self] in
if self?.downloadsActive == true {
NSLog("SpotiFLAC: download background task expired")
// Flutter channel delivery is asynchronous and iOS may suspend
// us immediately after this callback. Cancel live requests
// synchronously first; Dart then persists/requeues the items.
let cancelledItemIDs = (try? self?.coreBackend.cancelActiveDownloads()) ?? "[]"
self?.backendChannel?.invokeMethod(
"iosBackgroundDownloadExpired",
arguments: cancelledItemIDs
)
}
self?.endBackgroundDownloadTask()
}
}
private func endBackgroundDownloadTask() {
if downloadBackgroundTask != .invalid {
UIApplication.shared.endBackgroundTask(downloadBackgroundTask)
downloadBackgroundTask = .invalid
}
}
private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? {
switch call.method {
case "downloadByStrategy":
let requestJson = call.arguments as! String
return try coreBackend.downloadByStrategy(requestJson: requestJson)
case "acquireDownloadDirectory":
let args = call.arguments as! [String: Any]
try coreBackend.openDownloadDirectory(path: args["path"] as! String).close()
return ""
case "releaseDownloadDirectory": return nil
case "getBackendImplementations":
return [
"filename": coreBackend.implementation,
"file_metadata": coreBackend.fileMetadataImplementation(path: (call.arguments as? [String: Any])?["file_path"] as? String ?? ""),
"extensions": coreBackend.routesApplication ? "rust" : "go",
"downloads": coreBackend.implementation,
]
case "buildFilename":
let args = call.arguments as! [String: Any]
let template = args["template"] as! String
let metadata = args["metadata"] as! String
return try coreBackend.buildFilename(template: template, metadataJson: metadata)
case "sanitizeFilename":
let args = call.arguments as! [String: Any]
let filename = args["filename"] as! String
return coreBackend.sanitizeFilename(filename: filename)
case "rewriteSplitArtistTags":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let artist = args["artist"] as! String
let albumArtist = args["album_artist"] as! String
return try coreBackend.rewriteSplitArtistTags(path: filePath, artist: artist, albumArtist: albumArtist)
case "writeM4AFreeformTags":
let args = call.arguments as! [String: Any]
return try coreBackend.writeM4aFreeformTags(path: args["file_path"] as! String, metadataJson: args["metadata_json"] as? String ?? "{}")
case "ensureAC4Config":
let args = call.arguments as! [String: Any]
return try coreBackend.ensureAc4Config(path: args["file_path"] as! String, reference: args["source_path"] as? String ?? "")
case "writeAC4Metadata":
let args = call.arguments as! [String: Any]
return try coreBackend.writeAc4Metadata(path: args["file_path"] as! String, metadataJson: args["metadata_json"] as? String ?? "{}", coverPath: args["cover_path"] as? String ?? "")
case "downloadCoverToFile":
let args = call.arguments as! [String: Any]
let coverURL = args["cover_url"] as! String
let outputPath = args["output_path"] as! String
let maxDimension = max(0, (args["max_dimension"] as? NSNumber)?.int64Value ?? 0)
let temporary = outputPath.isEmpty ? try coreBackend.createTemporaryMediaFile(prefix: "cover_", suffix: ".jpg") : nil
do {
try coreBackend.downloadCoverToFileSized(url: coverURL, outputPath: temporary?.path ?? outputPath, maxDimension: maxDimension)
} catch {
if let temporary = temporary { try? FileManager.default.removeItem(at: temporary) }
throw error
}
if let temporary = temporary {
return String(decoding: try JSONSerialization.data(withJSONObject: ["success": true, "file_path": temporary.path]), as: UTF8.self)
}
return "{\"success\":true}"
case "extractCoverToFile":
let args = call.arguments as! [String: Any]
let audioPath = args["audio_path"] as! String
let outputPath = args["output_path"] as! String
try coreBackend.extractCoverToFile(audioPath: audioPath, outputPath: outputPath)
return "{\"success\":true}"
case "reEnrichFile":
let args = call.arguments as! [String: Any]
let requestJson = args["request_json"] as? String ?? "{}"
return try coreBackend.reEnrichFile(requestJson: requestJson)
case "readFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
return try coreBackend.readFileMetadata(path: filePath, hint: args["display_name"] as? String ?? "")
case "editFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let metadataJson = args["metadata_json"] as? String ?? "{}"
return try coreBackend.editFileMetadata(path: filePath, metadataJson: metadataJson)
case "releaseMemory":
try coreBackend.releaseIdleResources()
return nil
case "releaseMemoryUnderPressure":
try coreBackend.releaseMemoryUnderPressure()
NSLog("SpotiFLAC: Backend memory pressure release completed")
return nil
case "runPostProcessingV2":
let args = call.arguments as! [String: Any]
let inputJson = args["input"] as? String ?? ""
let metadataJson = args["metadata"] as? String ?? ""
return try coreBackend.runPostProcessing(inputJson: inputJson, metadataJson: metadataJson)
case "setLibraryCoverCacheDir":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
try coreBackend.setLibraryCoverCacheDirectory(path: cacheDir)
return nil
case "scanLibraryFolder":
let args = call.arguments as! [String: Any]
let folderPath = args["folder_path"] as! String
return bridgeJsonResult(try coreBackend.scanLibraryFolder(folder: folderPath))
case "scanLibraryFolderToNDJSONFile":
guard
let args = call.arguments as? [String: Any],
let folderPath = args["folder_path"] as? String,
!folderPath.isEmpty,
let outputPath = args["output_path"] as? String,
!outputPath.isEmpty
else {
throw invalidArgumentsError(call.method)
}
let count = try coreBackend.scanLibraryFolderToNdjsonFile(folder: folderPath, output: outputPath)
return ["path": outputPath, "count": count]
case "scanLibraryFolderIncremental":
let args = call.arguments as! [String: Any]
let folderPath = args["folder_path"] as! String
let existingFiles = args["existing_files"] as? String ?? "{}"
return bridgeJsonResult(try coreBackend.scanLibraryFolderIncremental(folder: folderPath, existing: existingFiles))
case "getLibraryScanProgress":
return parseJsonPayload(try coreBackend.getLibraryScanProgress())
case "cancelLibraryScan":
try coreBackend.cancelLibraryScan()
return nil
case "resolveIosBookmark":
let args = call.arguments as! [String: Any]
let bookmarkBase64 = args["bookmark"] as! String
return try resolveIosBookmark(bookmarkBase64)
case "startAccessingIosBookmark":
guard
let args = call.arguments as? [String: Any],
let bookmarkBase64 = args["bookmark"] as? String,
!bookmarkBase64.isEmpty
else {
throw invalidArgumentsError(call.method)
}
return try startAccessingIosBookmark(bookmarkBase64)
case "stopAccessingIosBookmark":
guard
let args = call.arguments as? [String: Any],
let token = args["token"] as? String,
!token.isEmpty
else {
throw invalidArgumentsError(call.method)
}
stopAccessingIosBookmark(token: token)
return nil
case "createIosBookmarkFromPath":
let args = call.arguments as! [String: Any]
let path = args["path"] as! String
return try createIosBookmarkFromPath(path)
case "parseCueSheet":
let args = call.arguments as! [String: Any]
let cuePath = args["cue_path"] as! String
let audioDir = args["audio_dir"] as? String ?? ""
return try coreBackend.parseCueSheet(path: cuePath, audioDirectory: audioDir)
default:
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Method not implemented: \(call.method)"]
)
}
}
// MARK: - Native Folder Picker
/// Present a native folder picker and return `{path, bookmark}` where the
/// security-scoped bookmark is created inside the picker callback, while
/// the picker's access grant is still active. Returns nil on cancel.
private func pickIosDirectory(result: @escaping FlutterResult) {
if pendingDirectoryPickerResult != nil {
result(FlutterError(
code: "PICKER_ACTIVE",
message: "A folder picker is already active",
details: nil
))
return
}
guard var topController = activeWindow?.rootViewController else {
result(FlutterError(
code: "NO_VIEW_CONTROLLER",
message: "No view controller available to present the folder picker",
details: nil
))
return
}
while let presented = topController.presentedViewController {
topController = presented
}
pendingDirectoryPickerResult = result
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder])
picker.delegate = self
picker.allowsMultipleSelection = false
topController.present(picker, animated: true)
}
// MARK: - iOS Security-Scoped Bookmark Helpers
/// Create a security-scoped bookmark from a filesystem path (e.g. from FilePicker).
/// The path must currently be accessible (within the same picker session).
/// Returns base64-encoded bookmark data.
private func createIosBookmarkFromPath(_ path: String) throws -> String {
let url = URL(fileURLWithPath: path)
do {
#if os(macOS)
let options: URL.BookmarkCreationOptions = .withSecurityScope
#else
let options: URL.BookmarkCreationOptions = []
#endif
let bookmarkData = try url.bookmarkData(
options: options,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
return bookmarkData.base64EncodedString()
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark for path \(path): \(error.localizedDescription)"]
)
}
}
/// Resolve a base64-encoded security-scoped bookmark and return the resolved path.
/// Does NOT start accessing the resource.
private func resolveIosBookmark(_ bookmarkBase64: String) throws -> String {
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
)
}
var isStale = false
let url: URL
do {
#if os(macOS)
let options: URL.BookmarkResolutionOptions = .withSecurityScope
#else
let options: URL.BookmarkResolutionOptions = []
#endif
url = try URL(
resolvingBookmarkData: bookmarkData,
options: options,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
)
}
return url.path
}
private func invalidArgumentsError(_ method: String) -> NSError {
return NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid arguments for \(method)"]
)
}
/// Starts an independently owned security-scoped lease.
private func startAccessingIosBookmark(_ bookmarkBase64: String) throws -> [String: String] {
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
)
}
var isStale = false
let url: URL
do {
#if os(macOS)
let options: URL.BookmarkResolutionOptions = .withSecurityScope
#else
let options: URL.BookmarkResolutionOptions = []
#endif
url = try URL(
resolvingBookmarkData: bookmarkData,
options: options,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
)
}
guard url.startAccessingSecurityScopedResource() else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to start accessing security-scoped resource at \(url.path)"]
)
}
let scope: CoreDirectoryScope
do {
scope = try coreBackend.openDownloadDirectory(path: url.path)
} catch {
url.stopAccessingSecurityScopedResource()
throw error
}
let token = UUID().uuidString
securityScopedAccessLock.lock()
securityScopedAccesses[token] = (url, scope)
securityScopedAccessLock.unlock()
return ["path": url.path, "token": token]
}
/// Releases only the lease identified by the caller's token.
private func stopAccessingIosBookmark(token: String) {
securityScopedAccessLock.lock()
let lease = securityScopedAccesses.removeValue(forKey: token)
securityScopedAccessLock.unlock()
lease?.1.close()
lease?.0.stopAccessingSecurityScopedResource()
}
}
@available(iOS 13.0, *)
extension AppDelegate: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return activeWindow ?? ASPresentationAnchor()
}
}
extension AppDelegate: UIDocumentPickerDelegate {
func documentPicker(
_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]
) {
guard let result = pendingDirectoryPickerResult else { return }
pendingDirectoryPickerResult = nil
guard let url = urls.first else {
result(nil)
return
}
// The bookmark must be created here, from the picker's own URL, while
// its security-scoped grant is active. A URL rebuilt from the path
// string later has no grant, so bookmark creation either fails or
// yields a bookmark that cannot re-open the folder.
let didStartAccess = url.startAccessingSecurityScopedResource()
defer {
if didStartAccess { url.stopAccessingSecurityScopedResource() }
}
do {
let bookmarkData = try url.bookmarkData(
options: [],
includingResourceValuesForKeys: nil,
relativeTo: nil
)
result([
"path": url.path,
"bookmark": bookmarkData.base64EncodedString(),
])
} catch {
result(FlutterError(
code: "BOOKMARK_FAILED",
message: "Failed to create bookmark for \(url.path): \(error.localizedDescription)",
details: nil
))
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
pendingDirectoryPickerResult?(nil)
pendingDirectoryPickerResult = nil
}
}
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
typealias ListenHandler = (_ arguments: Any?, _ events: @escaping FlutterEventSink) -> FlutterError?
typealias CancelHandler = (_ arguments: Any?) -> FlutterError?
private let onListenHandler: ListenHandler
private let onCancelHandler: CancelHandler
init(
onListen: @escaping ListenHandler,
onCancel: @escaping CancelHandler = { _ in nil }
) {
self.onListenHandler = onListen
self.onCancelHandler = onCancel
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
onListenHandler(arguments, events)
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
onCancelHandler(arguments)
}
}
/// UIScene lifecycle entry point (required from iOS 27). FlutterSceneDelegate
/// hosts the storyboard's FlutterViewController; this subclass forwards what
/// UIKit now delivers to the scene instead of the app delegate: OAuth
/// redirect URLs and the background/foreground transitions that keep
/// downloads alive.
class SceneDelegate: FlutterSceneDelegate {
private var appDelegate: AppDelegate? {
UIApplication.shared.delegate as? AppDelegate
}
override func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
for context in connectionOptions.urlContexts {
_ = appDelegate?.handleExtensionOAuthRedirect(url: context.url)
}
}
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
let unhandled = URLContexts.filter {
appDelegate?.handleExtensionOAuthRedirect(url: $0.url) != true
}
if !unhandled.isEmpty {
super.scene(scene, openURLContexts: unhandled)
}
}
override func sceneDidEnterBackground(_ scene: UIScene) {
super.sceneDidEnterBackground(scene)
appDelegate?.didEnterBackground()
}
override func sceneWillEnterForeground(_ scene: UIScene) {
super.sceneWillEnterForeground(scene)
appDelegate?.willEnterForeground()
}
}