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,40 @@
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});
})();
''';
@@ -1,4 +1,7 @@
import 'package:flutter/material.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' show IconButton;
import 'package:flutter/material.dart' hide IconButton;
import '../../l10n/l10n.dart';
import '../../shared/widgets/app_tooltip.dart';
import 'webview_rpc_service.dart';
@@ -70,11 +73,24 @@ 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,
),
),
AppTooltip(
message: context.l10n.close,
child: IconButton.ghost(
onPressed: () => WebViewRpcService.instance.closePageByUser(page.pageId),
icon: const Icon(Icons.close),
),
),
],
),
),
Expanded(child: content),
+75 -11
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -10,6 +11,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,
@@ -117,6 +119,38 @@ class WebViewRpcService {
final method = body['method'] as String? ?? '';
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, 'error': null});
} catch (e, stackTrace) {
@@ -261,13 +295,15 @@ class WebViewRpcService {
throw WebViewRpcException(code: 'INVALID_REQUEST', message: 'missing or invalid "cookie"');
}
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(code: 'PAGE_NOT_FOUND', message: 'page not found');
}
_syncPages();
await page.dispose();
await page.dispose(reason: reason);
}
void _syncPages() {
@@ -309,6 +345,12 @@ 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 WebViewProfile profile;
late final CookieManager cookieManager = profile.cookieManager;
@@ -328,11 +370,19 @@ 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(width > 0 ? width.toDouble() : 1, height > 0 ? height.toDouble() : 1),
@@ -354,14 +404,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 {
@@ -507,8 +549,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(code: 'PAGE_NOT_FOUND', message: 'webview page closed'));
}
@@ -587,6 +632,8 @@ class WebViewRpcPageSession {
}
void _handleReceivedError(InAppWebViewController controller, WebResourceRequest request, WebResourceError error) {
if (request.isForMainFrame != true) return;
_emit('load-error', {'url': request.url.toString(), 'message': error.description});
_completeNavigationError(WebViewRpcException(code: 'NAVIGATION_FAILED', message: error.description));
}
@@ -595,6 +642,11 @@ 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',
@@ -651,6 +703,18 @@ 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;
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:gopeed/app/rpc/webview_rpc_service.dart';
class _Profile extends Fake implements WebViewProfile {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('closed emits reason once and closes listener stream', () async {
final page = WebViewRpcPageSession(
pageId: 'test',
profile: _Profile(),
headless: true,
debug: false,
title: '',
width: 1,
height: 1,
userAgent: '',
);
// Consume the expected ready failure when an unopened page is disposed.
final pending = page.execute('1', []);
final failure = expectLater(pending, throwsA(isA<WebViewRpcException>()));
final events = page.events.toList();
await page.dispose(reason: 'user');
await page.dispose(reason: 'api');
await failure;
expect(await events, [
{
'event': 'closed',
'data': {'reason': 'user'},
},
]);
});
}