/// 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 _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 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 = []; _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 asMapFor(Uri uri) { final header = headerFor(uri); if (header.isEmpty) return {}; final out = {}; 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 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, ); }