feat(extension): expose host version and WebView page events

This commit is contained in:
Levi committed 2026-09-17 23:43:39 +08:00
1 parent 7c1447f659
commit dfe8e9a3fd
27 files changed
+1230 -191

No files matched your search

@@ -0,0 +1,39 @@
import 'dart:convert';
/// Runs before page scripts and reports only the main document's events.
String buildWebViewEventScript(String channel) => '''
(() => {
if (window !== window.top) return;
const pending = [];
const flush = () => {
const bridge = window.flutter_inappwebview;
if (!bridge || typeof bridge.callHandler !== 'function') return;
while (pending.length) bridge.callHandler(${jsonEncode(channel)}, JSON.stringify(pending.shift()));
};
const send = (event, data) => { pending.push({event, data}); flush(); };
window.addEventListener('flutterInAppWebViewPlatformReady', flush);
let lastURL = String(location.href);
const changed = () => {
const url = String(location.href);
if (url === lastURL) return;
lastURL = url;
send('url-changed', {url, sameDocument: true});
};
send('url-changed', {url: lastURL, sameDocument: false});
for (const name of ['pushState', 'replaceState']) {
const original = history[name];
history[name] = function(...args) {
const result = Reflect.apply(original, this, args);
changed();
return result;
};
}
window.addEventListener('hashchange', changed);
window.addEventListener('popstate', changed);
window.addEventListener('pageshow', (event) => {
if (event.persisted) send('url-changed', {url: String(location.href), sameDocument: false});
else changed();
});
window.addEventListener('load', () => send('load', {url: String(location.href)}), {once: true});
})();
''';
@@ -79,12 +79,19 @@ class _WebViewRpcPageViewState extends State<_WebViewRpcPageView> {
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Text(
page.title.isNotEmpty ? page.title : 'WebView',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
child: Row(children: [
Expanded(
child: Text(
page.title.isNotEmpty ? page.title : 'WebView',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
)),
IconButton(
onPressed: () => WebViewRpcService.instance
.closePageByUser(page.pageId),
icon: const Icon(Icons.close))
]),
),
Expanded(child: content),
],
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
@@ -9,6 +10,7 @@ import '../../core/common/start_config.dart';
import '../../util/log_util.dart';
import '../../util/util.dart';
import 'server.dart';
import 'webview_event_script.dart';
String buildWebViewExecuteScript({
required String channelName,
@@ -124,6 +126,38 @@ class WebViewRpcService {
final params = (body['params'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{};
try {
if (method == 'page.events') {
final page = _page(params);
ctx.response.headers.contentType =
ContentType('application', 'x-ndjson');
ctx.response.bufferOutput = false;
final output = StreamController<List<int>>();
void send(Map<String, dynamic> record) {
if (!output.isClosed) {
output.add(utf8.encode('${jsonEncode(record)}\n'));
}
}
final subscription = page.events.listen(send, onDone: () {
unawaited(output.close());
});
send({'ready': true});
final sending = ctx.response.addStream(output.stream);
try {
await Future.any([sending, ctx.response.done]);
} catch (_) {
// A disconnected listener cannot receive a JSON error response.
} finally {
await subscription.cancel();
unawaited(output.close());
try {
await sending;
} catch (_) {
// The response may have ended before the page did.
}
}
return;
}
final result = await _dispatch(method, params);
await ctx.writeJSON({
'result': result,
@@ -284,7 +318,10 @@ class WebViewRpcService {
);
}
Future<void> _closePage(String pageId) async {
Future<void> closePageByUser(String pageId) =>
_closePage(pageId, reason: 'user');
Future<void> _closePage(String pageId, {String reason = 'api'}) async {
final page = _pagesById.remove(pageId);
if (page == null) {
throw WebViewRpcException(
@@ -293,7 +330,7 @@ class WebViewRpcService {
);
}
_syncPages();
await page.dispose();
await page.dispose(reason: reason);
}
void _syncPages() {
@@ -340,6 +377,13 @@ class WebViewRpcPageSession {
required this.userAgent,
});
final StreamController<Map<String, dynamic>> _events =
StreamController.broadcast(sync: true);
Stream<Map<String, dynamic>> get events => _events.stream;
void _emit(String event, Map<String, dynamic> data) {
if (!_disposed) _events.add({'event': event, 'data': data});
}
final String pageId;
final CookieManager cookieManager;
final bool headless;
@@ -359,12 +403,18 @@ class WebViewRpcPageSession {
InAppWebViewController? _controller;
Completer<void>? _navigation;
String _currentUrl = '';
String _eventUrl = '';
int _executeSeq = 0;
bool _disposed = false;
Future<void> init() async {
callbackChannelName =
'__gopeedWebViewCallback_${pageId.replaceAll('-', '_')}';
_initScripts.add(UserScript(
source: buildWebViewEventScript(callbackChannelName),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: true,
));
if (headless) {
_headlessWebView = HeadlessInAppWebView(
initialSize: Size(
@@ -392,14 +442,6 @@ class WebViewRpcPageSession {
_initScripts.add(userScript);
final controller = await _controllerOrThrow();
await controller.addUserScript(userScript: userScript);
if (_currentUrl.isNotEmpty && !_disposed) {
try {
await controller.evaluateJavascript(source: script);
} catch (_) {
// Best-effort only for the current page. The user script persists
// for future navigations through the native user script registry.
}
}
}
Future<void> goto(String url, {int? timeoutMs, String? waitUntil}) async {
@@ -564,8 +606,11 @@ class WebViewRpcPageSession {
await cookieManager.deleteAllCookies();
}
Future<void> dispose() async {
Future<void> dispose({String reason = 'api'}) async {
if (_disposed) return;
_emit('closed', {'reason': reason});
_disposed = true;
unawaited(_events.close());
if (!_ready.isCompleted) {
_ready.completeError(
WebViewRpcException(
@@ -668,6 +713,9 @@ class WebViewRpcPageSession {
WebResourceRequest request,
WebResourceError error,
) {
if (request.isForMainFrame != true) return;
_emit('load-error',
{'url': request.url.toString(), 'message': error.description});
_completeNavigationError(
WebViewRpcException(
code: 'NAVIGATION_FAILED',
@@ -681,6 +729,12 @@ class WebViewRpcPageSession {
WebResourceRequest request,
WebResourceResponse errorResponse,
) {
if (request.isForMainFrame != true) return;
_emit('load-error', {
'url': request.url.toString(),
'message':
'HTTP ${errorResponse.statusCode}: ${errorResponse.reasonPhrase ?? 'navigation failed'}'
});
_completeNavigationError(
WebViewRpcException(
code: 'NAVIGATION_FAILED',
@@ -741,6 +795,20 @@ class WebViewRpcPageSession {
return;
}
final message = decoded.cast<String, dynamic>();
final event = message['event'];
if (event == 'url-changed' || event == 'load') {
final data = (message['data'] as Map).cast<String, dynamic>();
final url = data['url'] as String;
if (event == 'load' || url != _eventUrl) {
_eventUrl = url;
_currentUrl = url;
_emit(event as String, data);
if (event == 'url-changed' && data['sameDocument'] == true) {
_completeNavigation();
}
}
return;
}
final id = message['id']?.toString();
if (id == null || id.isEmpty) {
return;