feat: 优化 H5 URL 生成逻辑,移除品牌参数;更新测试用例以反映更改
This commit is contained in:
@@ -21,39 +21,25 @@ class AppConfig {
|
||||
final host = environmentHosts.isNotEmpty
|
||||
? environmentHosts.first
|
||||
: 'h5-im.imharry.work';
|
||||
return withFreshShellParams(
|
||||
_normalizeHomeUrl(host),
|
||||
appName: appName,
|
||||
appLogo: appLogo,
|
||||
);
|
||||
return withFreshShellParams(_normalizeHomeUrl(host));
|
||||
}
|
||||
|
||||
static String withFreshShellParams(
|
||||
String url, {
|
||||
String? appName,
|
||||
String? appLogo,
|
||||
}) {
|
||||
static String withFreshShellParams(String url) {
|
||||
final uri = Uri.parse(url);
|
||||
final queryParameters = Map<String, String>.from(uri.queryParameters)
|
||||
..['flutter_shell'] = '1'
|
||||
..['shell_cache_bust'] = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
queryParameters.remove('shell_app_name');
|
||||
queryParameters.remove('shell_app_logo');
|
||||
final fragmentParameters = Uri.splitQueryString(uri.fragment);
|
||||
|
||||
final trimmedName = (appName ?? AppConfig.appName).trim();
|
||||
if (trimmedName.isNotEmpty) {
|
||||
queryParameters['shell_app_name'] = trimmedName;
|
||||
}
|
||||
|
||||
final trimmedLogo = (appLogo ?? AppConfig.appLogo).trim();
|
||||
if (trimmedLogo.isNotEmpty) {
|
||||
fragmentParameters['shell_app_logo'] = trimmedLogo;
|
||||
}
|
||||
fragmentParameters.remove('shell_app_name');
|
||||
fragmentParameters.remove('shell_app_logo');
|
||||
|
||||
return uri
|
||||
.replace(
|
||||
queryParameters: queryParameters,
|
||||
fragment: fragmentParameters.isEmpty
|
||||
? null
|
||||
? ''
|
||||
: Uri(queryParameters: fragmentParameters).query,
|
||||
)
|
||||
.toString();
|
||||
|
||||
381
lib/main.dart
381
lib/main.dart
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
@@ -16,8 +15,6 @@ const _shellBackground = Color(0xFFF8FBFF);
|
||||
const _shellAccent = Color(0xFF0089FF);
|
||||
const _shellSubText = Color(0xFF8E9AB0);
|
||||
const _resumeCoverDuration = Duration(milliseconds: 700);
|
||||
const _urlOnlyShellMode = true;
|
||||
const _urlOnlyWebViewUrl = 'https://h5-im.imharry.work/';
|
||||
const _noCacheHeaders = {
|
||||
'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
@@ -27,87 +24,6 @@ const _shellBrandingChannel =
|
||||
MethodChannel('io.openim.flutter.im_webview_app/shell_branding');
|
||||
const _shellWebViewCacheChannel =
|
||||
MethodChannel('io.openim.flutter.im_webview_app/webview_cache');
|
||||
const _runtimeCachePurgeFallbackDuration = Duration(seconds: 2);
|
||||
const _purgeWebRuntimeCacheScript = r'''
|
||||
(() => {
|
||||
if (window.__openimShellRuntimeCachePurgeStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__openimShellRuntimeCachePurgeStarted = true;
|
||||
|
||||
const targetUrl = () => {
|
||||
try {
|
||||
const target = new URL(window.location.href);
|
||||
target.searchParams.set('shell_cache_bust', Date.now().toString());
|
||||
target.searchParams.set('shell_runtime_cache_purged', '1');
|
||||
return target.toString();
|
||||
} catch (_) {
|
||||
return window.location.href;
|
||||
}
|
||||
};
|
||||
|
||||
const notifyFlutter = (payload) => {
|
||||
const channel = window.OpenIMFlutterShell;
|
||||
if (channel && typeof channel.postMessage === 'function') {
|
||||
channel.postMessage(JSON.stringify(payload));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const purgeRuntimeCache = async () => {
|
||||
const result = {
|
||||
cacheStorageKeys: 0,
|
||||
serviceWorkerRegistrations: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
if ('caches' in window && caches.keys) {
|
||||
const keys = await caches.keys();
|
||||
result.cacheStorageKeys = keys.length;
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
if (
|
||||
navigator.serviceWorker &&
|
||||
typeof navigator.serviceWorker.getRegistrations === 'function'
|
||||
) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
result.serviceWorkerRegistrations = registrations.length;
|
||||
await Promise.all(
|
||||
registrations.map((registration) => registration.unregister())
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
purgeRuntimeCache()
|
||||
.then((result) => {
|
||||
const payload = {
|
||||
type: 'runtimeCachePurged',
|
||||
url: targetUrl(),
|
||||
result,
|
||||
};
|
||||
if (!notifyFlutter(payload)) {
|
||||
window.location.replace(payload.url);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const payload = {
|
||||
type: 'runtimeCachePurged',
|
||||
url: targetUrl(),
|
||||
};
|
||||
if (!notifyFlutter(payload)) {
|
||||
window.location.replace(payload.url);
|
||||
}
|
||||
});
|
||||
})();
|
||||
''';
|
||||
const _inspectH5SnapshotScript = r'''
|
||||
(() => {
|
||||
const toAbsoluteUrl = (value) => {
|
||||
@@ -265,10 +181,6 @@ Future<void> main() async {
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
);
|
||||
if (_urlOnlyShellMode) {
|
||||
runApp(const UrlOnlyWebViewApp());
|
||||
return;
|
||||
}
|
||||
final shellBranding = await ShellBranding.load();
|
||||
runApp(ImWebViewApp(shellBranding: shellBranding));
|
||||
}
|
||||
@@ -306,121 +218,6 @@ class ShellBranding {
|
||||
static String _trim(String? value) => value?.trim() ?? '';
|
||||
}
|
||||
|
||||
class UrlOnlyWebViewApp extends StatelessWidget {
|
||||
const UrlOnlyWebViewApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: _urlOnlyWebViewUrl,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(scaffoldBackgroundColor: Colors.white),
|
||||
home: const UrlOnlyWebViewPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UrlOnlyWebViewPage extends StatefulWidget {
|
||||
const UrlOnlyWebViewPage({super.key});
|
||||
|
||||
@override
|
||||
State<UrlOnlyWebViewPage> createState() => _UrlOnlyWebViewPageState();
|
||||
}
|
||||
|
||||
class _UrlOnlyWebViewPageState extends State<UrlOnlyWebViewPage> {
|
||||
late final WebViewController _controller;
|
||||
|
||||
int _progress = 0;
|
||||
String? _loadError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Colors.white)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (progress) {
|
||||
if (mounted) {
|
||||
setState(() => _progress = progress);
|
||||
}
|
||||
},
|
||||
onPageStarted: (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadError = null;
|
||||
_progress = 0;
|
||||
});
|
||||
}
|
||||
},
|
||||
onPageFinished: (url) {
|
||||
debugPrint('[UrlOnlyWebView] finished: $url');
|
||||
if (mounted) {
|
||||
setState(() => _progress = 100);
|
||||
}
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame ?? true) {
|
||||
if (mounted) {
|
||||
setState(() => _loadError = error.description);
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigationRequest: _handleUrlOnlyNavigationRequest,
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse(_urlOnlyWebViewUrl));
|
||||
}
|
||||
|
||||
Future<NavigationDecision> _handleUrlOnlyNavigationRequest(
|
||||
NavigationRequest request,
|
||||
) async {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null) {
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
const webSchemes = {'http', 'https', 'about', 'data'};
|
||||
if (webSchemes.contains(uri.scheme)) {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
try {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (_) {}
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_progress < 100)
|
||||
LinearProgressIndicator(
|
||||
value: _progress == 0 ? null : _progress / 100,
|
||||
minHeight: 2,
|
||||
),
|
||||
if (_loadError != null)
|
||||
_ErrorPanel(
|
||||
message: _loadError!,
|
||||
onRetry: () {
|
||||
setState(() => _loadError = null);
|
||||
_controller.loadRequest(Uri.parse(_urlOnlyWebViewUrl));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImWebViewApp extends StatelessWidget {
|
||||
const ImWebViewApp({
|
||||
super.key,
|
||||
@@ -459,11 +256,7 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
int _progress = 0;
|
||||
String? _loadError;
|
||||
bool _showShellCover = true;
|
||||
bool _runtimeCachePurgeStarted = false;
|
||||
bool _runtimeCachePurgeReloaded = false;
|
||||
String? _h5SnapshotDebugText;
|
||||
Timer? _shellCoverTimer;
|
||||
Timer? _runtimeCachePurgeFallbackTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -476,7 +269,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
@override
|
||||
void dispose() {
|
||||
_shellCoverTimer?.cancel();
|
||||
_runtimeCachePurgeFallbackTimer?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
@@ -596,10 +388,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
if (type == 'openAppSettings') {
|
||||
unawaited(openAppSettings());
|
||||
}
|
||||
if (type == 'runtimeCachePurged' && payload != null) {
|
||||
debugPrint('[H5Shell] runtime cache purged: ${jsonEncode(payload)}');
|
||||
unawaited(_reloadAfterRuntimeCachePurge(payload['url'] as String?));
|
||||
}
|
||||
if (type == 'requestMediaPermissions' && payload != null) {
|
||||
unawaited(_handleMediaPermissionBridgeRequest(payload));
|
||||
}
|
||||
@@ -665,37 +453,11 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
String _freshHomeUrl() {
|
||||
return AppConfig.homeUrl(
|
||||
appName: widget.shellBranding.appName,
|
||||
appLogo: widget.shellBranding.appLogo,
|
||||
);
|
||||
}
|
||||
|
||||
String _freshShellUrl(String? url) {
|
||||
final value = url?.trim();
|
||||
if (value == null || value.isEmpty) {
|
||||
return _freshHomeUrl();
|
||||
}
|
||||
|
||||
try {
|
||||
return AppConfig.withFreshShellParams(
|
||||
value,
|
||||
appName: widget.shellBranding.appName,
|
||||
appLogo: widget.shellBranding.appLogo,
|
||||
);
|
||||
} catch (_) {
|
||||
return _freshHomeUrl();
|
||||
}
|
||||
return AppConfig.homeUrl();
|
||||
}
|
||||
|
||||
Future<void> _handlePageFinished() async {
|
||||
final waitingForRuntimeCacheReload =
|
||||
await _purgeWebRuntimeCacheAndReloadIfNeeded();
|
||||
if (waitingForRuntimeCacheReload) {
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(_syncShellBranding());
|
||||
await _syncShellBranding();
|
||||
unawaited(_injectShellMediaPermissionBridge());
|
||||
unawaited(_logLoadedH5Snapshot());
|
||||
if (mounted) {
|
||||
@@ -706,56 +468,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _purgeWebRuntimeCacheAndReloadIfNeeded() async {
|
||||
if (_runtimeCachePurgeStarted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_runtimeCachePurgeStarted = true;
|
||||
try {
|
||||
await _controller.runJavaScript(_purgeWebRuntimeCacheScript);
|
||||
} catch (_) {
|
||||
_runtimeCachePurgeStarted = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
_runtimeCachePurgeFallbackTimer?.cancel();
|
||||
_runtimeCachePurgeFallbackTimer = Timer(
|
||||
_runtimeCachePurgeFallbackDuration,
|
||||
() {
|
||||
if (!_runtimeCachePurgeReloaded) {
|
||||
unawaited(_reloadAfterRuntimeCachePurge(null));
|
||||
}
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _reloadAfterRuntimeCachePurge(String? url) async {
|
||||
if (_runtimeCachePurgeReloaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
_runtimeCachePurgeReloaded = true;
|
||||
_runtimeCachePurgeFallbackTimer?.cancel();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadError = null;
|
||||
_progress = 0;
|
||||
_showShellCover = true;
|
||||
});
|
||||
}
|
||||
|
||||
String? currentUrl;
|
||||
if (url == null || url.trim().isEmpty) {
|
||||
try {
|
||||
currentUrl = await _controller.currentUrl();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await _loadUrl(_freshShellUrl(url ?? currentUrl));
|
||||
}
|
||||
|
||||
Future<void> _prepareWebViewForFreshLoad() async {
|
||||
await _configureAndroidNoCache();
|
||||
try {
|
||||
@@ -815,61 +527,11 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
debugPrint(
|
||||
'[H5Shell] loaded H5 snapshot: $snapshot',
|
||||
);
|
||||
_updateH5SnapshotDebugText(snapshot);
|
||||
} catch (error) {
|
||||
debugPrint('[H5Shell] loaded H5 snapshot failed: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _updateH5SnapshotDebugText(String snapshot) {
|
||||
if (!kDebugMode || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_h5SnapshotDebugText = _formatH5SnapshotDebugText(snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
String _formatH5SnapshotDebugText(String snapshot) {
|
||||
try {
|
||||
final decoded = jsonDecode(snapshot);
|
||||
if (decoded is! Map) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
final href = (decoded['href'] as String? ?? '').trim();
|
||||
final title = (decoded['title'] as String? ?? '').trim();
|
||||
final bodyText = (decoded['bodyText'] as String? ?? '').trim();
|
||||
final scripts = decoded['scripts'];
|
||||
final links = decoded['links'];
|
||||
final assets = <String>[
|
||||
if (scripts is List)
|
||||
...scripts.whereType<String>().where((value) => value.isNotEmpty),
|
||||
if (links is List)
|
||||
...links.whereType<String>().where((value) => value.isNotEmpty),
|
||||
];
|
||||
final importantAssets = assets
|
||||
.where(
|
||||
(asset) =>
|
||||
asset.contains('/assets/index-') ||
|
||||
asset.contains('LoginBrandHeader') ||
|
||||
asset.contains('useLoginBranding'),
|
||||
)
|
||||
.take(8)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
if (title.isNotEmpty) 'title: $title',
|
||||
if (href.isNotEmpty) 'url: $href',
|
||||
if (importantAssets.isNotEmpty) 'assets:\n$importantAssets',
|
||||
if (bodyText.isNotEmpty) 'text: $bodyText',
|
||||
].join('\n');
|
||||
} catch (_) {
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
String _decodeJavaScriptStringResult(Object? result) {
|
||||
if (result == null) {
|
||||
return '';
|
||||
@@ -1038,15 +700,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
message: _loadError!,
|
||||
onRetry: () => unawaited(_loadHome()),
|
||||
),
|
||||
if (kDebugMode && _h5SnapshotDebugText != null)
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 12,
|
||||
child: IgnorePointer(
|
||||
child: _H5SnapshotDebugPanel(text: _h5SnapshotDebugText!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1055,36 +708,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
class _H5SnapshotDebugPanel extends StatelessWidget {
|
||||
const _H5SnapshotDebugPanel({required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 10,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
height: 1.25,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorPanel extends StatelessWidget {
|
||||
const _ErrorPanel({required this.message, required this.onRetry});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('loads the configured H5 root URL with shell branding', () {
|
||||
test('loads the configured H5 root URL with shell launch params', () {
|
||||
const logo = 'data:image/png;base64,abc+/=';
|
||||
|
||||
final uri = Uri.parse(
|
||||
@@ -22,29 +22,29 @@ void main() {
|
||||
expect(uri.host, 'h5-im.imharry.work');
|
||||
expect(uri.path, '/');
|
||||
expect(uri.queryParameters['flutter_shell'], '1');
|
||||
expect(uri.queryParameters['shell_app_name'], 'Shell Test');
|
||||
expect(uri.queryParameters.containsKey('shell_app_name'), isFalse);
|
||||
expect(uri.queryParameters.containsKey('shell_app_logo'), isFalse);
|
||||
expect(uri.queryParameters['shell_cache_bust'], isNotEmpty);
|
||||
expect(uri.toString(), isNot(contains('%25')));
|
||||
expect(Uri.splitQueryString(uri.fragment)['shell_app_logo'], logo);
|
||||
expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
|
||||
isFalse);
|
||||
});
|
||||
|
||||
test('keeps shell branding when refreshing an H5 route URL', () {
|
||||
const logo = 'data:image/png;base64,routeLogo+/=';
|
||||
|
||||
test('refreshes an H5 route URL without adding branding to the URL', () {
|
||||
final uri = Uri.parse(
|
||||
AppConfig.withFreshShellParams(
|
||||
'https://h5-im.imharry.work/login?from=runtime',
|
||||
appName: 'Shell Route',
|
||||
appLogo: logo,
|
||||
'https://h5-im.imharry.work/login?from=runtime&shell_app_name=Old'
|
||||
'#shell_app_logo=old',
|
||||
),
|
||||
);
|
||||
|
||||
expect(uri.path, '/login');
|
||||
expect(uri.queryParameters['from'], 'runtime');
|
||||
expect(uri.queryParameters['flutter_shell'], '1');
|
||||
expect(uri.queryParameters['shell_app_name'], 'Shell Route');
|
||||
expect(uri.queryParameters.containsKey('shell_app_name'), isFalse);
|
||||
expect(uri.queryParameters.containsKey('shell_app_logo'), isFalse);
|
||||
expect(uri.queryParameters['shell_cache_bust'], isNotEmpty);
|
||||
expect(Uri.splitQueryString(uri.fragment)['shell_app_logo'], logo);
|
||||
expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
|
||||
isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user