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,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(),
);