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
|
|
@ -51,9 +51,10 @@ Settings → General → Stream languages (multi-select chips). Default `de`. Ap
|
|||
|
||||
- Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter
|
||||
- Viewer sort is persisted (`streamViewerSort`)
|
||||
- **Most viewers (desc):** Helix directory order (paginates; tags via client `tags[]` match)
|
||||
- **Fewest viewers (asc):** GQL `VIEWER_COUNT_ASC` for the first ~30 (true fewest), then Helix fill + dedupe for infinite scroll (GQL page-2 needs Client-Integrity and always fails)
|
||||
- Tag directories never rely on GQL cursors for load-more
|
||||
- **Tag lists (asc + desc):** GQL `streams` with `freeformTags` + `sort` + cursor. Page-2+ uses Client-Integrity (same mint/retry as follow via `UserStore.runWithGqlIntegrity`). Helix + client tag filter only if integrity fails / no web session.
|
||||
- **Follow / integrity:** Dio `POST /integrity` is almost always `is_bad_bot`. Follow retries via `IntegrityWebViewDialog` (capture `Client-Integrity` + `X-Device-Id` from Twitch's own WebView GQL traffic), then `UserStore.applyWebIntegrity`.
|
||||
- **Top / category most viewers (desc):** Helix directory order (paginates)
|
||||
- **Top / category fewest (asc):** GQL `VIEWER_COUNT_ASC` with the same integrity pagination; Helix fill + client re-sort as fallback
|
||||
|
||||
## Build
|
||||
|
||||
|
|
|
|||
|
|
@ -271,8 +271,8 @@ class TwitchApi extends BaseApiClient {
|
|||
/// optional [freeformTag] (case-insensitive tag name), or the directory ends.
|
||||
///
|
||||
/// Helix is always viewer-desc and supports at most one `language` query
|
||||
/// param; tags are filtered client-side. Prefer this over GQL for pagination
|
||||
/// (GQL page-2 requires a non-bot Client-Integrity token).
|
||||
/// param; tags are filtered client-side. Used as fallback when GQL directory
|
||||
/// pagination lacks a Client-Integrity token.
|
||||
///
|
||||
/// When a [freeformTag] filter yields no matches in a Helix window, this
|
||||
/// keeps scanning (up to [maxPages]) and still returns the Helix cursor so
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_integrity.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/playback_access_token.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
|
|
@ -31,27 +32,49 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
Future<JsonMap> _gql(Map<String, Object?> body) async {
|
||||
Future<JsonMap> _gql(
|
||||
Map<String, Object?> body, {
|
||||
Map<String, String>? extraHeaders,
|
||||
}) async {
|
||||
final response = await post<JsonMap>(
|
||||
'/gql',
|
||||
data: body,
|
||||
headers: _gqlHeaders,
|
||||
headers: {..._gqlHeaders, ...?extraHeaders},
|
||||
);
|
||||
final errors = response['errors'];
|
||||
if (errors is List && errors.isNotEmpty) {
|
||||
final message = (errors.first as JsonMap)['message'] as String? ??
|
||||
'Twitch GQL error';
|
||||
if (message.toLowerCase().contains('integrity')) {
|
||||
throw IntegrityCheckException(message);
|
||||
}
|
||||
final data = response['data'];
|
||||
final dataEmpty = data == null ||
|
||||
(data is Map &&
|
||||
data.values.every((value) => value == null));
|
||||
if (dataEmpty) {
|
||||
final message = (errors.first as JsonMap)['message'] as String? ??
|
||||
'Twitch GQL error';
|
||||
throw ApiException(message);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Optional web-session headers for directory pagination (page-2+).
|
||||
Map<String, String> _directoryHeaders({
|
||||
String? authToken,
|
||||
String? integrityToken,
|
||||
String? deviceId,
|
||||
}) {
|
||||
return {
|
||||
'User-Agent': playbackUserAgent,
|
||||
if (authToken != null && authToken.isNotEmpty)
|
||||
'Authorization': 'OAuth $authToken',
|
||||
if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId,
|
||||
if (integrityToken != null && integrityToken.isNotEmpty)
|
||||
'Client-Integrity': integrityToken,
|
||||
};
|
||||
}
|
||||
|
||||
/// Browser-consistent User-Agent for playback-related requests. Twitch has
|
||||
/// rejected access-token requests with non-browser UAs before
|
||||
/// (streamlink#6574); sending one consistent with the web client ID and
|
||||
|
|
@ -213,15 +236,18 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
|
||||
/// Live streams matching optional [freeformTags] / [languages], sorted by viewers.
|
||||
///
|
||||
/// Twitch caps `first` at 30 for this connection. Cursor pagination often
|
||||
/// fails with `IntegrityCheckFailed` without a web integrity token — callers
|
||||
/// should treat a failed page-2 as end-of-list.
|
||||
/// Twitch caps `first` at 30 for this connection. Cursor pagination needs a
|
||||
/// Client-Integrity token (+ matching [deviceId] / web [authToken]); without
|
||||
/// them page-2 throws [IntegrityCheckException].
|
||||
Future<StreamsTwitch> getStreams({
|
||||
List<String>? freeformTags,
|
||||
List<String>? languages,
|
||||
StreamViewerSort sort = StreamViewerSort.viewersDesc,
|
||||
String? cursor,
|
||||
int first = 30,
|
||||
String? authToken,
|
||||
String? integrityToken,
|
||||
String? deviceId,
|
||||
}) async {
|
||||
final cappedFirst = first.clamp(1, 30);
|
||||
final gqlLangs = languages == null || languages.isEmpty
|
||||
|
|
@ -231,7 +257,8 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
.where((l) => l.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final response = await _gql({
|
||||
final response = await _gql(
|
||||
{
|
||||
'operationName': 'FrostyTopStreams',
|
||||
'query':
|
||||
r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {'
|
||||
|
|
@ -248,7 +275,13 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
'sort': sort.gqlValue,
|
||||
'langs': gqlLangs,
|
||||
},
|
||||
});
|
||||
},
|
||||
extraHeaders: _directoryHeaders(
|
||||
authToken: authToken,
|
||||
integrityToken: integrityToken,
|
||||
deviceId: deviceId,
|
||||
),
|
||||
);
|
||||
|
||||
final connection =
|
||||
(response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {};
|
||||
|
|
@ -262,6 +295,9 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
StreamViewerSort sort = StreamViewerSort.viewersDesc,
|
||||
String? cursor,
|
||||
int first = 30,
|
||||
String? authToken,
|
||||
String? integrityToken,
|
||||
String? deviceId,
|
||||
}) async {
|
||||
final cappedFirst = first.clamp(1, 30);
|
||||
final gqlLangs = languages == null || languages.isEmpty
|
||||
|
|
@ -271,7 +307,8 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
.where((l) => l.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final response = await _gql({
|
||||
final response = await _gql(
|
||||
{
|
||||
'operationName': 'FrostyGameStreams',
|
||||
'query':
|
||||
r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {'
|
||||
|
|
@ -288,7 +325,13 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
'sort': sort.gqlValue,
|
||||
'langs': gqlLangs,
|
||||
},
|
||||
});
|
||||
},
|
||||
extraHeaders: _directoryHeaders(
|
||||
authToken: authToken,
|
||||
integrityToken: integrityToken,
|
||||
deviceId: deviceId,
|
||||
),
|
||||
);
|
||||
|
||||
final game = (response['data'] as JsonMap?)?['game'] as JsonMap?;
|
||||
if (game == null) {
|
||||
|
|
@ -354,8 +397,9 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
/// Mints a Client-Integrity token for authenticated GQL mutations.
|
||||
///
|
||||
/// Bound to [deviceId] and [authToken]; send the same values on follow calls.
|
||||
/// Twitch may still reject bot-flagged tokens — then the mutation fails with
|
||||
/// [IntegrityCheckException].
|
||||
/// Tokens minted outside a real browser are usually flagged `is_bad_bot` —
|
||||
/// those throw [IntegrityCheckException] so callers can fall back to a
|
||||
/// WebView capture.
|
||||
Future<({String token, int expirationMs})> fetchIntegrityToken({
|
||||
required String authToken,
|
||||
required String deviceId,
|
||||
|
|
@ -377,9 +421,16 @@ class TwitchGqlApi extends BaseApiClient {
|
|||
if (token == null || token.isEmpty) {
|
||||
throw const ApiException('Twitch integrity token missing from response');
|
||||
}
|
||||
if (TwitchIntegrity.isBadBot(token)) {
|
||||
throw const IntegrityCheckException(
|
||||
'Twitch integrity token flagged as bad bot',
|
||||
);
|
||||
}
|
||||
final expirationMs = expiration is int
|
||||
? expiration
|
||||
: int.tryParse('$expiration') ?? 0;
|
||||
: int.tryParse('$expiration') ??
|
||||
TwitchIntegrity.expirationMs(token) ??
|
||||
0;
|
||||
return (token: token, expirationMs: expirationMs);
|
||||
}
|
||||
|
||||
|
|
|
|||
81
lib/apis/twitch_integrity.dart
Normal file
81
lib/apis/twitch_integrity.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import 'dart:convert';
|
||||
|
||||
/// Helpers for Twitch `Client-Integrity` tokens (`v4.public.*` / `v4.local.*`).
|
||||
class TwitchIntegrity {
|
||||
const TwitchIntegrity._();
|
||||
|
||||
/// Decodes the JSON payload embedded in an integrity token, if present.
|
||||
static Map<String, dynamic>? decodePayload(String token) {
|
||||
final parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
for (final part in parts.skip(1)) {
|
||||
if (part.isEmpty) continue;
|
||||
try {
|
||||
final normalized = base64Url.normalize(part);
|
||||
final decoded = utf8.decode(base64Url.decode(normalized));
|
||||
final dynamic json = jsonDecode(decoded);
|
||||
if (json is Map &&
|
||||
(json.containsKey('is_bad_bot') ||
|
||||
json.containsKey('client_id') ||
|
||||
json.containsKey('device_id'))) {
|
||||
return Map<String, dynamic>.from(json);
|
||||
}
|
||||
} catch (_) {
|
||||
// Try the next segment — token layout varies by version.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Dio/`POST /integrity` tokens from non-browser clients are usually flagged.
|
||||
static bool isBadBot(String token) {
|
||||
final payload = decodePayload(token);
|
||||
if (payload == null) return false;
|
||||
final flag = payload['is_bad_bot'];
|
||||
if (flag is bool) return flag;
|
||||
if (flag is String) return flag.toLowerCase() == 'true';
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Expiration in epoch millis from the payload, if present.
|
||||
static int? expirationMs(String token) {
|
||||
final payload = decodePayload(token);
|
||||
if (payload == null) return null;
|
||||
|
||||
final exp = payload['exp'];
|
||||
if (exp is int) {
|
||||
// Already millis if large; seconds if typical JWT.
|
||||
return exp > 100000000000 ? exp : exp * 1000;
|
||||
}
|
||||
if (exp is String) {
|
||||
final asInt = int.tryParse(exp);
|
||||
if (asInt != null) {
|
||||
return asInt > 100000000000 ? asInt : asInt * 1000;
|
||||
}
|
||||
final dt = DateTime.tryParse(exp);
|
||||
if (dt != null) return dt.millisecondsSinceEpoch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Device id the token was minted for (`X-Device-Id` must match).
|
||||
static String? deviceId(String token) {
|
||||
final payload = decodePayload(token);
|
||||
final id = payload?['device_id'];
|
||||
return id is String && id.isNotEmpty ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Credentials captured from a browser/WebView GQL request.
|
||||
class GqlIntegrityCredentials {
|
||||
final String integrityToken;
|
||||
final String deviceId;
|
||||
final int expirationMs;
|
||||
|
||||
const GqlIntegrityCredentials({
|
||||
required this.integrityToken,
|
||||
required this.deviceId,
|
||||
required this.expirationMs,
|
||||
});
|
||||
}
|
||||
|
|
@ -51,9 +51,13 @@ abstract class ListStoreBase with Store {
|
|||
/// The pagination cursor for the streams.
|
||||
String? _streamsCursor;
|
||||
|
||||
/// After a GQL ASC first page, keep filling via Helix (GQL page-2 integrity
|
||||
/// fails). Helix starts from the top; duplicates are skipped.
|
||||
var _ascHelixFill = false;
|
||||
/// When true, pages come from Helix (GQL cursor/integrity unavailable).
|
||||
/// ASC Helix fill starts from the top; duplicates are skipped and the list
|
||||
/// is re-sorted client-side.
|
||||
var _helixFill = false;
|
||||
|
||||
/// Last page was served by GQL with server-side sort — append in API order.
|
||||
var _lastPageWasGql = false;
|
||||
|
||||
/// The pagination cursor for the offline followed channels.
|
||||
String? _offlineChannelsCursor;
|
||||
|
|
@ -64,7 +68,7 @@ abstract class ListStoreBase with Store {
|
|||
/// Returns whether or not there are more streams and loading status for pagination.
|
||||
@computed
|
||||
bool get hasMore =>
|
||||
isLoading == false && (_streamsCursor != null || _ascHelixFill);
|
||||
isLoading == false && (_streamsCursor != null || _helixFill);
|
||||
|
||||
@computed
|
||||
bool get isLoading =>
|
||||
|
|
@ -255,6 +259,14 @@ abstract class ListStoreBase with Store {
|
|||
}
|
||||
}
|
||||
|
||||
/// Prefer GQL for tag directories (both sorts) and for ascending Top/category.
|
||||
bool get _preferGqlDirectory {
|
||||
if (twitchGqlApi == null || _helixFill) return false;
|
||||
if (listType == ListType.tag) return true;
|
||||
return listType != ListType.followed &&
|
||||
sortOrder == StreamViewerSort.viewersAsc;
|
||||
}
|
||||
|
||||
/// Fetches the streams based on the type and current cursor.
|
||||
@action
|
||||
Future<void> getStreams() async {
|
||||
|
|
@ -265,84 +277,41 @@ abstract class ListStoreBase with Store {
|
|||
try {
|
||||
final languages = _languageFilter;
|
||||
final isAsc = sortOrder == StreamViewerSort.viewersAsc;
|
||||
// GQL ASC is correct fewest, but page-2 always fails IntegrityCheckFailed.
|
||||
// First empty list → GQL ASC once; further pages → Helix fill + dedupe.
|
||||
final gqlAscFirstPage = isAsc &&
|
||||
twitchGqlApi != null &&
|
||||
!_ascHelixFill &&
|
||||
_allStreams.isEmpty &&
|
||||
listType != ListType.followed;
|
||||
final useGql = _preferGqlDirectory;
|
||||
_lastPageWasGql = false;
|
||||
|
||||
final StreamsTwitch newStreams;
|
||||
switch (listType) {
|
||||
case ListType.followed:
|
||||
StreamsTwitch? newStreams;
|
||||
if (listType == ListType.followed) {
|
||||
newStreams = await twitchApi.getFollowedStreams(
|
||||
id: authStore.user.details!.id,
|
||||
cursor: _streamsCursor,
|
||||
);
|
||||
break;
|
||||
case ListType.top:
|
||||
if (gqlAscFirstPage) {
|
||||
newStreams = await twitchGqlApi!.getStreams(
|
||||
sort: StreamViewerSort.viewersAsc,
|
||||
languages: languages.isEmpty ? null : languages,
|
||||
);
|
||||
} else {
|
||||
newStreams = await twitchApi.getDirectoryStreams(
|
||||
} else if (useGql) {
|
||||
newStreams = await _fetchGqlDirectory(languages: languages);
|
||||
if (newStreams != null) {
|
||||
_lastPageWasGql = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Helix: Top/category DESC, or GQL unavailable / integrity fallback.
|
||||
newStreams ??= await twitchApi.getDirectoryStreams(
|
||||
languages: languages,
|
||||
gameId: listType == ListType.category ? categoryId : null,
|
||||
freeformTag: listType == ListType.tag ? freeformTag : null,
|
||||
cursor: _streamsCursor,
|
||||
minCount: isAsc ? 30 : 50,
|
||||
maxPages: isAsc ? 20 : 12,
|
||||
minCount: listType == ListType.tag ? 40 : (isAsc ? 30 : 50),
|
||||
maxPages: listType == ListType.tag ? 25 : (isAsc ? 20 : 12),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case ListType.category:
|
||||
if (gqlAscFirstPage) {
|
||||
newStreams = await twitchGqlApi!.getGameStreams(
|
||||
gameId: categoryId!,
|
||||
sort: StreamViewerSort.viewersAsc,
|
||||
languages: languages.isEmpty ? null : languages,
|
||||
);
|
||||
} else {
|
||||
newStreams = await twitchApi.getDirectoryStreams(
|
||||
languages: languages,
|
||||
gameId: categoryId!,
|
||||
cursor: _streamsCursor,
|
||||
minCount: isAsc ? 30 : 50,
|
||||
maxPages: isAsc ? 20 : 12,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case ListType.tag:
|
||||
if (gqlAscFirstPage) {
|
||||
newStreams = await twitchGqlApi!.getStreams(
|
||||
freeformTags: [freeformTag!],
|
||||
sort: StreamViewerSort.viewersAsc,
|
||||
languages: languages.isEmpty ? null : languages,
|
||||
);
|
||||
} else {
|
||||
// Desc, and ASC after GQL: Helix + tag filter (paginates).
|
||||
newStreams = await twitchApi.getDirectoryStreams(
|
||||
languages: languages,
|
||||
freeformTag: freeformTag,
|
||||
cursor: _streamsCursor,
|
||||
minCount: 40,
|
||||
maxPages: 25,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
final next = newStreams.pagination['cursor'];
|
||||
final hasHelixCursor = next != null && next.isNotEmpty;
|
||||
final hasCursor = next != null && next.isNotEmpty;
|
||||
|
||||
// Empty Helix window with cursor left ⇒ keep scanning (sparse tags).
|
||||
final isHelixContinuation =
|
||||
_streamsCursor != null || (_ascHelixFill && _allStreams.isNotEmpty);
|
||||
if (isHelixContinuation &&
|
||||
newStreams.data.isEmpty &&
|
||||
!gqlAscFirstPage) {
|
||||
if (hasHelixCursor) {
|
||||
final isHelixContinuation = !_lastPageWasGql &&
|
||||
(_streamsCursor != null ||
|
||||
(_helixFill && _allStreams.isNotEmpty));
|
||||
if (isHelixContinuation && newStreams.data.isEmpty) {
|
||||
if (hasCursor) {
|
||||
_streamsCursor = next;
|
||||
_error = null;
|
||||
_isAllStreamsLoading = false;
|
||||
|
|
@ -350,20 +319,20 @@ abstract class ListStoreBase with Store {
|
|||
return;
|
||||
}
|
||||
_streamsCursor = null;
|
||||
_ascHelixFill = false;
|
||||
_helixFill = false;
|
||||
_error = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep API order for desc. ASC: sanitize page, then full-list sort after
|
||||
// Helix fill so newly found streams land in fewest-first order.
|
||||
final pageData = isAsc
|
||||
// GQL pages are already server-sorted — keep API order.
|
||||
// Helix ASC fill: sort page + full list so late matches land correctly.
|
||||
final pageData = (!_lastPageWasGql && isAsc)
|
||||
? (List<StreamTwitch>.of(newStreams.data)..sort(
|
||||
(a, b) => a.viewerCount.compareTo(b.viewerCount),
|
||||
))
|
||||
: newStreams.data;
|
||||
|
||||
if (_allStreams.isEmpty && !_ascHelixFill) {
|
||||
if (_allStreams.isEmpty && !_helixFill) {
|
||||
_allStreams = pageData.asObservable();
|
||||
} else {
|
||||
final existingIds = _allStreams.map((s) => s.userId).toSet();
|
||||
|
|
@ -372,23 +341,17 @@ abstract class ListStoreBase with Store {
|
|||
);
|
||||
}
|
||||
|
||||
if (isAsc && _allStreams.length > 1) {
|
||||
if (!_lastPageWasGql && isAsc && _allStreams.length > 1) {
|
||||
final sorted = _allStreams.toList()
|
||||
..sort((a, b) => a.viewerCount.compareTo(b.viewerCount));
|
||||
_allStreams = sorted.asObservable();
|
||||
}
|
||||
|
||||
if (gqlAscFirstPage) {
|
||||
// Ignore GQL cursor — page-2 integrity fails. Fill via Helix next.
|
||||
_ascHelixFill = true;
|
||||
_streamsCursor = null;
|
||||
} else {
|
||||
_streamsCursor = hasHelixCursor ? next : null;
|
||||
_streamsCursor = hasCursor ? next : null;
|
||||
if (_streamsCursor == null) {
|
||||
_ascHelixFill = false;
|
||||
} else if (isAsc) {
|
||||
_ascHelixFill = true;
|
||||
}
|
||||
_helixFill = false;
|
||||
} else if (!_lastPageWasGql && isAsc) {
|
||||
_helixFill = true;
|
||||
}
|
||||
|
||||
_error = null;
|
||||
|
|
@ -397,15 +360,16 @@ abstract class ListStoreBase with Store {
|
|||
debugPrint('Streams SocketException: No internet connection');
|
||||
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||
_streamsCursor = null;
|
||||
_ascHelixFill = false;
|
||||
_helixFill = false;
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
_error = e.message;
|
||||
debugPrint('Streams ApiException: $e');
|
||||
if (_allStreams.isNotEmpty) {
|
||||
// GQL page-2 integrity or mid-scroll error: fall back to Helix fill.
|
||||
if (isAscDirectory && !_ascHelixFill) {
|
||||
_ascHelixFill = true;
|
||||
// Mid-scroll GQL failure: fall back to Helix fill when useful.
|
||||
if (!_helixFill &&
|
||||
(listType == ListType.tag || isAscDirectory)) {
|
||||
_helixFill = true;
|
||||
_streamsCursor = null;
|
||||
_error = null;
|
||||
_isAllStreamsLoading = false;
|
||||
|
|
@ -413,7 +377,7 @@ abstract class ListStoreBase with Store {
|
|||
return;
|
||||
}
|
||||
_streamsCursor = null;
|
||||
_ascHelixFill = false;
|
||||
_helixFill = false;
|
||||
_error = null;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -421,7 +385,7 @@ abstract class ListStoreBase with Store {
|
|||
debugPrint('Streams error: $e');
|
||||
if (_streamsCursor != null && _allStreams.isNotEmpty) {
|
||||
_streamsCursor = null;
|
||||
_ascHelixFill = false;
|
||||
_helixFill = false;
|
||||
_error = null;
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -429,6 +393,63 @@ abstract class ListStoreBase with Store {
|
|||
}
|
||||
}
|
||||
|
||||
/// GQL directory page with integrity retry. Returns null to trigger Helix.
|
||||
Future<StreamsTwitch?> _fetchGqlDirectory({
|
||||
required List<String> languages,
|
||||
}) async {
|
||||
final gql = twitchGqlApi;
|
||||
if (gql == null) return null;
|
||||
|
||||
final langs = languages.isEmpty ? null : languages;
|
||||
|
||||
try {
|
||||
return await authStore.user.runWithGqlIntegrity(
|
||||
gqlToken: authStore.gqlToken,
|
||||
action: ({authToken, integrityToken, deviceId}) {
|
||||
switch (listType) {
|
||||
case ListType.tag:
|
||||
return gql.getStreams(
|
||||
freeformTags: [freeformTag!],
|
||||
sort: sortOrder,
|
||||
languages: langs,
|
||||
cursor: _streamsCursor,
|
||||
authToken: authToken,
|
||||
integrityToken: integrityToken,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
case ListType.category:
|
||||
return gql.getGameStreams(
|
||||
gameId: categoryId!,
|
||||
sort: sortOrder,
|
||||
languages: langs,
|
||||
cursor: _streamsCursor,
|
||||
authToken: authToken,
|
||||
integrityToken: integrityToken,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
case ListType.top:
|
||||
return gql.getStreams(
|
||||
sort: sortOrder,
|
||||
languages: langs,
|
||||
cursor: _streamsCursor,
|
||||
authToken: authToken,
|
||||
integrityToken: integrityToken,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
case ListType.followed:
|
||||
throw StateError('GQL directory not used for followed');
|
||||
}
|
||||
},
|
||||
);
|
||||
} on IntegrityCheckException catch (e) {
|
||||
debugPrint('Streams GQL integrity: $e — falling back to Helix');
|
||||
_helixFill = true;
|
||||
// Keep cursor null so Helix starts from the top (dedupe handles overlap).
|
||||
_streamsCursor = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isAscDirectory =>
|
||||
sortOrder == StreamViewerSort.viewersAsc &&
|
||||
listType != ListType.followed;
|
||||
|
|
@ -514,7 +535,8 @@ abstract class ListStoreBase with Store {
|
|||
}
|
||||
|
||||
_streamsCursor = null;
|
||||
_ascHelixFill = false;
|
||||
_helixFill = false;
|
||||
_lastPageWasGql = false;
|
||||
await getStreams();
|
||||
}
|
||||
|
||||
|
|
@ -560,5 +582,5 @@ abstract class ListStoreBase with Store {
|
|||
/// [ListType.followed] is the list of streams that the user is following.
|
||||
/// [ListType.top] is the list of top streams.
|
||||
/// [ListType.category] is the list of streams under a category.
|
||||
/// [ListType.tag] is streams matching a freeform tag (Helix filter; GQL ASC first page).
|
||||
/// [ListType.tag] is streams matching a freeform tag (GQL sort + tags; Helix fallback).
|
||||
enum ListType { followed, top, category, tag }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
|||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/apis/twitch_integrity.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
|
@ -129,40 +130,98 @@ abstract class UserStoreBase with Store {
|
|||
required String gqlToken,
|
||||
required bool follow,
|
||||
}) async {
|
||||
final deviceId = await _ensureDeviceId();
|
||||
|
||||
Future<void> run({String? integrityToken}) {
|
||||
await runWithGqlIntegrity(
|
||||
gqlToken: gqlToken,
|
||||
action: ({authToken, integrityToken, deviceId}) {
|
||||
if (follow) {
|
||||
return twitchGqlApi.followUser(
|
||||
targetId: targetId,
|
||||
authToken: gqlToken,
|
||||
authToken: authToken!,
|
||||
integrityToken: integrityToken,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
}
|
||||
return twitchGqlApi.unfollowUser(
|
||||
targetId: targetId,
|
||||
authToken: gqlToken,
|
||||
authToken: authToken!,
|
||||
integrityToken: integrityToken,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs a GQL call with optional Client-Integrity; refreshes once on
|
||||
/// [IntegrityCheckException] when a web [gqlToken] is available.
|
||||
///
|
||||
/// Dio-minted tokens that are `is_bad_bot` are discarded; callers should
|
||||
/// then capture credentials via a WebView and [applyWebIntegrity].
|
||||
Future<T> runWithGqlIntegrity<T>({
|
||||
required String? gqlToken,
|
||||
required Future<T> Function({
|
||||
required String? authToken,
|
||||
required String? integrityToken,
|
||||
required String? deviceId,
|
||||
}) action,
|
||||
}) async {
|
||||
final deviceId = await _ensureDeviceId();
|
||||
final authToken =
|
||||
gqlToken != null && gqlToken.isNotEmpty ? gqlToken : null;
|
||||
|
||||
Future<T> run(String? integrityToken) => action(
|
||||
authToken: authToken,
|
||||
integrityToken: integrityToken,
|
||||
deviceId: _deviceId ?? deviceId,
|
||||
);
|
||||
|
||||
try {
|
||||
await run(integrityToken: _validIntegrityToken());
|
||||
return await run(_validIntegrityToken());
|
||||
} on IntegrityCheckException {
|
||||
if (authToken == null) {
|
||||
clearIntegrity();
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
final integrity = await twitchGqlApi.fetchIntegrityToken(
|
||||
authToken: gqlToken,
|
||||
deviceId: deviceId,
|
||||
authToken: authToken,
|
||||
deviceId: _deviceId ?? deviceId,
|
||||
);
|
||||
_integrityToken = integrity.token;
|
||||
_integrityExpirationMs = integrity.expirationMs;
|
||||
await run(integrityToken: integrity.token);
|
||||
return await run(integrity.token);
|
||||
} on IntegrityCheckException {
|
||||
clearIntegrity();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores WebView-captured integrity (must match [GqlIntegrityCredentials.deviceId]).
|
||||
Future<void> applyWebIntegrity(GqlIntegrityCredentials credentials) async {
|
||||
if (TwitchIntegrity.isBadBot(credentials.integrityToken)) {
|
||||
clearIntegrity();
|
||||
throw const IntegrityCheckException(
|
||||
'Twitch integrity token flagged as bad bot',
|
||||
);
|
||||
}
|
||||
_integrityToken = credentials.integrityToken;
|
||||
_integrityExpirationMs = credentials.expirationMs;
|
||||
_deviceId = credentials.deviceId;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_deviceIdKey, credentials.deviceId);
|
||||
}
|
||||
|
||||
void clearIntegrity() {
|
||||
_integrityToken = null;
|
||||
_integrityExpirationMs = 0;
|
||||
}
|
||||
|
||||
String? _validIntegrityToken() {
|
||||
if (_integrityToken == null) return null;
|
||||
if (TwitchIntegrity.isBadBot(_integrityToken!)) {
|
||||
clearIntegrity();
|
||||
return null;
|
||||
}
|
||||
if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -286,8 +345,7 @@ abstract class UserStoreBase with Store {
|
|||
_blockedUsers.clear();
|
||||
_moderatedChannels.clear();
|
||||
_followCache.clear();
|
||||
_integrityToken = null;
|
||||
_integrityExpirationMs = 0;
|
||||
clearIntegrity();
|
||||
_disposeReaction?.call();
|
||||
_disposeReaction = null;
|
||||
}
|
||||
|
|
|
|||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
|
|||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/blurred_container.dart';
|
||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||
import 'package:frosty/widgets/integrity_webview_dialog.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
|
|
@ -106,16 +107,78 @@ class _UserActionsModalState extends State<UserActionsModal> {
|
|||
setState(() => _followActionInProgress = true);
|
||||
|
||||
try {
|
||||
if (following) {
|
||||
await widget.authStore.user.unfollow(
|
||||
targetId: widget.userId,
|
||||
await _runFollowMutation(following: following, gqlToken: gqlToken);
|
||||
} on IntegrityCheckException {
|
||||
if (!mounted) return;
|
||||
// Dio /integrity is almost always bad_bot — mint via WebView like Xtra.
|
||||
final credentials = await showIntegrityWebViewDialog(
|
||||
context: context,
|
||||
gqlToken: gqlToken,
|
||||
);
|
||||
} else {
|
||||
await widget.authStore.user.follow(
|
||||
targetId: widget.userId,
|
||||
gqlToken: gqlToken,
|
||||
if (!mounted) return;
|
||||
if (credentials == null) {
|
||||
setState(() => _followActionInProgress = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: AlertMessage(
|
||||
message: 'Follow cancelled — Twitch verification required',
|
||||
centered: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await widget.authStore.user.applyWebIntegrity(credentials);
|
||||
await _runFollowMutation(following: following, gqlToken: gqlToken);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _followActionInProgress = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: AlertMessage(message: e.message, centered: false),
|
||||
),
|
||||
);
|
||||
return;
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _followActionInProgress = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: AlertMessage(
|
||||
message: following
|
||||
? 'Could not unfollow ${widget.name}'
|
||||
: 'Could not follow ${widget.name}',
|
||||
centered: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _followActionInProgress = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: AlertMessage(message: e.message, centered: false),
|
||||
),
|
||||
);
|
||||
return;
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _followActionInProgress = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: AlertMessage(
|
||||
message: following
|
||||
? 'Could not unfollow ${widget.name}'
|
||||
: 'Could not follow ${widget.name}',
|
||||
centered: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
@ -139,29 +202,21 @@ class _UserActionsModalState extends State<UserActionsModal> {
|
|||
),
|
||||
),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _followActionInProgress = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: AlertMessage(
|
||||
message: e.message,
|
||||
centered: false,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
Future<void> _runFollowMutation({
|
||||
required bool following,
|
||||
required String gqlToken,
|
||||
}) async {
|
||||
if (following) {
|
||||
await widget.authStore.user.unfollow(
|
||||
targetId: widget.userId,
|
||||
gqlToken: gqlToken,
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _followActionInProgress = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: AlertMessage(
|
||||
message: following
|
||||
? 'Could not unfollow ${widget.name}'
|
||||
: 'Could not follow ${widget.name}',
|
||||
centered: false,
|
||||
),
|
||||
),
|
||||
} else {
|
||||
await widget.authStore.user.follow(
|
||||
targetId: widget.userId,
|
||||
gqlToken: gqlToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
|
|
@ -69,4 +71,32 @@ void main() {
|
|||
await api.unfollowUser(targetId: '999', authToken: 'web-token');
|
||||
});
|
||||
});
|
||||
|
||||
group('fetchIntegrityToken', () {
|
||||
test('rejects bad_bot tokens', () async {
|
||||
final payload = base64Url.encode(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'is_bad_bot': 'true',
|
||||
'device_id': 'dev',
|
||||
'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/integrity',
|
||||
(server) => server.reply(200, {
|
||||
'token': 'v4.public.$payload.sig',
|
||||
'expiration': DateTime.now().millisecondsSinceEpoch + 600000,
|
||||
}),
|
||||
data: Matchers.any,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
api.fetchIntegrityToken(authToken: 'web', deviceId: 'dev'),
|
||||
throwsA(isA<IntegrityCheckException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
129
test/apis/twitch_gql_streams_test.dart
Normal file
129
test/apis/twitch_gql_streams_test.dart
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||
|
||||
void main() {
|
||||
late Dio dio;
|
||||
late DioAdapter dioAdapter;
|
||||
late TwitchGqlApi api;
|
||||
|
||||
setUp(() {
|
||||
dio = Dio(BaseOptions());
|
||||
dioAdapter = DioAdapter(dio: dio);
|
||||
api = TwitchGqlApi(dio);
|
||||
});
|
||||
|
||||
Map<String, Object?> streamEdge({
|
||||
required String id,
|
||||
required String login,
|
||||
required int viewers,
|
||||
String? cursor,
|
||||
}) {
|
||||
return {
|
||||
'cursor': cursor ?? 'cursor-$id',
|
||||
'node': {
|
||||
'id': id,
|
||||
'title': '$login live',
|
||||
'viewersCount': viewers,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'language': 'en',
|
||||
'game': {'id': '1', 'name': 'Game'},
|
||||
'broadcaster': {
|
||||
'id': id,
|
||||
'login': login,
|
||||
'displayName': login,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
group('getStreams', () {
|
||||
test('parses freeform tag page and cursor', () async {
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/gql',
|
||||
(server) => server.reply(200, {
|
||||
'data': {
|
||||
'streams': {
|
||||
'edges': [
|
||||
streamEdge(id: '1', login: 'a', viewers: 10),
|
||||
streamEdge(id: '2', login: 'b', viewers: 5),
|
||||
],
|
||||
'pageInfo': {'hasNextPage': true},
|
||||
},
|
||||
},
|
||||
}),
|
||||
data: Matchers.any,
|
||||
);
|
||||
|
||||
final page = await api.getStreams(
|
||||
freeformTags: ['Deutsch'],
|
||||
sort: StreamViewerSort.viewersAsc,
|
||||
);
|
||||
|
||||
expect(page.data.map((s) => s.userLogin), ['a', 'b']);
|
||||
expect(page.data.map((s) => s.viewerCount), [10, 5]);
|
||||
expect(page.pagination['cursor'], 'cursor-2');
|
||||
});
|
||||
|
||||
test('throws IntegrityCheckException on integrity errors', () async {
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/gql',
|
||||
(server) => server.reply(200, {
|
||||
'errors': [
|
||||
{'message': 'failed integrity check'},
|
||||
],
|
||||
'data': {'streams': null},
|
||||
}),
|
||||
data: Matchers.any,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
api.getStreams(
|
||||
freeformTags: ['Deutsch'],
|
||||
cursor: 'page-2',
|
||||
authToken: 'web',
|
||||
integrityToken: 'stale',
|
||||
deviceId: 'device',
|
||||
),
|
||||
throwsA(isA<IntegrityCheckException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('sends Client-Integrity and X-Device-Id when provided', () async {
|
||||
dioAdapter.onPost(
|
||||
'https://gql.twitch.tv/gql',
|
||||
(server) => server.reply(200, {
|
||||
'data': {
|
||||
'streams': {
|
||||
'edges': [streamEdge(id: '1', login: 'a', viewers: 1)],
|
||||
'pageInfo': {'hasNextPage': false},
|
||||
},
|
||||
},
|
||||
}),
|
||||
data: Matchers.any,
|
||||
headers: {
|
||||
'Client-ID': Matchers.any,
|
||||
'Content-Type': Matchers.any,
|
||||
'User-Agent': Matchers.any,
|
||||
'Authorization': 'OAuth web-token',
|
||||
'X-Device-Id': 'dev-123',
|
||||
'Client-Integrity': 'integrity-token',
|
||||
},
|
||||
);
|
||||
|
||||
final page = await api.getStreams(
|
||||
freeformTags: ['English'],
|
||||
sort: StreamViewerSort.viewersDesc,
|
||||
cursor: 'after-1',
|
||||
authToken: 'web-token',
|
||||
integrityToken: 'integrity-token',
|
||||
deviceId: 'dev-123',
|
||||
);
|
||||
|
||||
expect(page.data, hasLength(1));
|
||||
expect(page.pagination, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
42
test/apis/twitch_integrity_test.dart
Normal file
42
test/apis/twitch_integrity_test.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/twitch_integrity.dart';
|
||||
|
||||
String _tokenFor(Map<String, Object?> payload) {
|
||||
final encoded = base64Url.encode(utf8.encode(jsonEncode(payload)));
|
||||
return 'v4.public.$encoded.fakesig';
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('TwitchIntegrity', () {
|
||||
test('detects is_bad_bot string flag', () {
|
||||
final token = _tokenFor({
|
||||
'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
|
||||
'device_id': 'abc123',
|
||||
'is_bad_bot': 'true',
|
||||
'exp': '2099-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(TwitchIntegrity.isBadBot(token), isTrue);
|
||||
expect(TwitchIntegrity.deviceId(token), 'abc123');
|
||||
expect(TwitchIntegrity.expirationMs(token), isNotNull);
|
||||
});
|
||||
|
||||
test('detects is_bad_bot bool flag', () {
|
||||
final token = _tokenFor({
|
||||
'is_bad_bot': true,
|
||||
'device_id': 'dev',
|
||||
});
|
||||
expect(TwitchIntegrity.isBadBot(token), isTrue);
|
||||
});
|
||||
|
||||
test('returns false when not flagged', () {
|
||||
final token = _tokenFor({
|
||||
'is_bad_bot': 'false',
|
||||
'device_id': 'dev',
|
||||
});
|
||||
expect(TwitchIntegrity.isBadBot(token), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
203
test/screens/home/stream_list/stream_list_store_test.dart
Normal file
203
test/screens/home/stream_list/stream_list_store_test.dart
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class MockTwitchApi extends Mock implements TwitchApi {}
|
||||
|
||||
class MockTwitchGqlApi extends Mock implements TwitchGqlApi {}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
registerFallbackValue(StreamViewerSort.viewersDesc);
|
||||
registerFallbackValue(<String>[]);
|
||||
});
|
||||
|
||||
late MockTwitchApi api;
|
||||
late MockTwitchGqlApi gqlApi;
|
||||
late AuthStore authStore;
|
||||
late SettingsStore settingsStore;
|
||||
|
||||
StreamTwitch stream(String id, int viewers) => StreamTwitch(
|
||||
id,
|
||||
'user$id',
|
||||
'User$id',
|
||||
'game',
|
||||
'Game',
|
||||
'title',
|
||||
viewers,
|
||||
'2026-01-01T00:00:00Z',
|
||||
'https://cdn/live_user_$id-{width}x{height}.jpg',
|
||||
);
|
||||
|
||||
Future<void> waitUntilIdle(ListStore store) async {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
for (var i = 0; i < 50 && store.isLoading; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
}
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
api = MockTwitchApi();
|
||||
gqlApi = MockTwitchGqlApi();
|
||||
authStore = AuthStore(twitchApi: api, twitchGqlApi: gqlApi);
|
||||
settingsStore = SettingsStore()..streamLanguages = [];
|
||||
|
||||
when(() => api.getUserInfo()).thenAnswer(
|
||||
(_) async => const UserTwitch(
|
||||
'123',
|
||||
'me',
|
||||
'Me',
|
||||
'https://cdn/me.png',
|
||||
),
|
||||
);
|
||||
when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer(
|
||||
(_) async => <UserBlockedTwitch>[],
|
||||
);
|
||||
});
|
||||
|
||||
group('ListStore tag GQL pagination', () {
|
||||
test('keeps server ASC order across GQL pages', () async {
|
||||
when(
|
||||
() => gqlApi.getStreams(
|
||||
freeformTags: any(named: 'freeformTags'),
|
||||
languages: any(named: 'languages'),
|
||||
sort: any(named: 'sort'),
|
||||
cursor: any(named: 'cursor'),
|
||||
first: any(named: 'first'),
|
||||
authToken: any(named: 'authToken'),
|
||||
integrityToken: any(named: 'integrityToken'),
|
||||
deviceId: any(named: 'deviceId'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
final cursor = invocation.namedArguments[#cursor] as String?;
|
||||
if (cursor == null) {
|
||||
return StreamsTwitch(
|
||||
[stream('1', 3), stream('2', 7)],
|
||||
const {'cursor': 'c1'},
|
||||
);
|
||||
}
|
||||
expect(cursor, 'c1');
|
||||
return StreamsTwitch(
|
||||
[stream('3', 12), stream('4', 20)],
|
||||
const {},
|
||||
);
|
||||
});
|
||||
|
||||
settingsStore.streamViewerSort = StreamViewerSort.viewersAsc;
|
||||
|
||||
final store = ListStore(
|
||||
authStore: authStore,
|
||||
settingsStore: settingsStore,
|
||||
twitchApi: api,
|
||||
twitchGqlApi: gqlApi,
|
||||
listType: ListType.tag,
|
||||
freeformTag: 'Deutsch',
|
||||
);
|
||||
|
||||
await waitUntilIdle(store);
|
||||
|
||||
expect(store.streams.map((s) => s.userId), ['1', '2']);
|
||||
expect(store.hasMore, isTrue);
|
||||
|
||||
await store.getStreams();
|
||||
await waitUntilIdle(store);
|
||||
|
||||
expect(store.streams.map((s) => s.userId), ['1', '2', '3', '4']);
|
||||
expect(store.streams.map((s) => s.viewerCount), [3, 7, 12, 20]);
|
||||
expect(store.hasMore, isFalse);
|
||||
verifyNever(
|
||||
() => api.getDirectoryStreams(
|
||||
languages: any(named: 'languages'),
|
||||
gameId: any(named: 'gameId'),
|
||||
freeformTag: any(named: 'freeformTag'),
|
||||
cursor: any(named: 'cursor'),
|
||||
minCount: any(named: 'minCount'),
|
||||
maxPages: any(named: 'maxPages'),
|
||||
),
|
||||
);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test('falls back to Helix when GQL page-2 integrity fails', () async {
|
||||
var gqlCalls = 0;
|
||||
when(
|
||||
() => gqlApi.getStreams(
|
||||
freeformTags: any(named: 'freeformTags'),
|
||||
languages: any(named: 'languages'),
|
||||
sort: any(named: 'sort'),
|
||||
cursor: any(named: 'cursor'),
|
||||
first: any(named: 'first'),
|
||||
authToken: any(named: 'authToken'),
|
||||
integrityToken: any(named: 'integrityToken'),
|
||||
deviceId: any(named: 'deviceId'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
gqlCalls++;
|
||||
final cursor = invocation.namedArguments[#cursor] as String?;
|
||||
if (cursor == null) {
|
||||
return StreamsTwitch(
|
||||
[stream('1', 100)],
|
||||
const {'cursor': 'c1'},
|
||||
);
|
||||
}
|
||||
throw const IntegrityCheckException('failed integrity check');
|
||||
});
|
||||
|
||||
when(
|
||||
() => api.getDirectoryStreams(
|
||||
languages: any(named: 'languages'),
|
||||
gameId: any(named: 'gameId'),
|
||||
freeformTag: any(named: 'freeformTag'),
|
||||
cursor: any(named: 'cursor'),
|
||||
minCount: any(named: 'minCount'),
|
||||
maxPages: any(named: 'maxPages'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => StreamsTwitch(
|
||||
[stream('1', 100), stream('9', 50)],
|
||||
const {},
|
||||
),
|
||||
);
|
||||
|
||||
final store = ListStore(
|
||||
authStore: authStore,
|
||||
settingsStore: settingsStore,
|
||||
twitchApi: api,
|
||||
twitchGqlApi: gqlApi,
|
||||
listType: ListType.tag,
|
||||
freeformTag: 'Deutsch',
|
||||
);
|
||||
|
||||
await waitUntilIdle(store);
|
||||
expect(store.streams.map((s) => s.userId), ['1']);
|
||||
|
||||
await store.getStreams();
|
||||
await waitUntilIdle(store);
|
||||
|
||||
expect(gqlCalls, greaterThanOrEqualTo(2));
|
||||
expect(store.streams.map((s) => s.userId), ['1', '9']);
|
||||
verify(
|
||||
() => api.getDirectoryStreams(
|
||||
languages: any(named: 'languages'),
|
||||
gameId: any(named: 'gameId'),
|
||||
freeformTag: any(named: 'freeformTag'),
|
||||
cursor: any(named: 'cursor'),
|
||||
minCount: any(named: 'minCount'),
|
||||
maxPages: any(named: 'maxPages'),
|
||||
),
|
||||
).called(1);
|
||||
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/apis/base_api_client.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||
import 'package:frosty/apis/twitch_integrity.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
|
@ -178,5 +181,88 @@ void main() {
|
|||
|
||||
expect(userStore.cachedIsFollowing('999'), isFalse);
|
||||
});
|
||||
|
||||
test('runWithGqlIntegrity refreshes token and retries', () async {
|
||||
var calls = 0;
|
||||
when(
|
||||
() => gqlApi.fetchIntegrityToken(
|
||||
authToken: any(named: 'authToken'),
|
||||
deviceId: any(named: 'deviceId'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => (
|
||||
token: 'fresh-integrity',
|
||||
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
||||
),
|
||||
);
|
||||
|
||||
final userStore = makeStore(hasModeratorScopes: false);
|
||||
final result = await userStore.runWithGqlIntegrity<String>(
|
||||
gqlToken: 'web-token',
|
||||
action: ({authToken, integrityToken, deviceId}) async {
|
||||
calls++;
|
||||
expect(authToken, 'web-token');
|
||||
expect(deviceId, isNotEmpty);
|
||||
if (calls == 1) {
|
||||
throw const IntegrityCheckException('failed integrity check');
|
||||
}
|
||||
expect(integrityToken, 'fresh-integrity');
|
||||
return 'ok';
|
||||
},
|
||||
);
|
||||
|
||||
expect(result, 'ok');
|
||||
expect(calls, 2);
|
||||
verify(
|
||||
() => gqlApi.fetchIntegrityToken(
|
||||
authToken: 'web-token',
|
||||
deviceId: any(named: 'deviceId'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('runWithGqlIntegrity rethrows when no gqlToken', () async {
|
||||
final userStore = makeStore(hasModeratorScopes: false);
|
||||
|
||||
await expectLater(
|
||||
userStore.runWithGqlIntegrity<void>(
|
||||
gqlToken: null,
|
||||
action: ({authToken, integrityToken, deviceId}) async {
|
||||
throw const IntegrityCheckException('failed integrity check');
|
||||
},
|
||||
),
|
||||
throwsA(isA<IntegrityCheckException>()),
|
||||
);
|
||||
verifyNever(
|
||||
() => gqlApi.fetchIntegrityToken(
|
||||
authToken: any(named: 'authToken'),
|
||||
deviceId: any(named: 'deviceId'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('applyWebIntegrity stores device id for later GQL calls', () async {
|
||||
final userStore = makeStore(hasModeratorScopes: false);
|
||||
await userStore.applyWebIntegrity(
|
||||
GqlIntegrityCredentials(
|
||||
integrityToken: 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({
|
||||
'is_bad_bot': 'false',
|
||||
'device_id': 'webview-device',
|
||||
})))}.sig',
|
||||
deviceId: 'webview-device',
|
||||
expirationMs: DateTime.now().millisecondsSinceEpoch + 600000,
|
||||
),
|
||||
);
|
||||
|
||||
final deviceId = await userStore.runWithGqlIntegrity<String>(
|
||||
gqlToken: 'web-token',
|
||||
action: ({authToken, integrityToken, deviceId}) async {
|
||||
expect(deviceId, 'webview-device');
|
||||
expect(integrityToken, isNotNull);
|
||||
return deviceId!;
|
||||
},
|
||||
);
|
||||
expect(deviceId, 'webview-device');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue