Paginate tag (and ASC) lists via GQL with integrity retry; mint follow integrity from a WebView because Dio /integrity tokens are bad_bot. Co-authored-by: Cursor <cursoragent@cursor.com>
232 lines
7.1 KiB
Dart
232 lines
7.1 KiB
Dart
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<GqlIntegrityCredentials?> showIntegrityWebViewDialog({
|
|
required BuildContext context,
|
|
required String gqlToken,
|
|
}) {
|
|
return showDialog<GqlIntegrityCredentials>(
|
|
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<IntegrityWebViewDialog> createState() => _IntegrityWebViewDialogState();
|
|
}
|
|
|
|
class _IntegrityWebViewDialogState extends State<IntegrityWebViewDialog> {
|
|
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<void> _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<void> _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<String, dynamic>;
|
|
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|