import AuthenticationServices import Flutter import UIKit import UniformTypeIdentifiers @main @objc class AppDelegate: FlutterAppDelegate { // Widget playback intents can launch the process before any scene exists. // Keep one headless-capable engine and attach that same player to the UI. lazy var playerEngine = FlutterEngine(name: "spotiflac-player", project: nil, allowHeadlessExecution: true) 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 var concertCalendar: ConcertCalendarBridge? 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 { playerEngine.run() configureFlutterEngine(playerEngine.binaryMessenger, registry: playerEngine) if let url = launchOptions?[.url] as? URL { _ = handleExtensionOAuthRedirect(url: url) } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } private func configureFlutterEngine(_ messenger: FlutterBinaryMessenger, registry: FlutterPluginRegistry) { concertCalendar = ConcertCalendarBridge(messenger: messenger) { [weak self] in self?.activeWindow?.rootViewController } PlayerWidgetBridge.shared.attach(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: registry) registry.registrar(forPlugin: "AudioOutputView")?.register( AudioOutputViewFactory(messenger: messenger), withId: "com.zarz.spotiflac/audio_output" ) } /// 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= /// - Signed session: spotiflac://session-grant?grant=...&state= @discardableResult func handleExtensionOAuthRedirect(url: URL) -> Bool { if url.scheme == "spotiflac", url.host == "player" { Task { _ = await PlayerWidgetBridge.shared.perform("open") } return true } 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 = ["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", "checkHiResAuthenticity"] 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 "checkHiResAuthenticity": let args = call.arguments as! [String: Any] let filePath = args["file_path"] as! String return try coreBackend.checkHiResAuthenticity(path: filePath, optionsJson: args["options_json"] 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 shared engine'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 ) { if let windowScene = scene as? UIWindowScene, let appDelegate { let window = UIWindow(windowScene: windowScene) window.rootViewController = FlutterViewController(engine: appDelegate.playerEngine, nibName: nil, bundle: nil) self.window = window window.makeKeyAndVisible() } 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) { 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() } }