diff --git a/packages/web_shell_core/lib/core_app.dart b/packages/web_shell_core/lib/core_app.dart index d677457..696d710 100644 --- a/packages/web_shell_core/lib/core_app.dart +++ b/packages/web_shell_core/lib/core_app.dart @@ -55,6 +55,7 @@ part 'src/ui/unsupported_platform_page.dart'; // ── 全局环境 ── late ShellEnvironment _env; +final ValueNotifier _runtimeConfigVersion = ValueNotifier(0); Color get _shellAccentColor => _env.accentColor; Color get _shellBackgroundColor => _env.backgroundColor; @@ -84,6 +85,41 @@ Future runShellApp(ShellEnvironment environment) async { runApp(const ShellApp()); } +/// 重新请求升级配置,并将其中的运行时启动配置应用到当前壳环境。 +Future reloadUpgradeRuntimeConfig() async { + final configUrl = ShellUpgradeService.instance._configUrl?.trim(); + if (configUrl == null || configUrl.isEmpty) { + return null; + } + + final remoteConfig = await ShellUpgradeService.instance._fetchConfig( + configUrl, + ); + if (remoteConfig == null) { + return null; + } + + await _applyUpgradeRuntimeConfig(remoteConfig, source: '升级配置'); + return remoteConfig; +} + +/// 先重新拉取升级配置中的运行时启动配置,再执行升级检查。 +Future reloadUpgradeRuntimeConfigAndCheckVersion( + BuildContext context, { + bool showNoUpdateToast = false, +}) async { + final remoteConfig = await reloadUpgradeRuntimeConfig(); + if (remoteConfig == null) { + return null; + } + + await ShellUpgradeService.instance.checkVersion( + context, + showNoUpdateToast: showNoUpdateToast, + ); + return remoteConfig; +} + /// 返回最终确定的 upgradeConfigUrl。 Future _applyBootstrapConfig() async { String? bootstrapConfigUrl; @@ -146,6 +182,38 @@ void _mergeBootstrapConfig( ); } +Future _applyUpgradeRuntimeConfig( + ShellUpgradeConfig remoteConfig, { + required String source, +}) async { + final runtimeConfig = remoteConfig.config; + if (runtimeConfig == null) { + return; + } + + final previousInitialUrl = _env.initialUrl?.trim(); + final previousPreferredOrientations = _env.preferredOrientations; + + _mergeBootstrapConfig(runtimeConfig, source: source); + + final nextUpgradeConfigUrl = runtimeConfig.upgradeConfigUrl?.trim(); + if (nextUpgradeConfigUrl != null && nextUpgradeConfigUrl.isNotEmpty) { + ShellUpgradeService.instance.setupConfigUrl(nextUpgradeConfigUrl); + } + + _initializeUrls(); + await SystemChrome.setPreferredOrientations(_shellPreferredOrientations); + + final hasInitialUrlChanged = previousInitialUrl != _env.initialUrl?.trim(); + final hasOrientationsChanged = !listEquals( + previousPreferredOrientations, + _env.preferredOrientations, + ); + if (hasInitialUrlChanged || hasOrientationsChanged) { + _runtimeConfigVersion.value += 1; + } +} + Future _enterImmersiveMode() async { await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setSystemUIOverlayStyle( diff --git a/packages/web_shell_core/lib/src/bridge/bridge_actions.dart b/packages/web_shell_core/lib/src/bridge/bridge_actions.dart index 97f82c5..df5675e 100644 --- a/packages/web_shell_core/lib/src/bridge/bridge_actions.dart +++ b/packages/web_shell_core/lib/src/bridge/bridge_actions.dart @@ -103,6 +103,21 @@ void _setStatusBarFromBridge(Map payload) { ); } +Map _runtimeConfigSnapshotFromBridge( + ShellUpgradeConfig? remoteConfig, +) { + return { + 'initialUrl': _initialUrl, + 'preferredOrientations': _shellPreferredOrientations + .map((item) => item.name) + .toList(), + 'upgradeConfigUrl': ShellUpgradeService.instance._configUrl, + 'versionName': remoteConfig?.versionName, + 'version': remoteConfig?.version, + 'describe': remoteConfig?.describe, + }; +} + // ── Toast 动效组件 ── class _ToastWidget extends StatefulWidget { diff --git a/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart b/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart index 9ed37ae..d68cbe5 100644 --- a/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart +++ b/packages/web_shell_core/lib/src/bridge/bridge_protocol.dart @@ -91,6 +91,11 @@ String _buildAppShellBridgeScript() { openExternal: (url) => send('openExternal', { url }), requestPermissions: (types = []) => send('requestPermissions', { types }), reloadPage: () => send('reloadPage'), + reloadUpgradeRuntimeConfig: () => send('reloadUpgradeRuntimeConfig'), + reloadUpgradeRuntimeConfigAndCheckVersion: (options = {}) => send( + 'reloadUpgradeRuntimeConfigAndCheckVersion', + options, + ), goBack: () => send('goBack'), closeApp: () => send('closeApp'), getDeviceInfo: () => send('getDeviceInfo'), diff --git a/packages/web_shell_core/lib/src/testing/test_hooks.dart b/packages/web_shell_core/lib/src/testing/test_hooks.dart index 1067c77..1c54888 100644 --- a/packages/web_shell_core/lib/src/testing/test_hooks.dart +++ b/packages/web_shell_core/lib/src/testing/test_hooks.dart @@ -53,6 +53,22 @@ class ShellCoreTestHooks { /// 执行启动配置合并流程。 Future applyBootstrapConfig() => _applyBootstrapConfig(); + /// 重新拉取升级配置中的运行时启动配置并应用。 + Future reloadUpgradeRuntimeConfigForTest() { + return reloadUpgradeRuntimeConfig(); + } + + /// 先重载运行时升级配置,再执行升级检查。 + Future reloadUpgradeRuntimeConfigAndCheckVersionForTest( + BuildContext context, { + bool showNoUpdateToast = false, + }) { + return reloadUpgradeRuntimeConfigAndCheckVersion( + context, + showNoUpdateToast: showNoUpdateToast, + ); + } + /// 解析字符串格式的升级配置。 ShellUpgradeConfig? parseUpgradeConfigString(String content) { return ShellUpgradeService.instance._parseConfigString(content); diff --git a/packages/web_shell_core/lib/src/ui/shell_page.dart b/packages/web_shell_core/lib/src/ui/shell_page.dart index da7593b..08025e9 100644 --- a/packages/web_shell_core/lib/src/ui/shell_page.dart +++ b/packages/web_shell_core/lib/src/ui/shell_page.dart @@ -35,9 +35,13 @@ class _WebShellPageState extends State bool _hasStartedRemoteMainFrame = false; bool _hasMainFrameError = false; bool _hasMeasuredProgress = false; + bool _isApplyingRuntimeConfigReload = false; + bool _hasPendingRuntimeConfigReload = false; Timer? _startupWatchdogTimer; int _progress = 0; int _startupRetryCount = 0; + int _lastRuntimeConfigVersion = _runtimeConfigVersion.value; + String _appliedInitialUrl = _initialUrl; String _currentUrl = _initialUrl; String _errorTitle = '页面加载失败'; String _errorMessage = '请检查网络后重试。'; @@ -48,6 +52,7 @@ class _WebShellPageState extends State super.initState(); debugPrint('WebShell 初始化,初始地址=$_initialUrl'); WidgetsBinding.instance.addObserver(this); + _runtimeConfigVersion.addListener(_handleRuntimeConfigChanged); _androidCompatibilityFuture = _prepareAndroidCompatibility(); _recreateWebView(); @@ -57,8 +62,8 @@ class _WebShellPageState extends State } _hasTriggeredInitialLoad = true; unawaited(_loadInitialPage()); - // 触发版本检测(如果配置了升级地址才会弹窗) - unawaited(ShellUpgradeService.instance.checkVersion(context)); + // 启动时先同步运行时配置,再执行升级检查。 + unawaited(reloadUpgradeRuntimeConfigAndCheckVersion(context)); }); } @@ -444,10 +449,51 @@ class _WebShellPageState extends State @override void dispose() { _cancelStartupWatchdog(); + _runtimeConfigVersion.removeListener(_handleRuntimeConfigChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); } + void _handleRuntimeConfigChanged() { + final nextVersion = _runtimeConfigVersion.value; + if (nextVersion == _lastRuntimeConfigVersion) { + return; + } + _lastRuntimeConfigVersion = nextVersion; + _hasPendingRuntimeConfigReload = true; + unawaited(_applyPendingRuntimeConfigReload()); + } + + Future _applyPendingRuntimeConfigReload() async { + if (_isApplyingRuntimeConfigReload) { + return; + } + + _isApplyingRuntimeConfigReload = true; + try { + while (_hasPendingRuntimeConfigReload && mounted) { + _hasPendingRuntimeConfigReload = false; + + final nextInitialUrl = _initialUrl; + final shouldReloadInitialPage = _appliedInitialUrl != nextInitialUrl; + _appliedInitialUrl = nextInitialUrl; + + if (!shouldReloadInitialPage) { + continue; + } + + debugPrint('WebShell 检测到运行时配置更新,重新加载初始地址: $nextInitialUrl'); + await _ensureCompatibilityPlanApplied(); + if (!mounted) { + return; + } + await _startLoadSequence(rebuildWebView: false, resetRetryCount: true); + } + } finally { + _isApplyingRuntimeConfigReload = false; + } + } + // ── 页面加载 ── Future _loadInitialPage() async { @@ -693,6 +739,20 @@ class _WebShellPageState extends State case 'reloadPage': await _reloadPage(); data = true; + case 'reloadUpgradeRuntimeConfig': + data = _runtimeConfigSnapshotFromBridge( + await reloadUpgradeRuntimeConfig(), + ); + case 'reloadUpgradeRuntimeConfigAndCheckVersion': + data = _runtimeConfigSnapshotFromBridge( + await reloadUpgradeRuntimeConfigAndCheckVersion( + context, + showNoUpdateToast: _boolValue( + payload['showNoUpdateToast'], + defaultValue: false, + ), + ), + ); case 'goBack': data = await _goBackFromBridge(); case 'closeApp': diff --git a/packages/web_shell_core/test/web_shell_core_test.dart b/packages/web_shell_core/test/web_shell_core_test.dart index 271e59a..bfdd8d3 100644 --- a/packages/web_shell_core/test/web_shell_core_test.dart +++ b/packages/web_shell_core/test/web_shell_core_test.dart @@ -22,6 +22,9 @@ const MethodChannel _platformChannel = SystemChannels.platform; const _permissionChannel = MethodChannel( 'flutter.baseflow.com/permissions/methods', ); +const _packageInfoChannel = MethodChannel( + 'dev.fluttercommunity.plus/package_info', +); const _testEnvironment = ShellEnvironment( appName: '测试应用', @@ -121,6 +124,19 @@ void main() { for (final permission in permissions) permission: statusValue, }; }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_packageInfoChannel, (call) async { + if (call.method != 'getAll') { + return null; + } + return { + 'appName': 'web_shell_core_test', + 'packageName': 'com.example.web_shell_core_test', + 'version': '1.0.0', + 'buildNumber': '1', + }; + }); }); tearDown(() { @@ -129,6 +145,8 @@ void main() { .setMockMethodCallHandler(_platformChannel, null); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_permissionChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_packageInfoChannel, null); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMessageHandler('flutter/assets', null); }); @@ -1394,7 +1412,7 @@ void main() { expect(script, contains('window.AppShell')); }); - test('脚本暴露全部 12 个 Action', () { + test('脚本暴露全部 14 个 Action', () { final script = shellCoreTestHooks.buildAppShellBridgeScript(); for (final action in [ @@ -1404,6 +1422,8 @@ void main() { 'openExternal', 'requestPermissions', 'reloadPage', + 'reloadUpgradeRuntimeConfig', + 'reloadUpgradeRuntimeConfigAndCheckVersion', 'goBack', 'closeApp', 'getDeviceInfo', @@ -1618,6 +1638,7 @@ void main() { isNull, ); }); + }); group('Phase 2: 新增 Bridge Action', () { @@ -1958,6 +1979,197 @@ void main() { await tester.pumpAndSettle(); }); + testWidgets('checkVersion 不会隐式覆盖运行时首页配置', (tester) async { + shellCoreTestHooks.initializeEnvironment( + _testEnvironment.copyWith( + initialUrl: 'https://before.example.com/home', + preferredOrientations: [ + DeviceOrientation.landscapeLeft, + ], + ), + ); + addTearDown( + () => shellCoreTestHooks.initializeEnvironment(_testEnvironment), + ); + late BuildContext pageContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + pageContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + await tester.runAsync(() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + try { + server.listen((request) async { + request.response.statusCode = HttpStatus.ok; + request.response.headers.set( + HttpHeaders.contentTypeHeader, + 'application/json; charset=utf-8', + ); + request.response.write( + jsonEncode({ + 'success': true, + 'data': { + 'config': jsonEncode({ + 'upgrade': { + 'versionName': '1.0.0', + 'version': 1, + }, + 'config': { + 'initialUrl': 'https://remote.example.com/runtime', + 'preferredOrientations': ['portraitUp', 'portraitDown'], + }, + }), + }, + }), + ); + await request.response.close(); + }); + + shellCoreTestHooks.setupUpgradeConfigUrl( + 'http://127.0.0.1:${server.port}/config.json', + ); + await _runWithRealHttpClient( + () => shellCoreTestHooks.checkVersion(pageContext), + ); + } finally { + await server.close(force: true); + } + }); + await tester.pumpAndSettle(); + + expect( + shellCoreTestHooks.configuredInitialUrl, + 'https://before.example.com/home', + ); + expect(shellCoreTestHooks.initialUrl, 'https://before.example.com/home'); + expect( + shellCoreTestHooks.configuredPreferredOrientations, + [DeviceOrientation.landscapeLeft], + ); + }); + + testWidgets('reloadUpgradeRuntimeConfigAndCheckVersion 先重载配置再检查升级', ( + tester, + ) async { + shellCoreTestHooks.initializeEnvironment( + _testEnvironment.copyWith( + initialUrl: 'https://before.example.com/home', + ), + ); + addTearDown(() { + shellCoreTestHooks.initializeEnvironment(_testEnvironment); + shellCoreTestHooks.setupUpgradeConfigUrl(null); + }); + + late BuildContext pageContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + pageContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + await tester.runAsync(() async { + final upgradeServer = await HttpServer.bind( + InternetAddress.loopbackIPv4, + 0, + ); + var upgradeCheckCount = 0; + upgradeServer.listen((request) async { + upgradeCheckCount += 1; + request.response.statusCode = HttpStatus.ok; + request.response.headers.set( + HttpHeaders.contentTypeHeader, + 'application/json; charset=utf-8', + ); + request.response.write( + jsonEncode({ + 'success': true, + 'data': { + 'installOssUrl': 'https://example.com/app.apk', + 'config': jsonEncode({ + 'upgrade': { + 'versionName': '1.0.0', + 'version': 1, + }, + }), + }, + }), + ); + await request.response.close(); + }); + + final configServer = await HttpServer.bind( + InternetAddress.loopbackIPv4, + 0, + ); + configServer.listen((request) async { + request.response.statusCode = HttpStatus.ok; + request.response.headers.set( + HttpHeaders.contentTypeHeader, + 'application/json; charset=utf-8', + ); + request.response.write( + jsonEncode({ + 'success': true, + 'data': { + 'config': jsonEncode({ + 'upgrade': { + 'versionName': '1.0.0', + 'version': 1, + }, + 'config': { + 'initialUrl': 'https://remote.example.com/runtime', + 'preferredOrientations': ['portraitUp', 'portraitDown'], + 'upgradeConfigUrl': + 'http://127.0.0.1:${upgradeServer.port}/upgrade.json', + }, + }), + }, + }), + ); + await request.response.close(); + }); + + try { + shellCoreTestHooks.setupUpgradeConfigUrl( + 'http://127.0.0.1:${configServer.port}/config.json', + ); + final remoteConfig = await _runWithRealHttpClient( + () => shellCoreTestHooks + .reloadUpgradeRuntimeConfigAndCheckVersionForTest( + pageContext, + ), + ); + + expect(remoteConfig, isNotNull); + expect( + shellCoreTestHooks.configuredInitialUrl, + 'https://remote.example.com/runtime', + ); + expect( + shellCoreTestHooks.upgradeConfigUrl, + 'http://127.0.0.1:${upgradeServer.port}/upgrade.json', + ); + expect(upgradeCheckCount, 1); + } finally { + await configServer.close(force: true); + await upgradeServer.close(force: true); + } + }); + }); + test('升级配置解析仅支持新版接口响应', () { final wrappedContent = jsonEncode({ 'success': true, @@ -2265,6 +2477,73 @@ void main() { shellCoreTestHooks.initializeEnvironment(_testEnvironment); }); + test('reloadUpgradeRuntimeConfig 会应用升级配置中的运行时启动配置', () async { + shellCoreTestHooks.initializeEnvironment( + _testEnvironment.copyWith( + initialUrl: 'https://before.example.com/home', + preferredOrientations: [ + DeviceOrientation.landscapeLeft, + ], + ), + ); + addTearDown(() { + shellCoreTestHooks.initializeEnvironment(_testEnvironment); + shellCoreTestHooks.setupUpgradeConfigUrl(null); + }); + + final successUri = await _startJsonServer( + (_) async => jsonEncode({ + 'success': true, + 'data': { + 'config': jsonEncode({ + 'upgrade': { + 'versionName': '2.0.0', + 'version': 200, + }, + 'config': { + 'initialUrl': 'https://remote.example.com/runtime', + 'preferredOrientations': ['portraitUp', 'portraitDown'], + 'upgradeConfigUrl': 'https://remote.example.com/upgrade.json', + }, + }), + }, + }), + ); + shellCoreTestHooks.setupUpgradeConfigUrl(successUri.toString()); + + final remoteConfig = await _runWithRealHttpClient( + shellCoreTestHooks.reloadUpgradeRuntimeConfigForTest, + ); + + expect(remoteConfig, isNotNull); + expect( + shellCoreTestHooks.configuredInitialUrl, + 'https://remote.example.com/runtime', + ); + expect( + shellCoreTestHooks.initialUrl, + 'https://remote.example.com/runtime', + ); + expect( + shellCoreTestHooks.configuredPreferredOrientations, + [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ], + ); + expect( + shellCoreTestHooks.preferredOrientations, + [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ], + ); + expect( + shellCoreTestHooks.upgradeConfigUrl, + 'https://remote.example.com/upgrade.json', + ); + }); + testWidgets('LaunchOverlay 配置品牌图片时渲染 Image', (tester) async { shellCoreTestHooks.initializeEnvironment( _testEnvironment.copyWith( @@ -2641,6 +2920,7 @@ class _FakePlatformWebViewController extends PlatformWebViewController { _FakePlatformWebViewController(super.params) : super.implementation(); final javaScriptCalls = []; + final javaScriptChannels = {}; PlatformNavigationDelegate? delegate; bool throwOnRunJavaScript = false; bool canGoBackValue = false; @@ -2648,7 +2928,20 @@ class _FakePlatformWebViewController extends PlatformWebViewController { Uri? lastLoadedUri; @override - Future addJavaScriptChannel(JavaScriptChannelParams params) async {} + Future addJavaScriptChannel(JavaScriptChannelParams params) async { + javaScriptChannels[params.name] = params; + } + + Future dispatchJavaScriptMessage( + String channelName, + String message, + ) async { + final channel = javaScriptChannels[channelName]; + if (channel == null) { + throw StateError('JavaScript channel not found: $channelName'); + } + channel.onMessageReceived(JavaScriptMessage(message: message)); + } @override Future clearCache() async {}