app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,22 @@
/// H5 容器。来源:conti-docs/10-webview-h5.md。
///
/// ---------------------------------------------------------------------------
/// **适用范围:Embedded H5 仅用于承载 F6 页面,不做通用外链容器**(PRD §7.1)。
/// 任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。
///
/// 本包只负责**校验和承载**:换票(要发 HTTP)不在这里,`core_webview →
/// core_network` 不是 01 允许的依赖边——由调用方实现
/// [H5LaunchRepository] 后 override 进来。
///
/// TODO(10): 12 项 bridge 能力的具体 handler 尚未实现(依赖 native_media /
/// native_device,本次脚手架范围外)。[BridgeDispatcher.handlers] 现在是空表,
/// 任何调用都会得到 `UNSUPPORTED_METHOD`——这是预期行为,不是 bug。
/// ---------------------------------------------------------------------------
library;
export 'src/bridge.dart';
export 'src/bridge_shim.dart';
export 'src/h5_launch.dart';
export 'src/page_watchdog.dart';
export 'src/url_guard.dart';
export 'src/webview_session.dart';
+161
View File
@@ -0,0 +1,161 @@
/// JSBridge 的消息分发。来源:conti-docs/10-webview-h5.md §JSBridge 协议。
library;
import 'dart:async';
import 'dart:convert';
import 'package:core_foundation/core_foundation.dart';
import 'url_guard.dart';
/// 回给 H5 的错误。
///
/// `code` 是**稳定的字符串枚举**,不是数字,也不透传原生错误码——H5 侧按 code
/// 分支处理,`message` 只用于展示。取值见 [AppException.bridgeCode] 和
/// [BridgeErrorCode]。
class BridgeError {
/// 构造。
const BridgeError(this.code, this.message);
/// 稳定错误码。**一旦发布不能改**,H5 侧按它分支。
final String code;
/// 展示文案。
final String message;
}
/// [BridgeError.code] 里由 bridge 自己产生(而非来自 [AppException])的取值。
abstract final class BridgeErrorCode {
/// H5 调了一个当前 App 版本没有的能力。
///
/// 不静默忽略:H5 版本比 App 新时,明确告诉它"不支持"才能降级,
/// 否则 H5 侧的 Promise 永远 pending,页面卡死。
static const String unsupportedMethod = 'UNSUPPORTED_METHOD';
/// handler 抛了非 [AppException] 的异常,属于 bug。
static const String internalError = 'INTERNAL_ERROR';
}
/// 一项 bridge 能力的实现。
typedef BridgeHandler = Future<Object?> Function(Map<String, dynamic> params);
/// bridge 的日志出口。core_webview 不能依赖 core_logging(不是允许的依赖边)。
typedef BridgeLogSink = void Function(String message, {Object? error});
/// 把 `ContiBridge` 通道收到的原始字符串分发到各能力实现。
///
/// ---------------------------------------------------------------------------
/// 只开**一个** JavaScript Channel,所有能力走同一个通道分发。每个能力开一个
/// channel 会让来源校验、日志、错误处理各写一遍。
/// ---------------------------------------------------------------------------
class BridgeDispatcher {
/// [currentUrl] 通常是 `controller.currentUrl`[evaluateJavaScript] 通常是
/// `controller.runJavaScript`。注入而不是直接持有 `WebViewController`
/// 是为了这段安全逻辑能被单测覆盖。
BridgeDispatcher({
required this.urlGuard,
required this.handlers,
required this.currentUrl,
required this.evaluateJavaScript,
required this.log,
});
/// 白名单。
final UrlGuard urlGuard;
/// method → 实现。
final Map<String, BridgeHandler> handlers;
/// 当前主 frame 的 URL。
final Future<String?> Function() currentUrl;
/// 执行一段 JS(用于回包和推事件)。
final Future<void> Function(String js) evaluateJavaScript;
/// 日志出口。
final BridgeLogSink log;
/// 处理一条来自 H5 的原始消息。
Future<void> handle(String raw) async {
// ------------------------------------------------------------------
// 1. 来源校验。
//
// JavaScript Channel 会注入到 WebView 的**所有 frame,包括 iframe**。
// F6 页面里嵌的第三方 iframe 也能调 ContiBridge。
//
// 注意 currentUrl() 返回的是**主 frame** 的 URL:这一条能挡住"整页被导航
// 到恶意站点后调 bridge",挡不住"白名单页面内的恶意 iframe"。后者只能靠
// 协议层面约定 F6 不嵌不受信 iframe + 导航拦截限制 iframe 域名。
// ------------------------------------------------------------------
final String? current = await currentUrl();
if (!urlGuard.isAllowedUrl(current)) {
log('[bridge] 拒绝来自非白名单页面的调用: $current');
// 静默丢弃,不回包——不给探测者任何反馈。
return;
}
// 2. 解析必须容错:H5 传了畸形 JSON 不能让 App 崩。
final Map<String, dynamic> req;
try {
final Object? decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) {
log('[bridge] 消息不是对象');
return;
}
req = decoded;
} on FormatException catch (e) {
log('[bridge] 无法解析的消息', error: e);
return;
}
// id 由 H5 侧生成并原样回传,App 不生成——H5 的 Promise 映射表由它自己管。
final Object? id = req['id'];
final Object? method = req['method'];
if (id is! String || method is! String) {
log('[bridge] 缺少 id 或 method');
return;
}
final BridgeHandler? handler = handlers[method];
if (handler == null) {
await _reply(
id,
error: const BridgeError(BridgeErrorCode.unsupportedMethod, '当前 App 版本不支持该能力'),
);
return;
}
final Object? rawParams = req['params'];
final Map<String, dynamic> params = rawParams is Map<String, dynamic>
? rawParams
: const <String, dynamic>{};
try {
await _reply(id, data: await handler(params));
} on AppException catch (e) {
// 供应商/原生错误不透传,只给稳定 code + 可展示文案。
await _reply(id, error: BridgeError(e.bridgeCode, e.message));
} on Object catch (e) {
log('[bridge] $method 未预期异常', error: e);
await _reply(id, error: const BridgeError(BridgeErrorCode.internalError, '操作失败,请重试'));
}
}
/// 主动事件(App → H5,无 id)。如门店切换、上传进度。
Future<void> emit(String event, Map<String, dynamic> payload) {
final String json = jsonEncode(<String, dynamic>{'event': event, 'payload': payload});
return evaluateJavaScript('window.__contiBridgeEvent && window.__contiBridgeEvent($json);');
}
Future<void> _reply(String id, {Object? data, BridgeError? error}) {
final Map<String, dynamic> resp = <String, dynamic>{
'id': id,
'ok': error == null,
if (error == null) 'data': data,
if (error != null) 'error': <String, String>{'code': error.code, 'message': error.message},
};
return evaluateJavaScript(
'window.__contiBridgeCallback && window.__contiBridgeCallback(${jsonEncode(resp)});',
);
}
}
@@ -0,0 +1,44 @@
/// 注入给 H5 的 JS 胶水。来源:conti-docs/10-webview-h5.md §JS 侧胶水。
library;
/// `window.ContiBridge` 只是一个原始的 `postMessage` 通道,H5 侧直接用很难写。
/// 这段把它包成 Promise。
///
/// ---------------------------------------------------------------------------
/// **注入时机是 `onPageFinished`,不是 `onPageStarted`**——后者时 H5 的脚本
/// 可能还没执行完,会重复注入或时序错乱。
///
/// `__contiBridgeReady` 做幂等保护:SPA 内部路由变化可能触发多次回调。
///
/// H5 侧要处理"bridge 还没就绪"的情况,约定等待 `window.__contiBridgeReady`。
/// **这条要写进给 F6 的接入文档**(10 §与 F6 的接口对齐清单 第 1 条)。
/// ---------------------------------------------------------------------------
const String kBridgeShim = r'''
(function () {
if (window.__contiBridgeReady) return;
const pending = new Map();
window.__contiBridgeCallback = function (resp) {
const p = pending.get(resp.id);
if (!p) return;
pending.delete(resp.id);
resp.ok ? p.resolve(resp.data) : p.reject(resp.error);
};
window.__contiBridgeEvent = function (evt) {
window.dispatchEvent(new CustomEvent('conti:' + evt.event, { detail: evt.payload }));
};
const raw = window.ContiBridge;
window.ContiBridge = {
call: function (method, params) {
const id = String(Date.now()) + Math.random().toString(36).slice(2);
return new Promise(function (resolve, reject) {
pending.set(id, { resolve: resolve, reject: reject });
raw.postMessage(JSON.stringify({ id: id, method: method, params: params || {} }));
});
},
};
window.__contiBridgeReady = true;
})();
''';
/// JavaScript Channel 名。H5 侧按这个名字调用,**改名等于破坏所有 F6 页面**。
const String kBridgeChannelName = 'ContiBridge';
@@ -0,0 +1,49 @@
/// H5 启动信息与换票端口。来源:conti-docs/10-webview-h5.md §H5 启动流程。
library;
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// `/api/v1/h5/launch` 的返回。
@immutable
class H5LaunchInfo {
/// 构造。
const H5LaunchInfo({required this.url, required this.title, required this.ttl});
/// 已由**后端**拼好票据和上下文参数的完整 URL。
///
/// ---------------------------------------------------------------------------
/// **启动上下文参数(PRD §7.3)由 App Backend 拼进 URL,客户端不参与拼接。**
/// 客户端拼参数意味着 userId / storeId / roleCode 这些权限相关字段可以被本地
/// 篡改;后端拼接时这些值都从服务端会话上下文取,客户端只能说"我要开
/// QUOTE_ORDER"。
///
/// 客户端唯一负责传的是 traceId(请求头 `X-Trace-Id`),后端把它带进 H5 URL
/// 这样"用户在 H5 里遇到问题"能一路追到 App 侧的请求。
/// ---------------------------------------------------------------------------
final String url;
/// 导航栏标题。
final String title;
/// 票据有效期,用于判断是否需要换票。
final Duration ttl;
}
/// 换票端口。
///
/// ---------------------------------------------------------------------------
/// 实现**不在本包**:换票要发 HTTP,而 `core_webview → core_network` 不是 01
/// 允许的依赖边。由 `feature_*`(或 app/)实现后 override 进来。
///
/// 同 core_auth/src/session_ports.dart 的依赖反转套路。
/// ---------------------------------------------------------------------------
abstract interface class H5LaunchRepository {
/// 用 [target](白名单枚举,不是 URL)换一份可加载的 [H5LaunchInfo]。
Future<H5LaunchInfo> launch(String target);
}
/// 未 override 时直接报错,比默默打不开页面好。
final Provider<H5LaunchRepository> h5LaunchRepositoryProvider = Provider<H5LaunchRepository>(
(Ref ref) => throw UnimplementedError('h5LaunchRepositoryProvider 必须在 bootstrap 里 override'),
);
@@ -0,0 +1,43 @@
/// 白屏看门狗。来源:conti-docs/10-webview-h5.md §白屏、超时、网络失败兜底。
library;
import 'dart:async';
/// `onPageStarted` 后 15 秒还没 `onPageFinished` 就判超时。
///
/// ---------------------------------------------------------------------------
/// **WebView 在某些网络状况下既不成功也不报错**`onWebResourceError` 不会触发,
/// 用户看到的是一片空白且永远等下去。只有超时能兜住这种情况——这是 H5 容器
/// 体验最差的一类问题。
///
/// 超时后要展示错误态并**上报 `h5_failed` 埋点**(带 target、错误码、耗时、
/// traceId):这一类失败后端完全看不到(换票请求是成功的,加载失败发生在
/// WebView 内部),所以它必须由客户端报。这是 H5 链路健康度最重要的指标。
/// ---------------------------------------------------------------------------
class PageWatchdog {
/// [onTimeout] 里做展示错误态 + 埋点上报。
PageWatchdog({required this.onTimeout, this.timeout = const Duration(seconds: 15)});
/// 超时回调。
final void Function() onTimeout;
/// 超时时长。
final Duration timeout;
Timer? _timer;
/// `onPageStarted` 时调。重复调用会重置计时。
void start() {
_timer?.cancel();
_timer = Timer(timeout, onTimeout);
}
/// `onPageFinished` / 出错时调。
void cancel() {
_timer?.cancel();
_timer = null;
}
/// 是否正在计时。
bool get isRunning => _timer?.isActive ?? false;
}
@@ -0,0 +1,54 @@
/// 域名白名单。来源:conti-docs/10-webview-h5.md §域名白名单。
library;
import 'package:core_foundation/core_foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// H5 URL 的准入判定。
///
/// ---------------------------------------------------------------------------
/// 白名单在**三个位置**都要生效,缺一不可:
///
/// 1. 首次加载前——校验后端返回的 URL(防后端配置错误)。
/// 2. 导航拦截(`NavigationDelegate.onNavigationRequest`)——H5 内部跳到非白名单
/// 域名一律 `prevent`,并记一条埋点。
/// 3. JSBridge 每条消息进来时——校验当前页面的 host。
///
/// 少任何一处,前面的校验都会被绕过。
/// ---------------------------------------------------------------------------
class UrlGuard {
/// [allowedHosts] 来自 `env/{flavor}.json`,各环境不同。
const UrlGuard(this._allowedHosts);
final Set<String> _allowedHosts;
/// 是否允许加载。
bool isAllowed(Uri uri) {
// 只允许 HTTPSPRD §7.6)。dev 也不放开——一旦放开,dev 上写的
// http 地址会跟着代码活到 uat。
if (uri.scheme != 'https') {
return false;
}
final String host = uri.host.toLowerCase();
// ------------------------------------------------------------------
// 用 endsWith('.$allowed') 而不是 contains
// contains('example.com') 会让 f6.example.com.evil.com 通过校验。
// 这是白名单实现里最经典的一个洞,别改成 contains。
// ------------------------------------------------------------------
return _allowedHosts.any((String allowed) => host == allowed || host.endsWith('.$allowed'));
}
/// 字符串版,解析失败按不允许处理。
bool isAllowedUrl(String? url) {
if (url == null) {
return false;
}
final Uri? uri = Uri.tryParse(url);
return uri != null && isAllowed(uri);
}
}
/// 由 [AppEnv.h5AllowedHosts] 驱动。
final Provider<UrlGuard> urlGuardProvider = Provider<UrlGuard>(
(Ref ref) => UrlGuard(ref.watch(appEnvProvider).h5AllowedHosts),
);
@@ -0,0 +1,98 @@
/// H5 会话失效。来源:conti-docs/10-webview-h5.md §门店切换与登出时的会话失效。
library;
import 'package:core_auth/core_auth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// 一个已打开的 H5 页面。
///
/// 抽象出来是为了 [WebViewSession] 的清理顺序能被单测覆盖——真的
/// `WebViewController` 在单测里起不来。
abstract interface class H5Surface {
/// 停掉当前页面(导航到 about:blank),防止在途请求继续。
Future<void> stop();
/// 清 LocalStorage 和 Cache。
Future<void> clearBrowsingData();
}
/// [H5Surface] 在真机上的实现。
class WebViewSurface implements H5Surface {
/// 构造。
const WebViewSurface(this._controller);
final WebViewController _controller;
@override
Future<void> stop() => _controller.loadRequest(Uri.parse('about:blank'));
@override
Future<void> clearBrowsingData() async {
await _controller.clearLocalStorage();
await _controller.clearCache();
}
}
/// 所有打开中的 H5 页面的登记处。
///
/// ---------------------------------------------------------------------------
/// PRD §7.5 的两条硬要求:
/// - **门店切换后,当前 H5 页面必须失效并提示用户重新进入**,但**不清 Cookie**
/// (用户还是同一个人,清了会导致 F6 侧重新走一遍登录)。
/// - **用户退出登录后,所有 H5 会话必须同步失效**,并**清 Cookie / LocalStorage
/// / Cache**。不清的话下一个登录的人可能直接进到上一个人的 F6 会话——
/// 同一台门店共用设备上这是真实会发生的。
///
/// 本类实现 [SessionScopedStore],由 `SessionNotifier` 的级联统一调用
/// (11),不散在各处手动调。清理**必须 await 完成**再让新用户登录,
/// 不能 fire-and-forget。
/// ---------------------------------------------------------------------------
class WebViewSession implements SessionScopedStore {
/// [clearCookies] 只给测试替换;生产用默认的 [WebViewCookieManager]。
WebViewSession({Future<void> Function()? clearCookies})
: _clearCookies = clearCookies ?? _defaultClearCookies;
static Future<void> _defaultClearCookies() => WebViewCookieManager().clearCookies();
final Future<void> Function() _clearCookies;
final List<H5Surface> _open = <H5Surface>[];
/// 打开 H5 页时登记。
void register(H5Surface surface) => _open.add(surface);
/// 关闭 H5 页时注销。
void unregister(H5Surface surface) => _open.remove(surface);
/// 当前打开中的页面数。给测试和诊断用。
int get openCount => _open.length;
@override
String get debugName => 'WebViewSession';
@override
Future<void> onStoreChanged() => invalidateAll(clearCookies: false);
@override
Future<void> onSessionEnded() => invalidateAll(clearCookies: true);
/// 失效所有 H5 会话。
Future<void> invalidateAll({required bool clearCookies}) async {
// 先停掉页面,再清数据——反过来的话在途请求可能把刚清掉的东西又写回去。
for (final H5Surface surface in _open) {
await surface.stop();
}
if (clearCookies) {
await _clearCookies();
for (final H5Surface surface in _open) {
await surface.clearBrowsingData();
}
}
_open.clear();
}
}
/// 全 App 唯一的会话登记处。
final Provider<WebViewSession> webViewSessionProvider = Provider<WebViewSession>(
(Ref ref) => WebViewSession(),
);
+24
View File
@@ -0,0 +1,24 @@
name: core_webview
description: H5 容器。域名白名单、JSBridge、WebView 会话管理。
publish_to: none
version: 0.1.0
resolution: workspace
# core_webview → core_auth 是 01 允许的三条 core 间依赖之一(bridge 要读会话上下文)。
# 「用 target 换真实 URL」的接口调用**不在本包**——那需要 core_network,不是允许的边。
# 换票由调用方(feature)完成后把 URL 传进来,本包只负责校验和承载。
environment:
sdk: ^3.12.0
dependencies:
core_auth: ^0.1.0
core_foundation: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
webview_flutter: ^4.14.1
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
@@ -0,0 +1,163 @@
// core_webview 的高价值断言——这几条全是安全相关,回归了不会有人察觉:
// 1. 白名单不能被 f6.example.com.evil.com 绕过。
// 2. 非白名单页面调 bridge 必须静默丢弃,连错误都不回(不给探测者反馈)。
// 3. 畸形 JSON 不能让 App 崩。
// 4. 未知 method 必须明确回 UNSUPPORTED_METHOD,不能静默(否则 H5 侧 Promise 永远 pending)。
// 5. 登出清 Cookie,切店不清。
import 'dart:convert';
import 'package:core_foundation/core_foundation.dart';
import 'package:core_webview/core_webview.dart';
import 'package:flutter_test/flutter_test.dart';
class _FakeSurface implements H5Surface {
bool stopped = false;
bool cleared = false;
@override
Future<void> stop() async => stopped = true;
@override
Future<void> clearBrowsingData() async => cleared = true;
}
void main() {
group('UrlGuard', () {
const UrlGuard guard = UrlGuard(<String>{'example.com'});
test('放行白名单域名及其子域', () {
expect(guard.isAllowed(Uri.parse('https://example.com/a')), isTrue);
expect(guard.isAllowed(Uri.parse('https://f6.example.com/a')), isTrue);
expect(guard.isAllowed(Uri.parse('https://F6.EXAMPLE.COM/a')), isTrue);
});
test('挡住后缀伪装——这是白名单实现最经典的一个洞', () {
// 用 contains 实现的话这一条会通过。
expect(guard.isAllowed(Uri.parse('https://f6.example.com.evil.com/a')), isFalse);
expect(guard.isAllowed(Uri.parse('https://notexample.com/a')), isFalse);
});
test('只允许 HTTPSdev 也不放开', () {
expect(guard.isAllowed(Uri.parse('http://example.com/a')), isFalse);
expect(guard.isAllowedUrl('about:blank'), isFalse);
expect(guard.isAllowedUrl(null), isFalse);
});
});
group('BridgeDispatcher', () {
late List<String> evaluated;
late String currentUrl;
BridgeDispatcher build(Map<String, BridgeHandler> handlers) => BridgeDispatcher(
urlGuard: const UrlGuard(<String>{'example.com'}),
handlers: handlers,
currentUrl: () async => currentUrl,
evaluateJavaScript: (String js) async => evaluated.add(js),
log: (String message, {Object? error}) {},
);
setUp(() {
evaluated = <String>[];
currentUrl = 'https://f6.example.com/quote';
});
test('非白名单页面的调用静默丢弃,不回包', () async {
currentUrl = 'https://evil.com/x';
await build(<String, BridgeHandler>{
'scan': (Map<String, dynamic> _) async => 'never',
}).handle('{"id":"1","method":"scan"}');
expect(evaluated, isEmpty, reason: '回任何东西都是在给探测者反馈');
});
test('畸形 JSON 不崩也不回包', () async {
await build(const <String, BridgeHandler>{}).handle('{not json');
expect(evaluated, isEmpty);
});
test('未知 method 明确回 UNSUPPORTED_METHOD,不静默', () async {
await build(const <String, BridgeHandler>{}).handle('{"id":"1","method":"teleport"}');
expect(evaluated, hasLength(1));
final Map<String, dynamic> resp = _decodeReply(evaluated.single);
expect(resp['ok'], isFalse);
expect((resp['error']! as Map<String, dynamic>)['code'], 'UNSUPPORTED_METHOD');
});
test('成功调用原样回传 H5 生成的 id', () async {
await build(<String, BridgeHandler>{
'getStoreContext': (Map<String, dynamic> _) async => <String, dynamic>{'storeId': 7},
}).handle('{"id":"c8f1","method":"getStoreContext"}');
final Map<String, dynamic> resp = _decodeReply(evaluated.single);
expect(resp['id'], 'c8f1');
expect(resp['ok'], isTrue);
expect(resp['data'], <String, dynamic>{'storeId': 7});
});
test('AppException 转成稳定的 bridgeCode,不透传原始错误', () async {
await build(<String, BridgeHandler>{
'scan': (Map<String, dynamic> _) async =>
throw const NativeException(NativeErrorCode.permissionDenied, '未授予相机权限'),
}).handle('{"id":"1","method":"scan"}');
final Map<String, dynamic> error =
_decodeReply(evaluated.single)['error']! as Map<String, dynamic>;
expect(error['code'], 'PERMISSION_DENIED');
expect(error['message'], '未授予相机权限');
});
test('非 AppException 归一化成 INTERNAL_ERROR,不泄露技术细节', () async {
await build(<String, BridgeHandler>{
'scan': (Map<String, dynamic> _) async => throw StateError('null check on FooBar'),
}).handle('{"id":"1","method":"scan"}');
final Map<String, dynamic> error =
_decodeReply(evaluated.single)['error']! as Map<String, dynamic>;
expect(error['code'], 'INTERNAL_ERROR');
expect(error['message'], isNot(contains('FooBar')));
});
});
group('WebViewSession', () {
test('切店:停页面但不清 Cookie——用户还是同一个人', () async {
bool cookiesCleared = false;
final WebViewSession session = WebViewSession(
clearCookies: () async => cookiesCleared = true,
);
final _FakeSurface surface = _FakeSurface();
session.register(surface);
await session.onStoreChanged();
expect(surface.stopped, isTrue);
expect(cookiesCleared, isFalse);
expect(surface.cleared, isFalse);
expect(session.openCount, 0);
});
test('登出:必须清 Cookie——门店共用设备上会串号', () async {
bool cookiesCleared = false;
final WebViewSession session = WebViewSession(
clearCookies: () async => cookiesCleared = true,
);
final _FakeSurface surface = _FakeSurface();
session.register(surface);
await session.onSessionEnded();
expect(surface.stopped, isTrue);
expect(cookiesCleared, isTrue);
expect(surface.cleared, isTrue);
expect(session.openCount, 0);
});
});
}
/// 从 `window.__contiBridgeCallback({...});` 里把 JSON 抠出来。
Map<String, dynamic> _decodeReply(String js) {
final int start = js.indexOf('({') + 1;
final int end = js.lastIndexOf('})') + 1;
return jsonDecode(js.substring(start, end)) as Map<String, dynamic>;
}