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,270 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../http_client.dart';
|
||||
import '../http_util.dart';
|
||||
import '../models.dart';
|
||||
import 'base_provider.dart';
|
||||
import 'webview_base_provider.dart';
|
||||
|
||||
/// Microsoft Copilot: Cronet session + WebSocket, then origin WebView (nodriver analog).
|
||||
class CopilotApp extends BaseProvider {
|
||||
@override
|
||||
String get name => 'CopilotApp';
|
||||
|
||||
@override
|
||||
String get url => 'https://copilot.microsoft.com';
|
||||
|
||||
@override
|
||||
bool get working => !kIsWeb;
|
||||
|
||||
@override
|
||||
Duration get requestTimeout => const Duration(seconds: 90);
|
||||
|
||||
@override
|
||||
List<String> get models => ['smart', 'chat', 'reasoning'];
|
||||
|
||||
@override
|
||||
String get defaultModel => 'smart';
|
||||
|
||||
static const _ua =
|
||||
'CopilotNative/30.0.440505001-prod (Android 14; Google; Pixel 8 Pro)';
|
||||
static const _startUrl = 'https://copilot.microsoft.com/c/api/start';
|
||||
|
||||
@override
|
||||
Stream<String> createAsyncGenerator(
|
||||
String model,
|
||||
List<Message> messages, {
|
||||
bool stream = false,
|
||||
}) async* {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError('CopilotApp requires dart:io (Android/iOS/desktop)');
|
||||
}
|
||||
|
||||
final seeds = _fakeCookieSeeds();
|
||||
_seedFakeCookies(seeds);
|
||||
final prompt = formatPrompt(messages);
|
||||
try {
|
||||
yield await _viaRestAndWs(model, prompt);
|
||||
return;
|
||||
} catch (e) {
|
||||
debugPrint('Copilot REST/WS failed, trying origin WebView: $e');
|
||||
}
|
||||
yield await _viaWebView(model, prompt, seeds);
|
||||
}
|
||||
|
||||
Future<String> _viaRestAndWs(String model, String prompt) async {
|
||||
final startReq = http.Request('POST', Uri.parse(_startUrl));
|
||||
startReq.headers.addAll({
|
||||
'content-type': 'application/json',
|
||||
'user-agent': _ua,
|
||||
'origin': url,
|
||||
'referer': '$url/',
|
||||
'X-Search-UILang': 'en-US',
|
||||
});
|
||||
startReq.body = jsonEncode({
|
||||
'timeZone': 'Europe/Moscow',
|
||||
'startNewConversation': true,
|
||||
'teenSupportEnabled': true,
|
||||
'correctPersonalizationSetting': true,
|
||||
'deferredDataUseCapable': true,
|
||||
});
|
||||
|
||||
final startRes = await g4fHttp.send(startReq).timeout(const Duration(seconds: 15));
|
||||
if (startRes.statusCode != 200) {
|
||||
final err = await startRes.stream.bytesToString();
|
||||
throw Exception('Copilot start ${startRes.statusCode}: $err');
|
||||
}
|
||||
final startBody = jsonDecode(utf8.decode(await startRes.stream.toBytes()));
|
||||
final conversationId = (startBody['currentConversationId'] ?? '').toString();
|
||||
if (conversationId.isEmpty) {
|
||||
throw Exception('Copilot: no conversation id');
|
||||
}
|
||||
|
||||
final cookieHeader = g4fCookies.headerFor(Uri.parse(url));
|
||||
final sessionId = const Uuid().v4();
|
||||
final wsUrl =
|
||||
'wss://copilot.microsoft.com/c/api/chat?api-version=2&clientSessionId=$sessionId';
|
||||
final ws = await WebSocket.connect(
|
||||
wsUrl,
|
||||
headers: {
|
||||
'User-Agent': _ua,
|
||||
'Origin': url,
|
||||
'Pragma': 'no-cache',
|
||||
'Cache-Control': 'no-cache',
|
||||
if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader,
|
||||
},
|
||||
).timeout(const Duration(seconds: 12));
|
||||
|
||||
try {
|
||||
final mode = _modeFor(model);
|
||||
var connected = false;
|
||||
final collected = StringBuffer();
|
||||
|
||||
await for (final raw in ws.timeout(const Duration(seconds: 35))) {
|
||||
if (raw is! String) continue;
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final event = data['event']?.toString();
|
||||
if (event == 'connected' || event == 'received') {
|
||||
if (!connected) {
|
||||
connected = true;
|
||||
ws.add(jsonEncode({
|
||||
'event': 'send',
|
||||
'conversationId': conversationId,
|
||||
'content': [
|
||||
{'type': 'text', 'text': prompt},
|
||||
],
|
||||
'mode': mode,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (event == 'appendText' || event == 'replaceText') {
|
||||
final text = data['text']?.toString() ?? '';
|
||||
if (text.isNotEmpty) collected.write(text);
|
||||
} else if (event == 'done') {
|
||||
break;
|
||||
} else if (event == 'error') {
|
||||
throw Exception('Copilot error: $data');
|
||||
}
|
||||
}
|
||||
|
||||
final text = collected.toString().trim();
|
||||
if (!looksLikeModelOutput(text)) {
|
||||
throw Exception('Copilot returned empty response');
|
||||
}
|
||||
return text;
|
||||
} finally {
|
||||
await ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _viaWebView(
|
||||
String model,
|
||||
String prompt,
|
||||
List<CookieSeed> seeds,
|
||||
) async {
|
||||
final promptJson = jsonEncode(prompt);
|
||||
final modeJson = jsonEncode(_modeFor(model));
|
||||
final raw = await runOriginWebView(
|
||||
originUrl: url,
|
||||
timeout: requestTimeout,
|
||||
seeds: seeds,
|
||||
functionBody: '''
|
||||
const prompt = $promptJson;
|
||||
const mode = $modeJson;
|
||||
const start = await fetch('/c/api/start', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
startNewConversation: true,
|
||||
teenSupportEnabled: true,
|
||||
correctPersonalizationSetting: true,
|
||||
deferredDataUseCapable: true
|
||||
})
|
||||
});
|
||||
if (!start.ok) {
|
||||
throw new Error('start ' + start.status + ' ' + (await start.text()).slice(0, 160));
|
||||
}
|
||||
const started = await start.json();
|
||||
const conversationId = started.currentConversationId;
|
||||
if (!conversationId) throw new Error('no conversation id');
|
||||
const sessionId = (crypto.randomUUID && crypto.randomUUID()) || String(Math.random()).slice(2);
|
||||
return await new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket('wss://copilot.microsoft.com/c/api/chat?api-version=2&clientSessionId=' + sessionId);
|
||||
let text = '';
|
||||
let sent = false;
|
||||
const timer = setTimeout(() => { try { ws.close(); } catch (e) {} reject(new Error('ws timeout')); }, 45000);
|
||||
ws.onmessage = (e) => {
|
||||
let data;
|
||||
try { data = JSON.parse(e.data); } catch (err) { return; }
|
||||
const event = data.event;
|
||||
if ((event === 'connected' || event === 'received') && !sent) {
|
||||
sent = true;
|
||||
ws.send(JSON.stringify({
|
||||
event: 'send',
|
||||
conversationId,
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
mode
|
||||
}));
|
||||
}
|
||||
if (event === 'appendText' || event === 'replaceText') text += data.text || '';
|
||||
if (event === 'done') { clearTimeout(timer); ws.close(); resolve(text); }
|
||||
if (event === 'error') { clearTimeout(timer); reject(new Error(JSON.stringify(data))); }
|
||||
};
|
||||
ws.onerror = () => { clearTimeout(timer); reject(new Error('ws error')); };
|
||||
});
|
||||
''',
|
||||
);
|
||||
final text = raw.trim();
|
||||
if (!looksLikeModelOutput(text)) {
|
||||
throw Exception('Copilot WebView unusable: ${text.substring(0, text.length.clamp(0, 180))}');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
void _seedFakeCookies(List<CookieSeed> seeds) {
|
||||
final uri = Uri.parse(url);
|
||||
for (final seed in seeds) {
|
||||
g4fCookies.set(
|
||||
name: seed.name,
|
||||
value: seed.value,
|
||||
domain: seed.domain ?? 'microsoft.com',
|
||||
path: seed.path,
|
||||
secure: seed.isSecure,
|
||||
hostOnly: false,
|
||||
);
|
||||
}
|
||||
// Touch the jar host as well as the parent domain.
|
||||
g4fCookies.set(
|
||||
name: '_C_ETH',
|
||||
value: '1',
|
||||
domain: uri.host,
|
||||
secure: true,
|
||||
);
|
||||
}
|
||||
|
||||
List<CookieSeed> _fakeCookieSeeds() {
|
||||
return [
|
||||
CookieSeed(name: '_C_ETH', value: '1', domain: '.microsoft.com'),
|
||||
CookieSeed(name: '_C_Auth', value: '', domain: '.microsoft.com'),
|
||||
CookieSeed(name: 'MUID', value: _hex(32), domain: '.microsoft.com'),
|
||||
CookieSeed(name: 'MUIDB', value: _hex(32), domain: '.microsoft.com'),
|
||||
CookieSeed(
|
||||
name: '_EDGE_S',
|
||||
value: 'F=1&SID=${_hex(32)}',
|
||||
domain: '.microsoft.com',
|
||||
),
|
||||
CookieSeed(name: '_EDGE_V', value: '1', domain: '.microsoft.com'),
|
||||
];
|
||||
}
|
||||
|
||||
String _modeFor(String model) {
|
||||
final m = model.toLowerCase();
|
||||
if (m.contains('reason') || m.contains('think') || m.contains('o1')) {
|
||||
return 'reasoning';
|
||||
}
|
||||
if (m.contains('chat') || m.contains('gpt-4')) return 'chat';
|
||||
return 'smart';
|
||||
}
|
||||
|
||||
String _hex(int len) {
|
||||
const hex = '0123456789ABCDEF';
|
||||
final r = Random();
|
||||
return String.fromCharCodes(
|
||||
Iterable.generate(len, (_) => hex.codeUnitAt(r.nextInt(16))),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user