mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-26 05:51:18 +02:00
feat(ios): run verification and OAuth through ASWebAuthenticationSession
The captcha/OAuth flow relied on the OS routing the spotiflac:// callback back into the app. In sideload containers (LiveContainer) the guest app's URL scheme is never registered with iOS, so after solving the challenge the redirect went nowhere and the user could not return to the app (LiveContainer#242/#162 — unresolved upstream). ASWebAuthenticationSession intercepts the callback scheme in-process, so no OS-level scheme registration is involved: the session sheet closes itself on completion and the callback URL is fed into the same deep-link handler the OS path uses. Verification challenges, the help dialog's open-browser action, and extension OAuth logins all prefer the session on iOS, falling back to url_launcher if it fails to start. Safari's cookie store is shared so captcha providers see an established browsing context.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<bool> startIosWebAuthSession(
|
||||
String url, {
|
||||
String callbackScheme = 'spotiflac',
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('startWebAuthSession', {
|
||||
'url': url,
|
||||
'callback_scheme': callbackScheme,
|
||||
});
|
||||
return result == true;
|
||||
}
|
||||
|
||||
static Future<void> setDownloadDirectory(String path) async {
|
||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||
}
|
||||
|
||||
@@ -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<bool> launchExtensionAuthUrl(Uri uri, {required String browserMode}) {
|
||||
return _launchVerificationUrl(uri, browserMode);
|
||||
}
|
||||
|
||||
Future<bool> _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
|
||||
|
||||
Reference in New Issue
Block a user