Fix tag directory sorting and follow Client-Integrity.
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>
This commit is contained in:
parent
383e786479
commit
3fb393166f
13 changed files with 1197 additions and 207 deletions
232
lib/widgets/integrity_webview_dialog.dart
Normal file
232
lib/widgets/integrity_webview_dialog.dart
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue