import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:frosty/apis/twitch_integrity.dart'; import 'package:webview_flutter/webview_flutter.dart'; /// Captures a browser-valid `Client-Integrity` header by loading Twitch in a /// WebView (Dio `/integrity` tokens are almost always `is_bad_bot`). /// /// Returns credentials, or null if the user cancels / capture times out. Future showIntegrityWebViewDialog({ required BuildContext context, required String gqlToken, }) { return showDialog( context: context, barrierDismissible: false, builder: (dialogContext) => IntegrityWebViewDialog(gqlToken: gqlToken), ); } class IntegrityWebViewDialog extends StatefulWidget { final String gqlToken; const IntegrityWebViewDialog({super.key, required this.gqlToken}); @override State createState() => _IntegrityWebViewDialogState(); } class _IntegrityWebViewDialogState extends State { static const _captureTimeout = Duration(seconds: 45); late final WebViewController _controller; Timer? _timeout; var _captured = false; var _status = 'Verifying with Twitch…'; @override void initState() { super.initState(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setUserAgent( Platform.isIOS ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) ' 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 ' 'Mobile/15E148 Safari/604.1' : 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36', ) ..addJavaScriptChannel( 'FrostyIntegrity', onMessageReceived: _onIntegrityMessage, ) ..setNavigationDelegate( NavigationDelegate( onPageStarted: (_) => _injectHooks(), onPageFinished: (_) => _injectHooks(), ), ); _prepareAndLoad(); _timeout = Timer(_captureTimeout, () { if (!mounted || _captured) return; setState(() => _status = 'Timed out waiting for Twitch. Try again.'); }); } Future _prepareAndLoad() async { try { final cookies = WebViewCookieManager(); await cookies.setCookie( WebViewCookie( name: 'auth-token', value: widget.gqlToken, domain: '.twitch.tv', ), ); } catch (e) { debugPrint('IntegrityWebView: cookie set failed: $e'); } // Same entry as Xtra: with auth-token set, Twitch redirects and emits GQL // requests that carry a browser-valid Client-Integrity header. await _controller.loadRequest(Uri.parse('https://www.twitch.tv/login')); } Future _injectHooks() async { // Hook fetch + XHR so we see Client-Integrity on Twitch's own GQL traffic. const script = r''' (function () { if (window.__frostyIntegrityHooked) return; window.__frostyIntegrityHooked = true; function report(headers) { if (!headers || !window.FrostyIntegrity) return; var integrity = headers['Client-Integrity'] || headers['client-integrity']; if (!integrity) return; var deviceId = headers['X-Device-Id'] || headers['x-device-id'] || ''; var clientId = headers['Client-ID'] || headers['Client-Id'] || headers['client-id'] || ''; window.FrostyIntegrity.postMessage(JSON.stringify({ integrity: integrity, deviceId: deviceId, clientId: clientId })); } function headersToObject(headers) { var out = {}; if (!headers) return out; if (typeof Headers !== 'undefined' && headers instanceof Headers) { headers.forEach(function (value, key) { out[key] = value; }); return out; } if (typeof headers === 'object') { Object.keys(headers).forEach(function (key) { out[key] = headers[key]; }); } return out; } var originalFetch = window.fetch; window.fetch = function (input, init) { try { report(headersToObject(init && init.headers)); } catch (e) {} return originalFetch.apply(this, arguments); }; var originalOpen = XMLHttpRequest.prototype.open; var originalSetHeader = XMLHttpRequest.prototype.setRequestHeader; var originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function () { this.__frostyHeaders = {}; return originalOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function (key, value) { try { this.__frostyHeaders[key] = value; } catch (e) {} return originalSetHeader.apply(this, arguments); }; XMLHttpRequest.prototype.send = function () { try { report(this.__frostyHeaders || {}); } catch (e) {} return originalSend.apply(this, arguments); }; })(); '''; try { await _controller.runJavaScript(script); } catch (e) { debugPrint('IntegrityWebView: hook inject failed: $e'); } } void _onIntegrityMessage(JavaScriptMessage message) { if (_captured || !mounted) return; try { final map = jsonDecode(message.message) as Map; final integrity = map['integrity'] as String?; if (integrity == null || integrity.isEmpty) return; if (TwitchIntegrity.isBadBot(integrity)) { debugPrint('IntegrityWebView: ignoring bad_bot token'); return; } final fromHeader = map['deviceId'] as String?; final fromToken = TwitchIntegrity.deviceId(integrity); final deviceId = (fromHeader != null && fromHeader.isNotEmpty) ? fromHeader : (fromToken ?? ''); if (deviceId.isEmpty) { debugPrint('IntegrityWebView: missing device id'); return; } final expiration = TwitchIntegrity.expirationMs(integrity) ?? DateTime.now().millisecondsSinceEpoch + const Duration(hours: 16).inMilliseconds; _captured = true; _timeout?.cancel(); Navigator.of(context).pop( GqlIntegrityCredentials( integrityToken: integrity, deviceId: deviceId, expirationMs: expiration, ), ); } catch (e) { debugPrint('IntegrityWebView: parse failed: $e'); } } @override void dispose() { _timeout?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final height = MediaQuery.sizeOf(context).height * 0.55; return AlertDialog( title: const Text('Confirm with Twitch'), content: SizedBox( width: double.maxFinite, height: height.clamp(280.0, 520.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(_status), const SizedBox(height: 12), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(8), child: WebViewWidget(controller: _controller), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), ], ); } }