From 24d8fc3a2da67dc877ff3b43fb0af4c654c3c8c2 Mon Sep 17 00:00:00 2001 From: TangJo <574312013@qq.com> Date: Wed, 25 Mar 2026 17:55:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat=EF=BC=9A=E6=94=AF=E6=8C=81=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E5=8A=9F=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/aixue/assets/config/bootstrap.json | 3 +- flavors/aixue.yaml | 2 +- packages/web_shell_core/lib/core_app.dart | 1 + .../lib/src/services/upgrade_service.dart | 290 +++++++++++++++++- .../lib/src/testing/test_hooks.dart | 5 + .../test/web_shell_core_test.dart | 280 +++++++++++++---- 6 files changed, 510 insertions(+), 71 deletions(-) diff --git a/apps/aixue/assets/config/bootstrap.json b/apps/aixue/assets/config/bootstrap.json index 4032efd..ac4db1e 100644 --- a/apps/aixue/assets/config/bootstrap.json +++ b/apps/aixue/assets/config/bootstrap.json @@ -3,5 +3,6 @@ "preferredOrientations": [ "portraitUp", "portraitDown" - ] + ], + "upgradeConfigUrl": "https://umsapi.23544.com/api/biz/version/get-latest-config?appId=787154824761413&environment=1" } diff --git a/flavors/aixue.yaml b/flavors/aixue.yaml index ffae98e..3fb89a0 100644 --- a/flavors/aixue.yaml +++ b/flavors/aixue.yaml @@ -3,7 +3,7 @@ application_id: "com.yuanxuan.aixue" app_key: "aixue_prod" default_url: "http://xszy.lzzneng.com/login.html" bootstrap_config_url: "" -upgrade_config_url: "" +upgrade_config_url: "https://umsapi.23544.com/api/biz/version/get-latest-config?appId=787154824761413&environment=1" preferred_orientations: - "portraitUp" - "portraitDown" diff --git a/packages/web_shell_core/lib/core_app.dart b/packages/web_shell_core/lib/core_app.dart index 4b43e9b..d677457 100644 --- a/packages/web_shell_core/lib/core_app.dart +++ b/packages/web_shell_core/lib/core_app.dart @@ -18,6 +18,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; +import 'package:yx_app_upgrade_flutter/app_upgrade_plugin.dart'; import 'package:yx_app_upgrade_flutter/yx_app_upgrade_flutter.dart'; export 'package:yx_app_upgrade_flutter/yx_app_upgrade_flutter.dart' diff --git a/packages/web_shell_core/lib/src/services/upgrade_service.dart b/packages/web_shell_core/lib/src/services/upgrade_service.dart index effd54e..f232801 100644 --- a/packages/web_shell_core/lib/src/services/upgrade_service.dart +++ b/packages/web_shell_core/lib/src/services/upgrade_service.dart @@ -1,7 +1,7 @@ part of '../../core_app.dart'; -/// 远程升级配置模型。 -class ShellUpgradeConfig { +/// 升级明细配置,对应升级接口中的 `upgrade` 字段。 +class ShellUpgradeReleaseConfig { final String? versionName; final int? version; final int? isForce; @@ -9,7 +9,7 @@ class ShellUpgradeConfig { final String? filePath; final int? fileSize; - ShellUpgradeConfig({ + const ShellUpgradeReleaseConfig({ this.versionName, this.version, this.isForce, @@ -18,24 +18,241 @@ class ShellUpgradeConfig { this.fileSize, }); + factory ShellUpgradeReleaseConfig.fromJson(Map json) { + return ShellUpgradeReleaseConfig( + versionName: _readShellUpgradeString(json['versionName']), + version: _readShellUpgradeInt(json['version']), + isForce: _readShellUpgradeInt(json['isForce']), + remark: _readShellUpgradeString(json['remark']), + filePath: _readShellUpgradeString(json['filePath']), + fileSize: _readShellUpgradeInt(json['fileSize']), + ); + } + + static ShellUpgradeReleaseConfig? fromDynamic(dynamic value) { + if (value == null) { + return null; + } + if (value is Map) { + return ShellUpgradeReleaseConfig.fromJson(value); + } + if (value is Map) { + return ShellUpgradeReleaseConfig.fromJson( + Map.from(value), + ); + } + if (value is! String) { + return null; + } + + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + + try { + final decoded = jsonDecode(trimmed); + if (decoded is Map) { + return ShellUpgradeReleaseConfig.fromJson(decoded); + } + if (decoded is Map) { + return ShellUpgradeReleaseConfig.fromJson( + Map.from(decoded), + ); + } + } catch (e) { + debugPrint('解析 WebShell 升级配置 upgrade 字段异常: $e'); + } + return null; + } +} + +/// 远程升级配置模型,对应顶层升级 JSON。 +class ShellUpgradeConfig { + final bool? success; + final String? responseCode; + final String? msg; + final int? id; + final int? upgradeFileId; + final int? installFileId; + final bool? isLatest; + final int? appId; + final String? releaseCode; + final String? releaseTime; + final bool? releaseStatus; + final String? upgradeOssUrl; + final bool? isAllowRollback; + final String? rawConfig; + final String? configDescribe; + final int? describeType; + final int? environment; + final String? createdTime; + final ShellUpgradeReleaseConfig? upgrade; + final ShellBootstrapConfig? config; + final String? installOssUrl; + final String? describe; + + const ShellUpgradeConfig({ + this.success, + this.responseCode, + this.msg, + this.id, + this.upgradeFileId, + this.installFileId, + this.isLatest, + this.appId, + this.releaseCode, + this.releaseTime, + this.releaseStatus, + this.upgradeOssUrl, + this.isAllowRollback, + this.rawConfig, + this.configDescribe, + this.describe, + this.describeType, + this.environment, + this.createdTime, + this.installOssUrl, + this.upgrade, + this.config, + }); + + String? get versionName => upgrade?.versionName; + + int? get version => upgrade?.version; + + int? get isForce => upgrade?.isForce; + + String? get remark => describe ?? upgrade?.remark; + + String? get filePath => + _readShellUpgradeString(installOssUrl ?? upgrade?.filePath); + + int? get fileSize => upgrade?.fileSize; + + bool get hasStructuredPayload => upgrade != null || config != null; + + bool shouldOfferUpgrade({required int localVersion}) { + final remoteVersion = upgrade?.version; + if (remoteVersion == null) { + return false; + } + return remoteVersion > localVersion; + } + factory ShellUpgradeConfig.fromJson(Map json) { + final data = json['data']; + final dataMap = data is Map + ? data + : data is Map + ? Map.from(data) + : const {}; + final rawConfig = _readShellUpgradeString(dataMap['config']); + final parsedConfig = _readShellUpgradeEmbeddedConfig(rawConfig); + final rawBootstrapConfig = parsedConfig?['config']; + final bootstrapConfig = rawBootstrapConfig is Map + ? ShellBootstrapConfig.fromJson(rawBootstrapConfig) + : rawBootstrapConfig is Map + ? ShellBootstrapConfig.fromJson( + Map.from(rawBootstrapConfig), + ) + : null; + return ShellUpgradeConfig( - versionName: json['versionName']?.toString(), - version: json['version'] as int?, - isForce: (json['isForce'] ?? json['isforce']) as int?, - remark: json['remark']?.toString(), - filePath: json['filePath']?.toString(), - fileSize: json['fileSize'] as int?, + success: _readShellUpgradeBool(json['success']), + responseCode: _readShellUpgradeString(json['code']), + msg: _readShellUpgradeString(json['msg']), + id: _readShellUpgradeInt(dataMap['id']), + upgradeFileId: _readShellUpgradeInt(dataMap['upgradeFileId']), + installFileId: _readShellUpgradeInt(dataMap['installFileId']), + isLatest: _readShellUpgradeBool(dataMap['isLatest']), + appId: _readShellUpgradeInt(dataMap['appId']), + releaseCode: _readShellUpgradeString(dataMap['code']), + releaseTime: _readShellUpgradeString(dataMap['releaseTime']), + releaseStatus: _readShellUpgradeBool(dataMap['releaseStatus']), + upgradeOssUrl: _readShellUpgradeString(dataMap['upgradeOssUrl']), + installOssUrl: _readShellUpgradeString(dataMap['installOssUrl']), + isAllowRollback: _readShellUpgradeBool(dataMap['isAllowRollback']), + rawConfig: rawConfig, + upgrade: ShellUpgradeReleaseConfig.fromDynamic(parsedConfig?['upgrade']), + config: bootstrapConfig, + configDescribe: _readShellUpgradeString(dataMap['configDescribe']), + describe: _readShellUpgradeString(dataMap['describe']), + describeType: _readShellUpgradeInt(dataMap['describeType']), + environment: _readShellUpgradeInt(dataMap['environment']), + createdTime: _readShellUpgradeString(dataMap['createdTime']), ); } } -/// 管理壳应用的升级逻辑,依赖 `yx_app_upgrade_flutter`。 +String? _readShellUpgradeString(dynamic value) { + final normalized = value?.toString().trim(); + if (normalized == null || normalized.isEmpty) { + return null; + } + return normalized; +} + +int? _readShellUpgradeInt(dynamic value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + final normalized = value?.toString().trim(); + if (normalized == null || normalized.isEmpty) { + return null; + } + return int.tryParse(normalized); +} + +bool? _readShellUpgradeBool(dynamic value) { + if (value is bool) { + return value; + } + if (value is num) { + return value != 0; + } + final normalized = value?.toString().trim().toLowerCase(); + if (normalized == null || normalized.isEmpty) { + return null; + } + if (normalized == 'true' || normalized == '1') { + return true; + } + if (normalized == 'false' || normalized == '0') { + return false; + } + return null; +} + +Map? _readShellUpgradeEmbeddedConfig(String? value) { + if (value == null || value.isEmpty) { + return null; + } + + try { + final decoded = jsonDecode(value); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return Map.from(decoded); + } + } catch (e) { + debugPrint('解析 WebShell 升级配置 data.config 异常: $e'); + } + return null; +} + +/// 管理壳应用的升级逻辑,依赖 yx_app_upgrade_flutter。 class ShellUpgradeService { static final ShellUpgradeService instance = ShellUpgradeService._(); ShellUpgradeService._(); String? _configUrl; + Future Function()? _localBuildNumberResolver; /// 注入升级配置地址。 void setupConfigUrl(String? configUrl) { @@ -63,7 +280,8 @@ class ShellUpgradeService { return; } - UpgradeAuxiliaryUtils.instance.initiateVersionCheck( // coverage:ignore-line + UpgradeAuxiliaryUtils.instance.initiateVersionCheck( + // coverage:ignore-line context, showNoUpdateToast: showNoUpdateToast, future: _createVersionResolver(remoteConfig), // coverage:ignore-line @@ -74,10 +292,46 @@ class ShellUpgradeService { ShellUpgradeConfig remoteConfig, ) { return (int upType) async { - return _convertToAppUpgradeVersion(remoteConfig); + final upgradeVersion = _convertToAppUpgradeVersion(remoteConfig); + if (upgradeVersion == null) { + return null; + } + + final localBuildNumber = await _resolveLocalBuildNumber(); + if (localBuildNumber == null) { + debugPrint('获取 WebShell 本地版本号失败,回退到升级插件内部版本比较'); + return upgradeVersion; + } + if (!remoteConfig.shouldOfferUpgrade(localVersion: localBuildNumber)) { + debugPrint( + 'WebShell 远端版本(${remoteConfig.version}) <= 本地版本($localBuildNumber),跳过升级提示', + ); + return null; + } + return upgradeVersion; }; } + Future _resolveLocalBuildNumber() async { + final resolver = _localBuildNumberResolver; + if (resolver != null) { + return resolver(); + } + + try { + final appInfo = await AppUpgradePlugin().getAppInfo(); + return int.tryParse((appInfo['buildNumber'] ?? '').trim()); + } catch (e) { + debugPrint('获取 WebShell 本地版本号异常: $e'); + return null; + } + } + + @visibleForTesting + void debugSetLocalBuildNumberResolver(Future Function()? resolver) { + _localBuildNumberResolver = resolver; + } + Future _fetchConfig(String url) async { try { final uri = Uri.tryParse(url); @@ -102,10 +356,14 @@ class ShellUpgradeService { try { final dynamic jsonMap = jsonDecode(content); if (jsonMap is Map) { - final data = jsonMap['data'] is Map - ? jsonMap['data'] as Map - : jsonMap; - return ShellUpgradeConfig.fromJson(data); + final config = ShellUpgradeConfig.fromJson(jsonMap); + return config.hasStructuredPayload ? config : null; + } + if (jsonMap is Map) { + final config = ShellUpgradeConfig.fromJson( + Map.from(jsonMap), + ); + return config.hasStructuredPayload ? config : null; } } catch (e) { debugPrint('解析 WebShell 升级配置异常: $e'); 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 d5368ef..1067c77 100644 --- a/packages/web_shell_core/lib/src/testing/test_hooks.dart +++ b/packages/web_shell_core/lib/src/testing/test_hooks.dart @@ -76,6 +76,11 @@ class ShellCoreTestHooks { return ShellUpgradeService.instance._createVersionResolver(config)(upType); } + /// 为测试注入本地构建号解析逻辑。 + void setLocalBuildNumberResolver(Future Function()? resolver) { + ShellUpgradeService.instance.debugSetLocalBuildNumberResolver(resolver); + } + /// 为测试设置升级配置地址。 void setupUpgradeConfigUrl(String? url) { ShellUpgradeService.instance.setupConfigUrl(url); 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 269d99a..271e59a 100644 --- a/packages/web_shell_core/test/web_shell_core_test.dart +++ b/packages/web_shell_core/test/web_shell_core_test.dart @@ -47,8 +47,9 @@ Future _startJsonServer( ); request.response.write(body); } catch (error) { - request.response.statusCode = - error is int ? error : HttpStatus.internalServerError; + request.response.statusCode = error is int + ? error + : HttpStatus.internalServerError; } finally { await request.response.close(); } @@ -1254,7 +1255,10 @@ void main() { ); expect(config, isNotNull); - expect(config!.bootstrapConfigUrl, ' https://example.com/bootstrap.json '); + expect( + config!.bootstrapConfigUrl, + ' https://example.com/bootstrap.json ', + ); expect(config.upgradeConfigUrl, ' https://example.com/upgrade.json '); expect( config.preferredOrientations, @@ -1267,15 +1271,19 @@ void main() { test('启动配置解析支持空数组、非法对象与非法 JSON', () { expect( - shellCoreTestHooks.parseBootstrapConfigString( - '{"preferredOrientations":[]}', - )!.preferredOrientations, + shellCoreTestHooks + .parseBootstrapConfigString( + '{"preferredOrientations":[]}', + )! + .preferredOrientations, [], ); expect( - shellCoreTestHooks.parseBootstrapConfigString( - '{"preferredOrientations":"portraitUp"}', - )!.preferredOrientations, + shellCoreTestHooks + .parseBootstrapConfigString( + '{"preferredOrientations":"portraitUp"}', + )! + .preferredOrientations, isNull, ); expect(shellCoreTestHooks.parseBootstrapConfigString('['), isNull); @@ -1319,9 +1327,12 @@ void main() { await unavailableSocket.close(); final successUri = await _startJsonServer( - (_) async => '{"data":{"initialUrl":"https://remote.example.com","upgradeConfigUrl":"https://remote.example.com/upgrade.json"}}', + (_) async => + '{"data":{"initialUrl":"https://remote.example.com","upgradeConfigUrl":"https://remote.example.com/upgrade.json"}}', + ); + final success = await _runWithRealHttpClient( + () => shellCoreTestHooks.fetchBootstrapConfig(successUri.toString()), ); - final success = await _runWithRealHttpClient(() => shellCoreTestHooks.fetchBootstrapConfig(successUri.toString())); expect(success?.initialUrl, 'https://remote.example.com'); expect( success?.upgradeConfigUrl, @@ -1341,7 +1352,9 @@ void main() { expect(invalidUri?.initialUrl, 'https://remote.example.com'); final notFoundUri = await _startJsonServer((_) async => throw 404); - final notFound = await _runWithRealHttpClient(() => shellCoreTestHooks.fetchBootstrapConfig(notFoundUri.toString())); + final notFound = await _runWithRealHttpClient( + () => shellCoreTestHooks.fetchBootstrapConfig(notFoundUri.toString()), + ); expect(notFound?.initialUrl, 'https://remote.example.com'); final failed = await _runWithRealHttpClient( @@ -1355,7 +1368,9 @@ void main() { test('读取启动配置缓存异常时返回 null', () async { final originalStore = SharedPreferencesStorePlatform.instance; SharedPreferencesStorePlatform.instance = _ThrowingPreferencesStore(); - addTearDown(() => SharedPreferencesStorePlatform.instance = originalStore); + addTearDown( + () => SharedPreferencesStorePlatform.instance = originalStore, + ); expect(await shellCoreTestHooks.loadCachedBootstrapConfig(), isNull); }); @@ -1943,41 +1958,118 @@ void main() { await tester.pumpAndSettle(); }); - test('升级配置解析支持 data、平铺、isforce 和非法 JSON', () { - final wrapped = shellCoreTestHooks.parseUpgradeConfigString( - '{"data":{"versionName":"1.0.1","version":101,"isforce":1,"remark":"fix","filePath":"https://example.com/app.apk","fileSize":2048}}', - ); - final flat = shellCoreTestHooks.parseUpgradeConfigString( - '{"versionName":"1.0.2","version":102}', + test('升级配置解析仅支持新版接口响应', () { + final wrappedContent = jsonEncode({ + 'success': true, + 'code': null, + 'msg': null, + 'data': { + 'id': 787171620319301, + 'upgradeFileId': 0, + 'installFileId': 787171620307013, + 'isLatest': true, + 'appId': 787154824761413, + 'code': '1.0.1', + 'releaseTime': '2026-03-24 09:51:23', + 'releaseStatus': true, + 'upgradeOssUrl': '', + 'installOssUrl': + 'https://umsapi.23544.com/api/biz/Action/download-app?appId=787154824761413&environment=DevelopmentEnvironment&fileType=install&versionCode=1.0.1', + 'isAllowRollback': true, + 'config': jsonEncode({ + 'upgrade': { + 'versionName': '1.0.0', + 'version': 100, + 'isForce': 0, + 'remark': '1. 修复已知问题\n2. 测试升级弹窗功能是否正常加载', + 'filePath': + 'https://gitee.com/mr_koi/static_host/raw/master/app-release.apk', + 'fileSize': 30000, + }, + 'config': { + 'initialUrl': 'http://xszy.lzzneng.com/login.html', + 'preferredOrientations': ['portraitUp', 'portraitDown'], + }, + }), + 'configDescribe': '', + 'describe': '首次更新', + 'describeType': 1, + 'environment': 1, + 'createdTime': '2026-03-24 09:49:32.581', + }, + }); + final structured = shellCoreTestHooks.parseUpgradeConfigString( + wrappedContent, ); - expect(wrapped, isNotNull); - expect(wrapped!.versionName, '1.0.1'); - expect(wrapped.isForce, 1); - expect(flat?.version, 102); - expect(shellCoreTestHooks.parseUpgradeConfigString('{'), isNull); - }); - - test('升级配置转换为 AppUpgradeVersion 时处理缺省值', () { + expect(structured, isNotNull); + expect(structured!.success, isTrue); + expect(structured.id, 787171620319301); + expect(structured.installFileId, 787171620307013); + expect(structured.isLatest, isTrue); + expect(structured.appId, 787154824761413); + expect(structured.releaseCode, '1.0.1'); + expect(structured.releaseStatus, isTrue); + expect(structured.isAllowRollback, isTrue); + expect(structured.describe, '首次更新'); + expect(structured.describeType, 1); + expect(structured.environment, 1); + expect(structured.createdTime, '2026-03-24 09:49:32.581'); + expect(structured.upgrade, isNotNull); + expect(structured.versionName, '1.0.0'); + expect(structured.version, 100); + expect(structured.isForce, 0); + expect(structured.remark, contains('修复已知问题')); expect( - shellCoreTestHooks.convertUpgradeConfig( - ShellUpgradeConfig(versionName: '1.0.0'), + structured.filePath, + 'https://umsapi.23544.com/api/biz/Action/download-app?appId=787154824761413&environment=DevelopmentEnvironment&fileType=install&versionCode=1.0.1', + ); + expect(structured.fileSize, 30000); + expect(structured.config, isNotNull); + expect( + structured.config?.initialUrl, + 'http://xszy.lzzneng.com/login.html', + ); + expect( + structured.config?.preferredOrientations, + [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ], + ); + + expect( + shellCoreTestHooks.parseUpgradeConfigString( + '{"versionName":"1.0.2","version":102}', ), isNull, ); + expect(shellCoreTestHooks.parseUpgradeConfigString('{'), isNull); + }); + + test('升级配置转换为 AppUpgradeVersion 时仅支持顶层 upgrade', () { expect( - shellCoreTestHooks.convertUpgradeConfig(ShellUpgradeConfig(version: 1)), + shellCoreTestHooks.convertUpgradeConfig(const ShellUpgradeConfig()), + isNull, + ); + expect( + shellCoreTestHooks.convertUpgradeConfig( + const ShellUpgradeConfig( + upgrade: ShellUpgradeReleaseConfig(version: 1), + ), + ), isNull, ); final converted = shellCoreTestHooks.convertUpgradeConfig( - ShellUpgradeConfig( - versionName: '1.2.3', - version: 123, - isForce: 1, - remark: 'important update', - filePath: '', - fileSize: 2048, + const ShellUpgradeConfig( + upgrade: ShellUpgradeReleaseConfig( + versionName: '1.2.3', + version: 123, + isForce: 1, + remark: 'important update', + fileSize: 2048, + ), ), ); @@ -1992,29 +2084,74 @@ void main() { expect(converted.supportedMethods, hasLength(3)); final convertedWithPath = shellCoreTestHooks.convertUpgradeConfig( - ShellUpgradeConfig( - versionName: '2.0.0', - version: 200, - filePath: 'https://example.com/app.apk', + const ShellUpgradeConfig( + upgrade: ShellUpgradeReleaseConfig( + versionName: '2.0.0', + version: 200, + filePath: 'https://example.com/app.apk', + ), ), ); expect(convertedWithPath?.downloadUrl, 'https://example.com/app.apk'); expect(convertedWithPath?.appStoreUrl, 'https://example.com/app.apk'); + + final convertedFromStructuredConfig = shellCoreTestHooks + .convertUpgradeConfig( + const ShellUpgradeConfig( + upgrade: ShellUpgradeReleaseConfig( + versionName: '3.0.0', + version: 300, + isForce: 0, + remark: '1. 修复已知问题\n2. 测试升级弹窗功能是否正常加载', + filePath: 'https://download.example.com/app.apk', + fileSize: 25000, + ), + ), + ); + expect(convertedFromStructuredConfig, isNotNull); + expect(convertedFromStructuredConfig!.versionName, '3.0.0'); + expect(convertedFromStructuredConfig.versionBuildNumber, 300); + expect(convertedFromStructuredConfig.isForce, isFalse); + expect(convertedFromStructuredConfig.updateContent, contains('修复已知问题')); + expect( + convertedFromStructuredConfig.downloadUrl, + 'https://download.example.com/app.apk', + ); + expect( + convertedFromStructuredConfig.appStoreUrl, + 'https://download.example.com/app.apk', + ); + expect(convertedFromStructuredConfig.apkSize, 25000 * 1024); }); test('获取升级配置支持成功、非法地址、非 200 和异常', () async { final successUri = await _startJsonServer( - (_) async => '{"versionName":"2.0.0","version":200}', + (_) async => jsonEncode({ + 'success': true, + 'data': { + 'installOssUrl': 'https://example.com/app.apk', + 'config': jsonEncode({ + 'upgrade': { + 'versionName': '2.0.0', + 'version': 200, + }, + }), + }, + }), ); - final success = await _runWithRealHttpClient(() => shellCoreTestHooks.fetchUpgradeConfig(successUri.toString())); + final success = await _runWithRealHttpClient( + () => shellCoreTestHooks.fetchUpgradeConfig(successUri.toString()), + ); expect(success?.versionName, '2.0.0'); expect(await shellCoreTestHooks.fetchUpgradeConfig('%%%'), isNull); final notFoundUri = await _startJsonServer((_) async => throw 404); expect( - await _runWithRealHttpClient(() => shellCoreTestHooks.fetchUpgradeConfig(notFoundUri.toString())), + await _runWithRealHttpClient( + () => shellCoreTestHooks.fetchUpgradeConfig(notFoundUri.toString()), + ), isNull, ); @@ -2037,7 +2174,8 @@ void main() { test('applyBootstrapConfig 会用远程启动配置覆盖本地配置', () async { final bootstrapUri = await _startJsonServer( - (_) async => '{"data":{"initialUrl":"https://remote.example.com/home","preferredOrientations":["portraitDown"],"upgradeConfigUrl":" https://remote.example.com/upgrade.json "}}', + (_) async => + '{"data":{"initialUrl":"https://remote.example.com/home","preferredOrientations":["portraitDown"],"upgradeConfigUrl":" https://remote.example.com/upgrade.json "}}', ); shellCoreTestHooks.initializeEnvironment( const ShellEnvironment( @@ -2096,8 +2234,7 @@ void main() { assetContents['assets/config/fallback_bootstrap.json'] = jsonEncode( { 'initialUrl': 'https://asset.example.com/start', - 'bootstrapConfigUrl': - 'http://127.0.0.1:$unavailablePort/config.json', + 'bootstrapConfigUrl': 'http://127.0.0.1:$unavailablePort/config.json', 'upgradeConfigUrl': ' https://asset.example.com/upgrade.json ', }, ); @@ -2134,7 +2271,9 @@ void main() { splashImage: MemoryImage(Uint8List.fromList(kTransparentImage)), ), ); - addTearDown(() => shellCoreTestHooks.initializeEnvironment(_testEnvironment)); + addTearDown( + () => shellCoreTestHooks.initializeEnvironment(_testEnvironment), + ); await tester.pumpWidget( const MaterialApp( @@ -2213,18 +2352,53 @@ void main() { expect(find.text('长提示'), findsNothing); }); - test('升级配置异步解析闭包可返回版本信息', () async { + test('升级配置异步解析闭包按本地版本决定是否返回升级信息', () async { + addTearDown(() => shellCoreTestHooks.setLocalBuildNumberResolver(null)); + + shellCoreTestHooks.setLocalBuildNumberResolver(() async => 200); final version = await shellCoreTestHooks.resolveUpgradeConfig( - ShellUpgradeConfig( - versionName: '3.0.0', - version: 300, - filePath: 'https://example.com/app.apk', + const ShellUpgradeConfig( + upgrade: ShellUpgradeReleaseConfig( + versionName: '3.0.0', + version: 300, + filePath: 'https://example.com/app.apk', + ), ), ); expect(version?.versionName, '3.0.0'); expect(version?.versionBuildNumber, 300); expect(version?.downloadUrl, 'https://example.com/app.apk'); + + shellCoreTestHooks.setLocalBuildNumberResolver(() async => 300); + final sameVersion = await shellCoreTestHooks.resolveUpgradeConfig( + const ShellUpgradeConfig( + upgrade: ShellUpgradeReleaseConfig( + versionName: '3.0.0', + version: 300, + filePath: 'https://example.com/app.apk', + ), + ), + ); + expect(sameVersion, isNull); + + shellCoreTestHooks.setLocalBuildNumberResolver(() async => null); + final fallbackVersion = await shellCoreTestHooks.resolveUpgradeConfig( + const ShellUpgradeConfig( + upgrade: ShellUpgradeReleaseConfig( + versionName: '3.0.0', + version: 300, + filePath: 'https://example.com/app.apk', + ), + ), + ); + expect(fallbackVersion?.versionName, '3.0.0'); + expect(fallbackVersion?.versionBuildNumber, 300); + + final hiddenVersion = await shellCoreTestHooks.resolveUpgradeConfig( + const ShellUpgradeConfig(), + ); + expect(hiddenVersion, isNull); }); }); } From c08d5b58eb79cb09b2af6e8c90550dd296ddd9b5 Mon Sep 17 00:00:00 2001 From: TangJo <574312013@qq.com> Date: Thu, 26 Mar 2026 09:54:18 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat=EF=BC=9A=E5=8A=A0=E8=BD=BD=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/web_shell_core/lib/core_app.dart | 68 ++++ .../lib/src/bridge/bridge_actions.dart | 15 + .../lib/src/bridge/bridge_protocol.dart | 5 + .../lib/src/testing/test_hooks.dart | 16 + .../web_shell_core/lib/src/ui/shell_page.dart | 64 +++- .../test/web_shell_core_test.dart | 297 +++++++++++++++++- 6 files changed, 461 insertions(+), 4 deletions(-) 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 {} From 42d12212e4f4e99e9ad5d02c08b7e2a973720f71 Mon Sep 17 00:00:00 2001 From: TangJo <574312013@qq.com> Date: Thu, 26 Mar 2026 10:23:24 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9E=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/src/services/upgrade_service.dart | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/web_shell_core/lib/src/services/upgrade_service.dart b/packages/web_shell_core/lib/src/services/upgrade_service.dart index f232801..5526ab9 100644 --- a/packages/web_shell_core/lib/src/services/upgrade_service.dart +++ b/packages/web_shell_core/lib/src/services/upgrade_service.dart @@ -29,6 +29,7 @@ class ShellUpgradeReleaseConfig { ); } + /// 兼容已经解码好的 Map,或 `data.config.upgrade` 这种 JSON 字符串。 static ShellUpgradeReleaseConfig? fromDynamic(dynamic value) { if (value == null) { return null; @@ -67,7 +68,12 @@ class ShellUpgradeReleaseConfig { } } -/// 远程升级配置模型,对应顶层升级 JSON。 +/// 远程升级配置模型,对应升级接口的顶层 JSON。 +/// +/// 当前仅解析新接口结构: +/// 1. 顶层包含 `success/code/msg/data`; +/// 2. 升级与启动配置都放在 `data.config` 这个 JSON 字符串中; +/// 3. `data.config` 解开后,内部再拆为 `upgrade` 和 `config` 两部分。 class ShellUpgradeConfig { final bool? success; final String? responseCode; @@ -132,6 +138,10 @@ class ShellUpgradeConfig { bool get hasStructuredPayload => upgrade != null || config != null; + /// 仅使用远端 `upgrade.version` 与本地 buildNumber 做整数比较。 + /// + /// 返回 `true` 表示远端版本更高,需要触发升级提示; + /// 如果远端未下发版本号,则直接视为无需升级。 bool shouldOfferUpgrade({required int localVersion}) { final remoteVersion = upgrade?.version; if (remoteVersion == null) { @@ -140,6 +150,9 @@ class ShellUpgradeConfig { return remoteVersion > localVersion; } + /// 解析接口响应,并从 `data.config` 中拆出: + /// - `upgrade`:升级弹窗所需信息 + /// - `config`:启动期运行时配置,如 initialUrl / preferredOrientations factory ShellUpgradeConfig.fromJson(Map json) { final data = json['data']; final dataMap = data is Map @@ -227,6 +240,7 @@ bool? _readShellUpgradeBool(dynamic value) { return null; } +/// 解析 `data.config` 这个嵌套 JSON 字符串。 Map? _readShellUpgradeEmbeddedConfig(String? value) { if (value == null || value.isEmpty) { return null; @@ -255,11 +269,17 @@ class ShellUpgradeService { Future Function()? _localBuildNumberResolver; /// 注入升级配置地址。 + /// + /// 这里只保存升级接口 URL,不会立即发起网络请求。 void setupConfigUrl(String? configUrl) { _configUrl = configUrl?.trim(); } /// 检查应用版本,若配置中有更新则会弹出升级弹窗。 + /// + /// 这里仅负责“拉取升级配置 + 触发升级插件比较”。 + /// `initialUrl`、`preferredOrientations` 等运行时配置刷新, + /// 由 `core_app.dart` 中显式的 reload 方法单独处理。 Future checkVersion( BuildContext context, { bool showNoUpdateToast = false, @@ -292,11 +312,16 @@ class ShellUpgradeService { ShellUpgradeConfig remoteConfig, ) { return (int upType) async { + // 先把远端配置转换成升级插件需要的数据模型; + // 如果关键字段不完整,则直接中止本次升级检查。 final upgradeVersion = _convertToAppUpgradeVersion(remoteConfig); if (upgradeVersion == null) { return null; } + // 先用 WebShell 自己的版本比较逻辑做一次前置过滤, + // 避免远端版本不高时仍然进入插件弹窗流程。 + // 若本地 buildNumber 读取失败,则回退给升级插件做内部比较。 final localBuildNumber = await _resolveLocalBuildNumber(); if (localBuildNumber == null) { debugPrint('获取 WebShell 本地版本号失败,回退到升级插件内部版本比较'); @@ -312,6 +337,7 @@ class ShellUpgradeService { }; } + /// 优先使用测试注入的 resolver;否则回退到升级插件读取本地 buildNumber。 Future _resolveLocalBuildNumber() async { final resolver = _localBuildNumberResolver; if (resolver != null) { @@ -332,6 +358,7 @@ class ShellUpgradeService { _localBuildNumberResolver = resolver; } + /// 从升级接口拉取原始 JSON,并交给统一解析器处理。 Future _fetchConfig(String url) async { try { final uri = Uri.tryParse(url); @@ -352,6 +379,10 @@ class ShellUpgradeService { } } + /// 解析接口返回字符串。 + /// + /// 只有当响应中至少成功解析出 `upgrade` 或 `config` 任一结构时, + /// 才认为这是有效的升级配置。 ShellUpgradeConfig? _parseConfigString(String content) { try { final dynamic jsonMap = jsonDecode(content); @@ -372,6 +403,14 @@ class ShellUpgradeService { } /// 将远程 JSON 配置转换为升级弹窗所需的数据模型。 + /// + /// 这里会把: + /// - `upgrade.versionName` -> `versionName` + /// - `upgrade.version` -> `versionBuildNumber` + /// - `upgrade.isForce` -> `isForce` + /// - `describe/upgrade.remark` -> `updateContent` + /// - `installOssUrl/upgrade.filePath` -> 下载地址 + /// - `upgrade.fileSize`(KB) -> `apkSize`(Byte) AppUpgradeVersion? _convertToAppUpgradeVersion(ShellUpgradeConfig config) { if (config.version == null || config.versionName == null) { return null;