feat: embed a Flutter G4F client with Cronet, cookie jar, and origin WebView
Port the Oracle off the Python g4f container onto on-device providers: Chromium HTTP via Cronet, a shared cookie session, IterList retry, hosted OpenAI-compat aggregators, and same-origin WebView fallbacks instead of CORS fetch.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
/// In-process cookie jar. Python g4f keeps a curl_cffi session; Dart/Cronet
|
||||
/// does not, so every provider must share cookies across GET-then-POST.
|
||||
class G4fCookieJar {
|
||||
final Map<String, _StoredCookie> _cookies = {};
|
||||
|
||||
int get length => _cookies.length;
|
||||
|
||||
void clear() => _cookies.clear();
|
||||
|
||||
void set({
|
||||
required String name,
|
||||
required String value,
|
||||
required String domain,
|
||||
String path = '/',
|
||||
DateTime? expires,
|
||||
bool secure = false,
|
||||
bool hostOnly = true,
|
||||
}) {
|
||||
if (name.isEmpty) return;
|
||||
final canon = _canonDomain(domain);
|
||||
if (canon.isEmpty) return;
|
||||
final key = _key(canon, path, name);
|
||||
if (value.isEmpty && expires != null) {
|
||||
_cookies.remove(key);
|
||||
return;
|
||||
}
|
||||
_cookies[key] = _StoredCookie(
|
||||
name: name,
|
||||
value: value,
|
||||
domain: canon,
|
||||
path: path.isEmpty ? '/' : path,
|
||||
expires: expires,
|
||||
secure: secure,
|
||||
hostOnly: hostOnly,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse `Set-Cookie` from an HTTP response for [uri].
|
||||
void absorb(Uri uri, Map<String, String> headers) {
|
||||
final raw = headers['set-cookie'];
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
for (final part in splitSetCookieHeader(raw)) {
|
||||
final parsed = _parseSetCookie(part, uri);
|
||||
if (parsed == null) continue;
|
||||
if (parsed.expired) {
|
||||
_cookies.remove(_key(parsed.domain, parsed.path, parsed.name));
|
||||
continue;
|
||||
}
|
||||
_cookies[_key(parsed.domain, parsed.path, parsed.name)] = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
String headerFor(Uri uri) {
|
||||
final now = DateTime.now().toUtc();
|
||||
final matching = <_StoredCookie>[];
|
||||
final stale = <String>[];
|
||||
_cookies.forEach((key, cookie) {
|
||||
if (cookie.expires != null && !cookie.expires!.isAfter(now)) {
|
||||
stale.add(key);
|
||||
return;
|
||||
}
|
||||
if (cookie.matches(uri)) matching.add(cookie);
|
||||
});
|
||||
for (final key in stale) {
|
||||
_cookies.remove(key);
|
||||
}
|
||||
matching.sort((a, b) => b.path.length.compareTo(a.path.length));
|
||||
return matching.map((c) => '${c.name}=${c.value}').join('; ');
|
||||
}
|
||||
|
||||
/// Test helper.
|
||||
Map<String, String> asMapFor(Uri uri) {
|
||||
final header = headerFor(uri);
|
||||
if (header.isEmpty) return {};
|
||||
final out = <String, String>{};
|
||||
for (final part in header.split('; ')) {
|
||||
final eq = part.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
out[part.substring(0, eq)] = part.substring(eq + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class _StoredCookie {
|
||||
_StoredCookie({
|
||||
required this.name,
|
||||
required this.value,
|
||||
required this.domain,
|
||||
required this.path,
|
||||
required this.secure,
|
||||
required this.hostOnly,
|
||||
this.expires,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String value;
|
||||
final String domain;
|
||||
final String path;
|
||||
final DateTime? expires;
|
||||
final bool secure;
|
||||
final bool hostOnly;
|
||||
|
||||
bool get expired {
|
||||
if (expires == null) return false;
|
||||
return !expires!.isAfter(DateTime.now().toUtc());
|
||||
}
|
||||
|
||||
bool matches(Uri uri) {
|
||||
if (secure && uri.scheme != 'https') return false;
|
||||
final host = uri.host.toLowerCase();
|
||||
if (hostOnly) {
|
||||
if (host != domain) return false;
|
||||
} else {
|
||||
if (host != domain && !host.endsWith('.$domain')) return false;
|
||||
}
|
||||
final reqPath = uri.path.isEmpty ? '/' : uri.path;
|
||||
if (path == '/') return true;
|
||||
if (reqPath == path) return true;
|
||||
if (reqPath.startsWith(path.endsWith('/') ? path : '$path/')) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _canonDomain(String domain) {
|
||||
var d = domain.trim().toLowerCase();
|
||||
if (d.startsWith('.')) d = d.substring(1);
|
||||
return d;
|
||||
}
|
||||
|
||||
String _key(String domain, String path, String name) =>
|
||||
'${_canonDomain(domain)}|$path|$name';
|
||||
|
||||
/// Split a folded Set-Cookie header (commas inside Expires dates).
|
||||
List<String> splitSetCookieHeader(String header) {
|
||||
return header
|
||||
.split(RegExp(r',(?=\s*[^;=]+=)'))
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
_StoredCookie? _parseSetCookie(String raw, Uri uri) {
|
||||
final parts = raw.split(';');
|
||||
if (parts.isEmpty) return null;
|
||||
final nv = parts.first.trim();
|
||||
final eq = nv.indexOf('=');
|
||||
if (eq <= 0) return null;
|
||||
final name = nv.substring(0, eq).trim();
|
||||
final value = nv.substring(eq + 1).trim();
|
||||
if (name.isEmpty) return null;
|
||||
|
||||
String? domain;
|
||||
var path = '/';
|
||||
DateTime? expires;
|
||||
var secure = false;
|
||||
var hostOnly = true;
|
||||
|
||||
for (var i = 1; i < parts.length; i++) {
|
||||
final attr = parts[i].trim();
|
||||
if (attr.isEmpty) continue;
|
||||
final aEq = attr.indexOf('=');
|
||||
final key = (aEq < 0 ? attr : attr.substring(0, aEq)).trim().toLowerCase();
|
||||
final val = aEq < 0 ? '' : attr.substring(aEq + 1).trim();
|
||||
if (key == 'domain' && val.isNotEmpty) {
|
||||
domain = _canonDomain(val);
|
||||
hostOnly = false;
|
||||
} else if (key == 'path' && val.isNotEmpty) {
|
||||
path = val;
|
||||
} else if (key == 'expires') {
|
||||
expires ??= DateTime.tryParse(val)?.toUtc();
|
||||
} else if (key == 'max-age') {
|
||||
final secs = int.tryParse(val);
|
||||
if (secs != null) {
|
||||
expires = DateTime.now().toUtc().add(Duration(seconds: secs));
|
||||
}
|
||||
} else if (key == 'secure') {
|
||||
secure = true;
|
||||
}
|
||||
}
|
||||
|
||||
return _StoredCookie(
|
||||
name: name,
|
||||
value: value,
|
||||
domain: domain ?? uri.host.toLowerCase(),
|
||||
path: path,
|
||||
expires: expires,
|
||||
secure: secure,
|
||||
hostOnly: domain == null ? true : hostOnly,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user