diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 10e0c79d..15a4e31f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import Flutter import UIKit import UniformTypeIdentifiers @@ -31,6 +32,10 @@ import Gobackend /// started on each background entry to extend execution time. 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, @@ -336,6 +341,16 @@ import Gobackend 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 } @@ -354,6 +369,52 @@ import Gobackend } } + /// 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) if downloadsActive { @@ -1340,6 +1401,13 @@ import Gobackend } } +@available(iOS 13.0, *) +extension AppDelegate: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return window ?? ASPresentationAnchor() + } +} + extension AppDelegate: UIDocumentPickerDelegate { func documentPicker( _ controller: UIDocumentPickerViewController, diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index a9399a04..0d2fb585 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -7,8 +7,8 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:url_launcher/url_launcher.dart'; class ExtensionDetailPage extends ConsumerStatefulWidget { final String extensionId; @@ -1164,9 +1164,9 @@ class _SettingItemState extends State<_SettingItem> { final openAuth = payload['open_auth_url'] as String?; if (openAuth != null && openAuth.isNotEmpty) { final uri = Uri.parse(openAuth); - final launched = await launchUrl( + final launched = await launchExtensionAuthUrl( uri, - mode: LaunchMode.externalApplication, + browserMode: 'external_first', ); if (!launched && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 53a367df..8534a4c2 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -547,6 +547,23 @@ class PlatformBridge { await _channel.invokeMethod('resetDownloadCancel', {'item_id': itemId}); } + /// iOS only: run a verification/OAuth page inside ASWebAuthenticationSession. + /// The session intercepts the callback scheme in-process, so the flow + /// completes even where the app's URL scheme is not registered with the OS + /// (e.g. sideload containers like LiveContainer). Returns true when the + /// session was presented; the callback is delivered through the native side + /// exactly like an OS deep link. + static Future startIosWebAuthSession( + String url, { + String callbackScheme = 'spotiflac', + }) async { + final result = await _channel.invokeMethod('startWebAuthSession', { + 'url': url, + 'callback_scheme': callbackScheme, + }); + return result == true; + } + static Future setDownloadDirectory(String path) async { await _channel.invokeMethod('setDownloadDirectory', {'path': path}); } diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index 54085f0e..fb9dabcc 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -299,7 +300,27 @@ String? _firstRegexGroup(String input, RegExp regex) { return match?.group(1); } +/// Opens an extension auth/verification page. On iOS this prefers an +/// ASWebAuthenticationSession, which captures the spotiflac:// callback +/// in-process — required for environments where the app's URL scheme is not +/// registered with the OS (sideload containers like LiveContainer) and nicer +/// everywhere else (the sheet closes itself once the challenge completes). +Future launchExtensionAuthUrl(Uri uri, {required String browserMode}) { + return _launchVerificationUrl(uri, browserMode); +} + Future _launchVerificationUrl(Uri uri, String browserMode) async { + if (Platform.isIOS && uri.scheme == 'https') { + try { + if (await PlatformBridge.startIosWebAuthSession(uri.toString())) { + return true; + } + _log.w('Web auth session did not start, falling back to browser'); + } catch (e) { + _log.w('Web auth session failed, falling back to browser: $e'); + } + } + final preferInApp = browserMode.trim().toLowerCase() == 'in_app_first'; final firstMode = preferInApp ? LaunchMode.inAppBrowserView