app scaffold
This commit is contained in:
@@ -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';
|
||||
@@ -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) {
|
||||
// 只允许 HTTPS(PRD §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(),
|
||||
);
|
||||
Reference in New Issue
Block a user