From 0d259334ee87ded5f8186b676ac1f061b9c1983b Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 20 Mar 2026 17:23:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E5=8D=87=E7=BA=A7=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=88=86=E7=A6=BB=EF=BC=8C=E4=BF=AE=E5=A4=8D=20WebView=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=B5=AE=E5=B1=82=E7=AB=9E=E6=80=81=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 ShellRemoteConfig 拆分为 ShellBootstrapConfig(启动配置)和 ShellUpgradeConfig(升级配置) - ShellBootstrapConfig 支持 initialUrl 和 preferredOrientations - ShellUpgradeService 改为独立获取升级配置,不再依赖启动配置 - ShellEnvironment 新增 bootstrapConfigAsset / bootstrapConfigUrl / upgradeConfigUrl / copyWith - 修复 WebView 错误浮层被 onPageFinished 重置的竞态条件 - 修复 Offstage 导致 Stack 坍缩为零尺寸的白屏问题,改用 Visibility - 增强 recovery.dart 对原始 net::ERR_* 字符串的友好翻译 - 重新生成 3 个品牌应用(aixue / test / yunxiao)的图标和启动页资源 - aixue 应用改用 bootstrapConfigAsset 加载本地启动配置 - 移除 launch.json 中已废弃的 quanxue 配置 --- .vscode/launch.json | 20 - apps/aixue/assets/config/bootstrap.json | 7 + apps/aixue/lib/main.dart | 2 +- apps/aixue/pubspec.lock | 248 ++++++++++- apps/aixue/pubspec.yaml | 1 + apps/test/android/app/build.gradle.kts | 4 +- .../java/com/yuanxuan/test/MainActivity.java | 6 - apps/test/assets/config/bootstrap.json | 7 + apps/test/lib/main.dart | 2 +- apps/test/pubspec.lock | 248 ++++++++++- apps/test/pubspec.yaml | 1 + apps/yunxiao/assets/config/bootstrap.json | 7 + apps/yunxiao/lib/main.dart | 2 +- apps/yunxiao/pubspec.lock | 248 ++++++++++- apps/yunxiao/pubspec.yaml | 1 + flavors/aixue.yaml | 5 + flavors/test.yaml | 5 + flavors/yunxiao.yaml | 5 + packages/web_shell_core/README.md | 54 ++- packages/web_shell_core/lib/core_app.dart | 86 +++- .../lib/src/config/shell_environment.dart | 62 ++- .../lib/src/engine/recovery.dart | 23 +- .../lib/src/services/config_service.dart | 132 ++++++ .../lib/src/services/upgrade_service.dart | 138 ++++++ .../lib/src/testing/test_hooks.dart | 14 + .../web_shell_core/lib/src/ui/shell_page.dart | 37 +- packages/web_shell_core/pubspec.yaml | 7 + .../test/web_shell_core_test.dart | 195 ++++++++- .../web_shell_core/test_assets/bootstrap.json | 8 + .../web_shell_core/test_assets/config.json | 13 + .../web_shell_core/test_assets/upgrade.json | 12 + tool/generate_app.dart | 400 ++++++++++-------- tool/key.jks | Bin 0 -> 2692 bytes 33 files changed, 1742 insertions(+), 258 deletions(-) create mode 100644 apps/aixue/assets/config/bootstrap.json delete mode 100644 apps/test/android/app/src/main/java/com/yuanxuan/test/MainActivity.java create mode 100644 apps/test/assets/config/bootstrap.json create mode 100644 apps/yunxiao/assets/config/bootstrap.json create mode 100644 packages/web_shell_core/lib/src/services/config_service.dart create mode 100644 packages/web_shell_core/lib/src/services/upgrade_service.dart create mode 100644 packages/web_shell_core/test_assets/bootstrap.json create mode 100644 packages/web_shell_core/test_assets/config.json create mode 100644 packages/web_shell_core/test_assets/upgrade.json create mode 100644 tool/key.jks diff --git a/.vscode/launch.json b/.vscode/launch.json index 93256a8..12b5b15 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -29,26 +29,6 @@ "type": "dart", "flutterMode": "release" }, - { - "name": "quanxue", - "cwd": "apps/quanxue", - "request": "launch", - "type": "dart" - }, - { - "name": "quanxue (profile mode)", - "cwd": "apps/quanxue", - "request": "launch", - "type": "dart", - "flutterMode": "profile" - }, - { - "name": "quanxue (release mode)", - "cwd": "apps/quanxue", - "request": "launch", - "type": "dart", - "flutterMode": "release" - }, { "name": "test", "cwd": "apps/test", diff --git a/apps/aixue/assets/config/bootstrap.json b/apps/aixue/assets/config/bootstrap.json new file mode 100644 index 0000000..52df66c --- /dev/null +++ b/apps/aixue/assets/config/bootstrap.json @@ -0,0 +1,7 @@ +{ + "initialUrl": "http://xszy.lzzneng.com/login.html", + "preferredOrientations": [ + "portraitUp", + "portraitDown" + ] +} \ No newline at end of file diff --git a/apps/aixue/lib/main.dart b/apps/aixue/lib/main.dart index 06962f4..3f1addd 100644 --- a/apps/aixue/lib/main.dart +++ b/apps/aixue/lib/main.dart @@ -11,7 +11,7 @@ void main() { textColor: const Color(0xFF1F2937), mutedTextColor: const Color(0xFF6B7280), splashImage: const AssetImage('assets/branding/splash.png'), - initialUrl: 'http://xszy.lzzneng.com/login.html', + bootstrapConfigAsset: 'assets/config/bootstrap.json', ), ); } diff --git a/apps/aixue/pubspec.lock b/apps/aixue/pubspec.lock index 1fcb372..413a62e 100644 --- a/apps/aixue/pubspec.lock +++ b/apps/aixue/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -73,6 +81,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -105,6 +121,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.3" + dio: + dependency: transitive + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" fake_async: dependency: transitive description: @@ -182,6 +214,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -200,6 +237,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" http: dependency: transitive description: @@ -280,6 +333,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.20.2" leak_tracker: dependency: transitive description: @@ -312,6 +373,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -344,6 +413,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.17.6" nm: dependency: transitive description: @@ -352,6 +429,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.3.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -360,6 +461,54 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" permission_handler: dependency: transitive description: @@ -416,6 +565,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -424,6 +581,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -628,6 +849,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -636,6 +865,23 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" + yx_app_upgrade_flutter: + dependency: transitive + description: + path: "." + ref: "2.0.6" + resolved-ref: e6e7f8e951368b6fdd30c2fd237fd3e00252e568 + url: "https://gitea.23544.com/wangyang/yx_app_upgrade_flutter.git" + source: git + version: "1.0.5" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.38.4" diff --git a/apps/aixue/pubspec.yaml b/apps/aixue/pubspec.yaml index eef422a..e30f890 100644 --- a/apps/aixue/pubspec.yaml +++ b/apps/aixue/pubspec.yaml @@ -55,6 +55,7 @@ dev_dependencies: flutter: assets: - assets/branding/ + - assets/config/ # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/apps/test/android/app/build.gradle.kts b/apps/test/android/app/build.gradle.kts index 2223b05..39e71f1 100644 --- a/apps/test/android/app/build.gradle.kts +++ b/apps/test/android/app/build.gradle.kts @@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) { android { - namespace = "com.yuanxuan.test" + namespace = "com.yuanxuan.test_shell" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -31,7 +31,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.yuanxuan.test" + applicationId = "com.yuanxuan.test_shell" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/apps/test/android/app/src/main/java/com/yuanxuan/test/MainActivity.java b/apps/test/android/app/src/main/java/com/yuanxuan/test/MainActivity.java deleted file mode 100644 index 33aac06..0000000 --- a/apps/test/android/app/src/main/java/com/yuanxuan/test/MainActivity.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.yuanxuan.test; - -import io.flutter.embedding.android.FlutterActivity; - -public class MainActivity extends FlutterActivity { -} diff --git a/apps/test/assets/config/bootstrap.json b/apps/test/assets/config/bootstrap.json new file mode 100644 index 0000000..54be618 --- /dev/null +++ b/apps/test/assets/config/bootstrap.json @@ -0,0 +1,7 @@ +{ + "initialUrl": "http://192.168.2.57:8080/test_bridge.html", + "preferredOrientations": [ + "portraitUp", + "portraitDown" + ] +} diff --git a/apps/test/lib/main.dart b/apps/test/lib/main.dart index 0a9b34c..6c78315 100644 --- a/apps/test/lib/main.dart +++ b/apps/test/lib/main.dart @@ -11,7 +11,7 @@ void main() { textColor: const Color(0xFFFFFFFF), mutedTextColor: const Color(0xFF9CA3AF), splashImage: const AssetImage('assets/branding/splash.png'), - initialUrl: 'http://192.168.2.57:8080/test_bridge.html', + bootstrapConfigAsset: 'assets/config/bootstrap.json', ), ); } diff --git a/apps/test/pubspec.lock b/apps/test/pubspec.lock index 1fcb372..413a62e 100644 --- a/apps/test/pubspec.lock +++ b/apps/test/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -73,6 +81,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -105,6 +121,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.3" + dio: + dependency: transitive + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" fake_async: dependency: transitive description: @@ -182,6 +214,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -200,6 +237,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" http: dependency: transitive description: @@ -280,6 +333,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.20.2" leak_tracker: dependency: transitive description: @@ -312,6 +373,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -344,6 +413,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.17.6" nm: dependency: transitive description: @@ -352,6 +429,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.3.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -360,6 +461,54 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" permission_handler: dependency: transitive description: @@ -416,6 +565,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -424,6 +581,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -628,6 +849,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -636,6 +865,23 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" + yx_app_upgrade_flutter: + dependency: transitive + description: + path: "." + ref: "2.0.6" + resolved-ref: e6e7f8e951368b6fdd30c2fd237fd3e00252e568 + url: "https://gitea.23544.com/wangyang/yx_app_upgrade_flutter.git" + source: git + version: "1.0.5" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.38.4" diff --git a/apps/test/pubspec.yaml b/apps/test/pubspec.yaml index de6b073..aa2e4cf 100644 --- a/apps/test/pubspec.yaml +++ b/apps/test/pubspec.yaml @@ -55,6 +55,7 @@ dev_dependencies: flutter: assets: - assets/branding/ + - assets/config/ # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/apps/yunxiao/assets/config/bootstrap.json b/apps/yunxiao/assets/config/bootstrap.json new file mode 100644 index 0000000..c6bb1c7 --- /dev/null +++ b/apps/yunxiao/assets/config/bootstrap.json @@ -0,0 +1,7 @@ +{ + "initialUrl": "https://h5.jingdaka.com/pages/user/login/login?sourcePage=%252Fpages%252Fuser_sub%252Fuser_homework%252Fhomework_list%252Fhomework_list%253FcourseId%253D1821860%2526type%253Dredirect%2526domain_name%253Dmjunysod", + "preferredOrientations": [ + "portraitUp", + "portraitDown" + ] +} diff --git a/apps/yunxiao/lib/main.dart b/apps/yunxiao/lib/main.dart index 12b0b59..5f92a2d 100644 --- a/apps/yunxiao/lib/main.dart +++ b/apps/yunxiao/lib/main.dart @@ -11,7 +11,7 @@ void main() { textColor: const Color(0xFF1F2937), mutedTextColor: const Color(0xFF6B7280), splashImage: const AssetImage('assets/branding/splash.png'), - initialUrl: 'https://h5.jingdaka.com/pages/user/login/login?sourcePage=%252Fpages%252Fuser_sub%252Fuser_homework%252Fhomework_list%252Fhomework_list%253FcourseId%253D1821860%2526type%253Dredirect%2526domain_name%253Dmjunysod', + bootstrapConfigAsset: 'assets/config/bootstrap.json', ), ); } diff --git a/apps/yunxiao/pubspec.lock b/apps/yunxiao/pubspec.lock index 1fcb372..413a62e 100644 --- a/apps/yunxiao/pubspec.lock +++ b/apps/yunxiao/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -73,6 +81,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -105,6 +121,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.3" + dio: + dependency: transitive + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" fake_async: dependency: transitive description: @@ -182,6 +214,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -200,6 +237,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" http: dependency: transitive description: @@ -280,6 +333,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.20.2" leak_tracker: dependency: transitive description: @@ -312,6 +373,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -344,6 +413,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.17.6" nm: dependency: transitive description: @@ -352,6 +429,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.3.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -360,6 +461,54 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" permission_handler: dependency: transitive description: @@ -416,6 +565,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -424,6 +581,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -628,6 +849,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -636,6 +865,23 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" + yx_app_upgrade_flutter: + dependency: transitive + description: + path: "." + ref: "2.0.6" + resolved-ref: e6e7f8e951368b6fdd30c2fd237fd3e00252e568 + url: "https://gitea.23544.com/wangyang/yx_app_upgrade_flutter.git" + source: git + version: "1.0.5" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.38.4" diff --git a/apps/yunxiao/pubspec.yaml b/apps/yunxiao/pubspec.yaml index 6070a81..d7118a1 100644 --- a/apps/yunxiao/pubspec.yaml +++ b/apps/yunxiao/pubspec.yaml @@ -55,6 +55,7 @@ dev_dependencies: flutter: assets: - assets/branding/ + - assets/config/ # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. diff --git a/flavors/aixue.yaml b/flavors/aixue.yaml index 7806355..dff7a83 100644 --- a/flavors/aixue.yaml +++ b/flavors/aixue.yaml @@ -2,6 +2,11 @@ app_name: "爱学蝶变" application_id: "com.yuanxuan.aixue" app_key: "aixue_prod" default_url: "http://xszy.lzzneng.com/login.html" +bootstrap_config_url: "" +upgrade_config_url: "" +preferred_orientations: + - "portraitUp" + - "portraitDown" theme: accent_color: "0xFFF97316" bg_color: "0xFFFFFFFF" diff --git a/flavors/test.yaml b/flavors/test.yaml index 16543d1..3547077 100644 --- a/flavors/test.yaml +++ b/flavors/test.yaml @@ -2,6 +2,11 @@ app_name: "测试壳工程" application_id: "com.yuanxuan.test_shell" app_key: "test_shell" default_url: "http://192.168.2.57:8080/test_bridge.html" +bootstrap_config_url: "" +upgrade_config_url: "" +preferred_orientations: + - "portraitUp" + - "portraitDown" theme: accent_color: "0xFF10B981" bg_color: "0xFF1F2937" diff --git a/flavors/yunxiao.yaml b/flavors/yunxiao.yaml index 3ec5915..98fe6c3 100644 --- a/flavors/yunxiao.yaml +++ b/flavors/yunxiao.yaml @@ -2,6 +2,11 @@ app_name: "云校嗨学" application_id: "com.yuanxuan.yunxiao" app_key: "yunxiao_prod" default_url: "https://h5.jingdaka.com/pages/user/login/login?sourcePage=%252Fpages%252Fuser_sub%252Fuser_homework%252Fhomework_list%252Fhomework_list%253FcourseId%253D1821860%2526type%253Dredirect%2526domain_name%253Dmjunysod" +bootstrap_config_url: "" +upgrade_config_url: "" +preferred_orientations: + - "portraitUp" + - "portraitDown" theme: accent_color: "0xFF4F46E5" bg_color: "0xFFFFFFFF" diff --git a/packages/web_shell_core/README.md b/packages/web_shell_core/README.md index 9910ce6..950507f 100644 --- a/packages/web_shell_core/README.md +++ b/packages/web_shell_core/README.md @@ -14,10 +14,26 @@ Android 平板专用的 H5 壳核心库。所有品牌应用共享此库,只 | **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 | | **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 | | **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 | +| **启动配置** | 支持本地默认启动配置文件 + 远程启动配置缓存 | +| **版本检查** | 升级配置独立请求,不再与启动配置共用一个 JSON | ## 使用方式 +### 1. 准备本地默认启动配置文件 + +`assets/config/bootstrap.json` + +```json +{ + "initialUrl": "https://example.com/login", + "preferredOrientations": ["portraitUp", "portraitDown"] +} +``` + +### 2. 在应用入口传入环境配置 + ```dart +import 'package:flutter/material.dart'; import 'package:web_shell_core/web_shell_core.dart'; void main() { @@ -29,12 +45,40 @@ void main() { backgroundColor: Color(0xFFFFFFFF), textColor: Color(0xFF1F2937), mutedTextColor: Color(0xFF6B7280), - initialUrl: 'example.com/login', // 可选,不传使用默认地址 + bootstrapConfigAsset: 'assets/config/bootstrap.json', + bootstrapConfigUrl: 'https://example.com/bootstrap.json', + upgradeConfigUrl: 'https://example.com/upgrade.json', ), ); } ``` +### 3. 远程启动配置格式 + +```json +{ + "data": { + "initialUrl": "https://example.com/login", + "preferredOrientations": ["portraitUp", "portraitDown"] + } +} +``` + +### 4. 远程升级配置格式 + +```json +{ + "data": { + "versionName": "1.0.1", + "version": 101, + "isForce": 0, + "remark": "1. 修复已知问题\n2. 优化启动体验", + "filePath": "https://example.com/app-release.apk", + "fileSize": 25000 + } +} +``` + ## 代码结构 ``` @@ -53,9 +97,11 @@ lib/ │ ├── bridge_actions.dart # Action handler(占位) │ └── legacy_camera_compat.dart # 旧相机 JS 兼容层 ├── services/ + │ ├── config_service.dart # 启动配置读取与缓存 │ ├── media_service.dart # 相机/图库/文件 + 序列化 │ ├── permission_service.dart # 权限类型映射 - │ └── navigation_service.dart # URL 路由 + 外链跳转 + │ ├── navigation_service.dart # URL 路由 + 外链跳转 + │ └── upgrade_service.dart # 升级配置请求与弹窗转换 ├── ui/ │ ├── shell_app.dart # MaterialApp 入口 │ ├── shell_page.dart # WebView 主页面 @@ -74,10 +120,10 @@ cd packages/web_shell_core flutter test ``` -当前 **67 个测试用例**,覆盖: +当前测试覆盖: - 平台检测 · URL 解析 · 兼容性策略 · 错误映射 - Bridge 注入/响应/异常处理 · 媒体序列化 · 权限映射 -- 导航路由 · 所有独立 UI 组件 +- 导航路由 · 启动配置解析 · 方向配置 · 组件渲染 ## 平台约束 diff --git a/packages/web_shell_core/lib/core_app.dart b/packages/web_shell_core/lib/core_app.dart index 5fb3f6f..813a931 100644 --- a/packages/web_shell_core/lib/core_app.dart +++ b/packages/web_shell_core/lib/core_app.dart @@ -11,11 +11,17 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; import 'package:permission_handler/permission_handler.dart'; +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/yx_app_upgrade_flutter.dart'; + +export 'package:yx_app_upgrade_flutter/yx_app_upgrade_flutter.dart' + show AppUpgradeVersion; // ── 配置 ── part 'src/config/shell_environment.dart'; @@ -31,18 +37,20 @@ part 'src/bridge/bridge_actions.dart'; part 'src/bridge/legacy_camera_compat.dart'; // ── 服务 ── +part 'src/services/config_service.dart'; part 'src/services/media_service.dart'; -part 'src/services/permission_service.dart'; part 'src/services/navigation_service.dart'; +part 'src/services/permission_service.dart'; +part 'src/services/upgrade_service.dart'; // ── 界面 ── +part 'src/testing/test_hooks.dart'; +part 'src/ui/error_overlay.dart'; +part 'src/ui/launch_overlay.dart'; +part 'src/ui/progress_bar.dart'; part 'src/ui/shell_app.dart'; part 'src/ui/shell_page.dart'; -part 'src/ui/launch_overlay.dart'; -part 'src/ui/error_overlay.dart'; -part 'src/ui/progress_bar.dart'; part 'src/ui/unsupported_platform_page.dart'; -part 'src/testing/test_hooks.dart'; // ── 全局环境 ── late ShellEnvironment _env; @@ -51,23 +59,79 @@ Color get _shellAccentColor => _env.accentColor; Color get _shellBackgroundColor => _env.backgroundColor; Color get _shellTextColor => _env.textColor; Color get _shellMutedTextColor => _env.mutedTextColor; +List get _shellPreferredOrientations => + _env.preferredOrientations ?? + const [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]; // ── 入口 ── /// 启动壳应用的唯一入口。 /// 各品牌应用的 `main.dart` 调用此函数,并传入自己的 [ShellEnvironment]。 Future runShellApp(ShellEnvironment environment) async { - _env = environment; - _initializeUrls(); WidgetsFlutterBinding.ensureInitialized(); - await SystemChrome.setPreferredOrientations(const [ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); + _env = environment; + + await _applyBootstrapConfig(); + ShellUpgradeService.instance.setupConfigUrl(_env.upgradeConfigUrl); + + _initializeUrls(); + await SystemChrome.setPreferredOrientations(_shellPreferredOrientations); await _enterImmersiveMode(); runApp(const ShellApp()); } +Future _applyBootstrapConfig() async { + final bootstrapAsset = _env.bootstrapConfigAsset; + if (bootstrapAsset != null && bootstrapAsset.isNotEmpty) { + debugPrint('WebShell 正在读取本地启动配置: $bootstrapAsset'); + final localConfig = await ShellBootstrapConfigService.loadDefaultConfig( + bootstrapAsset, + ); + if (localConfig != null) { + _mergeBootstrapConfig(localConfig, source: '本地启动配置'); + } + } + + final bootstrapConfigUrl = _env.bootstrapConfigUrl; + if (bootstrapConfigUrl != null && bootstrapConfigUrl.isNotEmpty) { + debugPrint('WebShell 正在获取远程启动配置: $bootstrapConfigUrl'); + final remoteConfig = await ShellBootstrapConfigService.fetchConfig( + bootstrapConfigUrl, + ); + if (remoteConfig != null) { + _mergeBootstrapConfig(remoteConfig, source: '远程启动配置'); + } + } +} + +void _mergeBootstrapConfig( + ShellBootstrapConfig config, { + required String source, +}) { + final initialUrl = config.initialUrl?.trim(); + final preferredOrientations = config.preferredOrientations; + + if (initialUrl != null && initialUrl.isNotEmpty) { + debugPrint('WebShell $source 覆盖了初始地址: $initialUrl'); + } + if (preferredOrientations != null) { + debugPrint( + 'WebShell $source 覆盖了首屏方向: ' + '${preferredOrientations.map((item) => item.name).join(', ')}', + ); + } + + _env = _env.copyWith( + initialUrl: initialUrl != null && initialUrl.isNotEmpty + ? initialUrl + : _env.initialUrl, + preferredOrientations: preferredOrientations ?? _env.preferredOrientations, + ); +} + Future _enterImmersiveMode() async { await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setSystemUIOverlayStyle( diff --git a/packages/web_shell_core/lib/src/config/shell_environment.dart b/packages/web_shell_core/lib/src/config/shell_environment.dart index 3f33596..1aa16af 100644 --- a/packages/web_shell_core/lib/src/config/shell_environment.dart +++ b/packages/web_shell_core/lib/src/config/shell_environment.dart @@ -13,29 +13,77 @@ class ShellEnvironment { required this.mutedTextColor, this.splashImage, this.initialUrl, + this.bootstrapConfigAsset, + this.bootstrapConfigUrl, + this.upgradeConfigUrl, + this.preferredOrientations, }); - /// 应用显示名称(如 "全学通"、"点智学") + /// 应用显示名称(如 "全学通"、"点智学")。 final String appName; - /// 应用唯一标识符(用于后端区分品牌) + /// 应用唯一标识符(用于后端区分品牌)。 final String appKey; - /// 品牌主题强调色 + /// 品牌主题强调色。 final Color accentColor; - /// 品牌背景色 + /// 品牌背景色。 final Color backgroundColor; - /// 品牌正文文字色 + /// 品牌正文文字色。 final Color textColor; - /// 品牌次要文字色 + /// 品牌次要文字色。 final Color mutedTextColor; /// 可选的品牌启动页图标;为空时使用默认图标。 final ImageProvider? splashImage; - /// 可选的初始地址;为空时使用默认地址。 + /// 可选的初始地址;作为本地默认启动配置缺省时的兜底值。 final String? initialUrl; + + /// 可选的本地默认启动配置文件路径。 + final String? bootstrapConfigAsset; + + /// 可选的远程启动配置地址;配置中可指定 `initialUrl` 及方向锁定。 + final String? bootstrapConfigUrl; + + /// 可选的远程升级配置地址。 + final String? upgradeConfigUrl; + + /// 可选的页面首屏方向锁定配置;为空时使用默认竖屏。 + final List? preferredOrientations; + + /// 返回带局部覆盖的新配置对象。 + ShellEnvironment copyWith({ + String? appName, + String? appKey, + Color? accentColor, + Color? backgroundColor, + Color? textColor, + Color? mutedTextColor, + ImageProvider? splashImage, + String? initialUrl, + String? bootstrapConfigAsset, + String? bootstrapConfigUrl, + String? upgradeConfigUrl, + List? preferredOrientations, + }) { + return ShellEnvironment( + appName: appName ?? this.appName, + appKey: appKey ?? this.appKey, + accentColor: accentColor ?? this.accentColor, + backgroundColor: backgroundColor ?? this.backgroundColor, + textColor: textColor ?? this.textColor, + mutedTextColor: mutedTextColor ?? this.mutedTextColor, + splashImage: splashImage ?? this.splashImage, + initialUrl: initialUrl ?? this.initialUrl, + bootstrapConfigAsset: bootstrapConfigAsset ?? this.bootstrapConfigAsset, + bootstrapConfigUrl: bootstrapConfigUrl ?? this.bootstrapConfigUrl, + upgradeConfigUrl: upgradeConfigUrl ?? this.upgradeConfigUrl, + preferredOrientations: + preferredOrientations ?? this.preferredOrientations, + ); + } } diff --git a/packages/web_shell_core/lib/src/engine/recovery.dart b/packages/web_shell_core/lib/src/engine/recovery.dart index e320620..fc4e448 100644 --- a/packages/web_shell_core/lib/src/engine/recovery.dart +++ b/packages/web_shell_core/lib/src/engine/recovery.dart @@ -23,9 +23,24 @@ String _friendlyErrorMessage(WebResourceError error) { WebResourceErrorType.connect || WebResourceErrorType.io => '没有成功连接到服务器,请检查网络后重试。', WebResourceErrorType.failedSslHandshake => '当前站点证书校验失败,请稍后再试。', - _ => - error.description.trim().isEmpty - ? '请稍后重新加载页面。' - : error.description.trim(), + _ => _parseRawErrorDescription(error.description), }; } + +String _parseRawErrorDescription(String description) { + final cleanDesc = description.trim(); + if (cleanDesc.isEmpty) { + return '请稍后重新加载页面。'; + } + final lower = cleanDesc.toLowerCase(); + if (lower.contains('err_internet_disconnected') || lower.contains('err_address_unreachable') || lower.contains('err_name_not_resolved')) { + return '没有成功连接到服务器,请检查网络后重试。'; + } + if (lower.contains('err_connection_timed_out') || lower.contains('err_timed_out')) { + return '当前网络较慢,请稍后重新加载。'; + } + if (lower.contains('err_cert_') || lower.contains('err_ssl_')) { + return '当前站点证书校验失败,请稍后再试。'; + } + return cleanDesc; +} diff --git a/packages/web_shell_core/lib/src/services/config_service.dart b/packages/web_shell_core/lib/src/services/config_service.dart new file mode 100644 index 0000000..de4b56e --- /dev/null +++ b/packages/web_shell_core/lib/src/services/config_service.dart @@ -0,0 +1,132 @@ +part of '../../core_app.dart'; + +/// 壳应用的启动配置模型。 +class ShellBootstrapConfig { + /// 远程或本地配置指定的首页地址。 + final String? initialUrl; + + /// 应用首屏锁定方向;`null` 表示沿用宿主默认配置。 + final List? preferredOrientations; + + ShellBootstrapConfig({ + this.initialUrl, + this.preferredOrientations, + }); + + factory ShellBootstrapConfig.fromJson(Map json) { + return ShellBootstrapConfig( + initialUrl: json['initialUrl']?.toString(), + preferredOrientations: _parsePreferredOrientations( + json['preferredOrientations'] ?? json['orientations'], + ), + ); + } + + static List? _parsePreferredOrientations(Object? value) { + if (value is! List) { + return null; + } + if (value.isEmpty) { + return const []; + } + + final orientations = []; + for (final item in value) { + final orientation = _parseDeviceOrientation(item?.toString()); + if (orientation != null && !orientations.contains(orientation)) { + orientations.add(orientation); + } + } + return orientations.isEmpty ? null : orientations; + } + + static DeviceOrientation? _parseDeviceOrientation(String? raw) { + final normalized = raw?.trim(); + if (normalized == null || normalized.isEmpty) { + return null; + } + + return switch (normalized) { + 'portraitUp' || + 'DeviceOrientation.portraitUp' => DeviceOrientation.portraitUp, + 'portraitDown' || + 'DeviceOrientation.portraitDown' => DeviceOrientation.portraitDown, + 'landscapeLeft' || + 'DeviceOrientation.landscapeLeft' => DeviceOrientation.landscapeLeft, + 'landscapeRight' || + 'DeviceOrientation.landscapeRight' => DeviceOrientation.landscapeRight, + _ => null, + }; + } +} + +/// 负责读取本地默认启动配置,并获取远程启动配置。 +class ShellBootstrapConfigService { + static const String _cacheKey = 'webshell_bootstrap_config_cache'; + + /// 从本地 asset 读取默认启动配置。 + static Future loadDefaultConfig( + String assetPath, + ) async { + try { + final content = await rootBundle.loadString(assetPath); + return _parseConfigString(content); + } catch (e) { + debugPrint('读取 WebShell 本地启动配置失败: $e'); + return null; + } + } + + /// 获取并解析远程启动配置。如果网络请求失败,则尝试返回本地缓存。 + static Future fetchConfig(String url) async { + try { + final uri = Uri.tryParse(url); + if (uri == null) { + return _loadFromCache(); + } + + final response = await http.get(uri).timeout(const Duration(seconds: 10)); + if (response.statusCode == 200) { + final content = utf8.decode(response.bodyBytes); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_cacheKey, content); + return _parseConfigString(content); + } + return _loadFromCache(); + } catch (e) { + debugPrint('获取 WebShell 启动配置失败(网络异常/超时),尝试读取缓存: $e'); + return _loadFromCache(); + } + } + + /// 从本地缓存读取上次成功获取的启动配置。 + static Future _loadFromCache() async { + try { + final prefs = await SharedPreferences.getInstance(); + final cachedContent = prefs.getString(_cacheKey); + if (cachedContent != null && cachedContent.isNotEmpty) { + debugPrint('成功读取到本地缓存的 WebShell 启动配置'); + return _parseConfigString(cachedContent); + } + } catch (e) { + debugPrint('读取 WebShell 启动配置缓存失败: $e'); + } + return null; + } + + /// 解析字符串格式的 JSON 启动配置。 + static ShellBootstrapConfig? _parseConfigString(String content) { + try { + final dynamic jsonMap = jsonDecode(content); + if (jsonMap is Map) { + final data = jsonMap['data'] is Map + ? jsonMap['data'] as Map + : jsonMap; + return ShellBootstrapConfig.fromJson(data); + } + } catch (e) { + debugPrint('解析 WebShell 启动配置异常: $e'); + } + return null; + } +} diff --git a/packages/web_shell_core/lib/src/services/upgrade_service.dart b/packages/web_shell_core/lib/src/services/upgrade_service.dart new file mode 100644 index 0000000..2fd7ace --- /dev/null +++ b/packages/web_shell_core/lib/src/services/upgrade_service.dart @@ -0,0 +1,138 @@ +part of '../../core_app.dart'; + +/// 远程升级配置模型。 +class ShellUpgradeConfig { + final String? versionName; + final int? version; + final int? isForce; + final String? remark; + final String? filePath; + final int? fileSize; + + ShellUpgradeConfig({ + this.versionName, + this.version, + this.isForce, + this.remark, + this.filePath, + this.fileSize, + }); + + factory ShellUpgradeConfig.fromJson(Map json) { + 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?, + ); + } +} + +/// 管理壳应用的升级逻辑,依赖 `yx_app_upgrade_flutter`。 +class ShellUpgradeService { + static final ShellUpgradeService instance = ShellUpgradeService._(); + ShellUpgradeService._(); + + String? _configUrl; + + /// 注入升级配置地址。 + void setupConfigUrl(String? configUrl) { + _configUrl = configUrl?.trim(); + } + + /// 检查应用版本,若配置中有更新则会弹出升级弹窗。 + Future checkVersion( + BuildContext context, { + bool showNoUpdateToast = false, + }) async { + final configUrl = _configUrl; + if (configUrl == null || configUrl.isEmpty) { + if (showNoUpdateToast) { + _showToastFromBridge(context, {'message': '未配置升级地址'}); + } + return; + } + + final remoteConfig = await _fetchConfig(configUrl); + if (remoteConfig == null) { + if (showNoUpdateToast) { + _showToastFromBridge(context, {'message': '未获取到版本配置'}); + } + return; + } + + UpgradeAuxiliaryUtils.instance.initiateVersionCheck( + context, + showNoUpdateToast: showNoUpdateToast, + future: (int upType) async { + return _convertToAppUpgradeVersion(remoteConfig); + }, + ); + } + + Future _fetchConfig(String url) async { + try { + final uri = Uri.tryParse(url); + if (uri == null) { + return null; + } + debugPrint('WebShell 正在获取升级配置: $url'); + final response = await http.get(uri).timeout(const Duration(seconds: 10)); + if (response.statusCode != 200) { + debugPrint('获取 WebShell 升级配置失败,状态码=${response.statusCode}'); + return null; + } + final content = utf8.decode(response.bodyBytes); + return _parseConfigString(content); + } catch (e) { + debugPrint('获取 WebShell 升级配置失败(网络异常/超时): $e'); + return null; + } + } + + ShellUpgradeConfig? _parseConfigString(String content) { + 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); + } + } catch (e) { + debugPrint('解析 WebShell 升级配置异常: $e'); + } + return null; + } + + /// 将远程 JSON 配置转换为升级弹窗所需的数据模型。 + AppUpgradeVersion? _convertToAppUpgradeVersion(ShellUpgradeConfig config) { + if (config.version == null || config.versionName == null) { + return null; + } + + final apkSizeBytes = config.fileSize != null + ? config.fileSize! * 1024 + : null; + final filePath = (config.filePath != null && config.filePath!.isNotEmpty) + ? config.filePath + : null; + + return AppUpgradeVersion( + versionName: config.versionName, + versionBuildNumber: config.version, + isForce: config.isForce == 1, + updateContent: config.remark ?? '', + downloadUrl: filePath, + appStoreUrl: filePath, + apkSize: apkSizeBytes, + supportedMethods: [ + AppUpgradeMethod.browser, + AppUpgradeMethod.inApp, + AppUpgradeMethod.market, + ], + ); + } +} 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 5d9dbcc..8e4909c 100644 --- a/packages/web_shell_core/lib/src/testing/test_hooks.dart +++ b/packages/web_shell_core/lib/src/testing/test_hooks.dart @@ -16,6 +16,20 @@ class ShellCoreTestHooks { /// 返回当前初始 URI。 Uri get initialUri => _initialUri; + /// 返回当前生效的首屏方向配置。 + List get preferredOrientations => + _shellPreferredOrientations; + + /// 解析字符串格式的启动配置。 + ShellBootstrapConfig? parseBootstrapConfigString(String content) { + return ShellBootstrapConfigService._parseConfigString(content); + } + + /// 读取本地默认启动配置。 + Future loadDefaultBootstrapConfig(String assetPath) { + return ShellBootstrapConfigService.loadDefaultConfig(assetPath); + } + /// 为测试初始化全局壳环境与初始地址。 void initializeEnvironment(ShellEnvironment environment) { _env = environment; 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 a4b08ca..beaf934 100644 --- a/packages/web_shell_core/lib/src/ui/shell_page.dart +++ b/packages/web_shell_core/lib/src/ui/shell_page.dart @@ -57,6 +57,8 @@ class _WebShellPageState extends State } _hasTriggeredInitialLoad = true; unawaited(_loadInitialPage()); + // 触发版本检测(如果配置了升级地址才会弹窗) + unawaited(ShellUpgradeService.instance.checkVersion(context)); }); } @@ -339,7 +341,9 @@ class _WebShellPageState extends State } _recordWebViewEvent('页面加载完成:$url'); _cancelStartupWatchdog(); - unawaited(_injectAppShellBridge(_controller, url)); + if (!_hasMainFrameError) { + unawaited(_injectAppShellBridge(_controller, url)); + } if (!mounted) { return; } @@ -349,8 +353,9 @@ class _WebShellPageState extends State _progress = 100; _isLoadingPage = false; _hasBootstrapped = true; - _hasMainFrameError = false; - _startupRetryCount = 0; + if (!_hasMainFrameError) { + _startupRetryCount = 0; + } }); }, onHttpError: (error) { @@ -1038,7 +1043,10 @@ class _WebShellPageState extends State ), child: Stack( children: [ - _webViewWidget, + Visibility( + visible: !_hasMainFrameError, + child: _webViewWidget, + ), if (showProgressBar) Positioned( top: 0, @@ -1050,16 +1058,20 @@ class _WebShellPageState extends State ), ), if (showLaunchOverlay) - LaunchOverlay( - progress: _progress, - hasMeasuredProgress: _hasMeasuredProgress, + Positioned.fill( + child: LaunchOverlay( + progress: _progress, + hasMeasuredProgress: _hasMeasuredProgress, + ), ), if (_hasMainFrameError) - ErrorOverlay( - title: _errorTitle, - message: _errorMessage, - currentUrl: _currentUrl, - onRetry: _reloadPage, + Positioned.fill( + child: ErrorOverlay( + title: _errorTitle, + message: _errorMessage, + currentUrl: _currentUrl, + onRetry: _reloadPage, + ), ), ], ), @@ -1068,4 +1080,5 @@ class _WebShellPageState extends State ); } } + // coverage:ignore-end diff --git a/packages/web_shell_core/pubspec.yaml b/packages/web_shell_core/pubspec.yaml index bc412a0..296fe5e 100644 --- a/packages/web_shell_core/pubspec.yaml +++ b/packages/web_shell_core/pubspec.yaml @@ -1,6 +1,7 @@ name: web_shell_core description: "Android 平板专用 H5 壳核心库,提供 WebView 引擎、JS Bridge 和宿主服务。" version: 0.0.1 +publish_to: none homepage: environment: @@ -13,12 +14,18 @@ dependencies: file_picker: ^10.3.10 flutter: sdk: flutter + http: ^1.2.0 image_picker: ^1.2.1 permission_handler: ^12.0.1 plugin_platform_interface: ^2.0.2 + shared_preferences: ^2.3.2 url_launcher: ^6.3.2 webview_flutter: ^4.13.1 webview_flutter_android: ^4.10.13 + yx_app_upgrade_flutter: + git: + url: https://gitea.23544.com/wangyang/yx_app_upgrade_flutter.git + ref: 2.0.6 dev_dependencies: flutter_test: 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 f32ed91..71c28f5 100644 --- a/packages/web_shell_core/test/web_shell_core_test.dart +++ b/packages/web_shell_core/test/web_shell_core_test.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -35,10 +37,14 @@ void main() { late _FakeImagePickerPlatform fakeImagePickerPlatform; late _FakeUrlLauncherPlatform fakeUrlLauncherPlatform; late List platformCalls; + late List platformMethodCalls; + late Map assetContents; late bool cameraPermissionGranted; setUp(() { platformCalls = []; + platformMethodCalls = []; + assetContents = {}; cameraPermissionGranted = true; fakeWebViewPlatform = _FakeWebViewPlatform(); fakeImagePickerPlatform = _FakeImagePickerPlatform(); @@ -52,9 +58,24 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_platformChannel, (call) async { platformCalls.add(call.method); + platformMethodCalls.add(call); return null; }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (message) async { + if (message == null) { + return null; + } + final assetKey = utf8.decode(message.buffer.asUint8List()); + final content = assetContents[assetKey]; + if (content == null) { + return null; + } + final bytes = Uint8List.fromList(utf8.encode(content)); + return ByteData.view(bytes.buffer); + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_permissionChannel, (call) async { if (call.method != 'requestPermissions') { @@ -75,6 +96,8 @@ void main() { .setMockMethodCallHandler(_platformChannel, null); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_permissionChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); }); group('运行时与平台', () { @@ -126,6 +149,71 @@ void main() { } }); + testWidgets('runShellApp 会应用自定义首屏方向', (tester) async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + + await runShellApp( + const ShellEnvironment( + appName: '横屏应用', + appKey: 'landscape_app', + accentColor: Color(0xFF3ED37B), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF1F2937), + mutedTextColor: Color(0xFF6B7280), + initialUrl: 'https://example.com/start', + preferredOrientations: [ + DeviceOrientation.landscapeLeft, + ], + ), + ); + await tester.pump(); + + expect( + shellCoreTestHooks.preferredOrientations, + [DeviceOrientation.landscapeLeft], + ); + final orientationCall = platformMethodCalls.lastWhere( + (call) => call.method == 'SystemChrome.setPreferredOrientations', + ); + expect(orientationCall.arguments.toString(), contains('landscapeLeft')); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('runShellApp 会读取本地默认启动配置文件', (tester) async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + assetContents['assets/config/test_bootstrap.json'] = + '{"initialUrl":"https://asset.example.com/start","preferredOrientations":["landscapeRight"]}'; + + await runShellApp( + const ShellEnvironment( + appName: '本地配置应用', + appKey: 'asset_app', + accentColor: Color(0xFF3ED37B), + backgroundColor: Color(0xFFFFFFFF), + textColor: Color(0xFF1F2937), + mutedTextColor: Color(0xFF6B7280), + bootstrapConfigAsset: 'assets/config/test_bootstrap.json', + ), + ); + await tester.pump(); + + expect( + shellCoreTestHooks.initialUrl, + 'https://asset.example.com/start', + ); + expect( + shellCoreTestHooks.preferredOrientations, + [DeviceOrientation.landscapeRight], + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('ShellApp 在非 Android 下展示兜底页', (tester) async { try { debugDefaultTargetPlatformOverride = TargetPlatform.windows; @@ -664,8 +752,9 @@ void main() { expect(indicator.value, 0.5); }); - testWidgets('LaunchOverlay 始终显示不定态 CircularProgressIndicator', - (tester) async { + testWidgets('LaunchOverlay 始终显示不定态 CircularProgressIndicator', ( + tester, + ) async { await tester.pumpWidget( const MaterialApp( home: LaunchOverlay(progress: 0, hasMeasuredProgress: false), @@ -1036,6 +1125,26 @@ void main() { expect(_testEnvironment.textColor, const Color(0xFF1F2937)); expect(_testEnvironment.mutedTextColor, const Color(0xFF6B7280)); expect(_testEnvironment.initialUrl, 'example.com/login'); + expect(_testEnvironment.bootstrapConfigAsset, isNull); + expect(_testEnvironment.bootstrapConfigUrl, isNull); + expect(_testEnvironment.upgradeConfigUrl, isNull); + expect(_testEnvironment.preferredOrientations, isNull); + }); + + test('启动配置能解析首页地址与方向', () { + final config = shellCoreTestHooks.parseBootstrapConfigString( + '{"data":{"initialUrl":"https://example.com/bootstrap","preferredOrientations":["portraitUp","landscapeLeft"]}}', + ); + + expect(config, isNotNull); + expect(config!.initialUrl, 'https://example.com/bootstrap'); + expect( + config.preferredOrientations, + [ + DeviceOrientation.portraitUp, + DeviceOrientation.landscapeLeft, + ], + ); }); test('initialUrl 可选字段缺省时使用默认地址', () { @@ -1533,6 +1642,52 @@ void main() { // 由 MaterialApp 自带一些 FadeTransition,检查无多余 Text expect(find.text(''), findsNothing); }); + + testWidgets('主帧资源错误后 pageFinished 不会清空错误浮层', (tester) async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + await tester.pumpWidget(const MaterialApp(home: WebShellPage())); + await tester.pump(); + + final controller = fakeWebViewPlatform.createdControllers.last; + final delegate = + controller.delegate! as _FakePlatformNavigationDelegate; + const failedUrl = 'https://example.com/login'; + + delegate.onPageStarted?.call(failedUrl); + await tester.pump(); + + delegate.onWebResourceError?.call( + const WebResourceError( + errorCode: -2, + description: 'net::ERR_INTERNET_DISCONNECTED', + errorType: WebResourceErrorType.hostLookup, + isForMainFrame: true, + url: failedUrl, + ), + ); + await tester.pump(); + + expect(find.text('网络连接失败'), findsOneWidget); + expect(find.text('没有成功连接到服务器,请检查网络后重试。'), findsOneWidget); + expect( + find.byKey(const ValueKey('fake-webview')), + findsNothing, + ); + + delegate.onPageFinished?.call(failedUrl); + await tester.pump(); + + expect(find.text('网络连接失败'), findsOneWidget); + expect( + find.byKey(const ValueKey('fake-webview')), + findsNothing, + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); }); } @@ -1628,30 +1783,52 @@ class _FakePlatformWebViewCookieManager extends PlatformWebViewCookieManager { class _FakePlatformNavigationDelegate extends PlatformNavigationDelegate { _FakePlatformNavigationDelegate(super.params) : super.implementation(); + NavigationRequestCallback? onNavigationRequest; + PageEventCallback? onPageStarted; + PageEventCallback? onPageFinished; + HttpResponseErrorCallback? onHttpError; + ProgressCallback? onProgress; + WebResourceErrorCallback? onWebResourceError; + UrlChangeCallback? onUrlChange; + @override Future setOnNavigationRequest( NavigationRequestCallback onNavigationRequest, - ) async {} + ) async { + this.onNavigationRequest = onNavigationRequest; + } @override - Future setOnPageStarted(PageEventCallback onPageStarted) async {} + Future setOnPageStarted(PageEventCallback onPageStarted) async { + this.onPageStarted = onPageStarted; + } @override - Future setOnPageFinished(PageEventCallback onPageFinished) async {} + Future setOnPageFinished(PageEventCallback onPageFinished) async { + this.onPageFinished = onPageFinished; + } @override - Future setOnHttpError(HttpResponseErrorCallback onHttpError) async {} + Future setOnHttpError(HttpResponseErrorCallback onHttpError) async { + this.onHttpError = onHttpError; + } @override - Future setOnProgress(ProgressCallback onProgress) async {} + Future setOnProgress(ProgressCallback onProgress) async { + this.onProgress = onProgress; + } @override Future setOnWebResourceError( WebResourceErrorCallback onWebResourceError, - ) async {} + ) async { + this.onWebResourceError = onWebResourceError; + } @override - Future setOnUrlChange(UrlChangeCallback onUrlChange) async {} + Future setOnUrlChange(UrlChangeCallback onUrlChange) async { + this.onUrlChange = onUrlChange; + } } class _FakePlatformWebViewController extends PlatformWebViewController { diff --git a/packages/web_shell_core/test_assets/bootstrap.json b/packages/web_shell_core/test_assets/bootstrap.json new file mode 100644 index 0000000..b91bb1d --- /dev/null +++ b/packages/web_shell_core/test_assets/bootstrap.json @@ -0,0 +1,8 @@ +{ + "code": 200, + "msg": "success", + "data": { + "initialUrl": "http://192.168.2.57:8080/test_bridge.html", + "preferredOrientations": ["portraitUp", "portraitDown"] + } +} diff --git a/packages/web_shell_core/test_assets/config.json b/packages/web_shell_core/test_assets/config.json new file mode 100644 index 0000000..ccc9d0c --- /dev/null +++ b/packages/web_shell_core/test_assets/config.json @@ -0,0 +1,13 @@ +{ + "code": 200, + "msg": "success", + "data": { + "initialUrl": "http://192.168.2.57:8080/test_bridge.html", + "versionName": "1.0.0", + "version": 100, + "isForce": 0, + "remark": "1. 修复已知问题\n2. 测试升级弹窗功能是否正常加载\n3. JSON 动态覆盖 initialUrl", + "filePath": "https://gitee.com/mr_koi/static_host/raw/master/app-release.apk", + "fileSize": 25000 + } +} \ No newline at end of file diff --git a/packages/web_shell_core/test_assets/upgrade.json b/packages/web_shell_core/test_assets/upgrade.json new file mode 100644 index 0000000..e97b784 --- /dev/null +++ b/packages/web_shell_core/test_assets/upgrade.json @@ -0,0 +1,12 @@ +{ + "code": 200, + "msg": "success", + "data": { + "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": 25000 + } +} diff --git a/tool/generate_app.dart b/tool/generate_app.dart index c57c6d8..44996dc 100644 --- a/tool/generate_app.dart +++ b/tool/generate_app.dart @@ -1,86 +1,78 @@ +import 'dart:convert'; import 'dart:io'; + import 'package:yaml/yaml.dart'; -/// 品牌名允许的字符:小写字母、数字、下划线。 final RegExp _validBrandName = RegExp(r'^[a-z][a-z0-9_]*$'); +const List _defaultPreferredOrientations = [ + 'portraitUp', + 'portraitDown', +]; Future main(List args) async { if (args.isEmpty) { print('\x1B[31m用法:dart run tool/generate_app.dart <品牌名>\x1B[0m'); - print('\x1B[33m示例:dart run tool/generate_app.dart quanxue\x1B[0m'); + print('\x1B[33m示例:dart run tool/generate_app.dart aixue\x1B[0m'); exit(1); } - final String brand = args.first; - + final brand = args.first; if (!_validBrandName.hasMatch(brand)) { - print( - '\x1B[31m[错误] 品牌名 "$brand" 格式无效,' - '仅允许小写字母、数字和下划线(且必须以字母开头)。\x1B[0m', - ); + print('\x1B[31m[错误] 品牌名 "$brand" 格式无效,仅允许小写字母、数字和下划线。\x1B[0m'); exit(1); } - final File configFile = File('flavors/$brand.yaml'); - + final configFile = File('flavors/$brand.yaml'); if (!configFile.existsSync()) { print('\x1B[31m[错误] 未找到配置文件:${configFile.path}\x1B[0m'); exit(1); } print('\x1B[34m[信息] 正在为品牌生成应用:$brand...\x1B[0m'); + final yamlString = await configFile.readAsString(); + final config = loadYaml(yamlString) as YamlMap; - // 1. 解析 YAML 配置 - final String yamlString = await configFile.readAsString(); - final YamlMap config = loadYaml(yamlString) as YamlMap; - - final String appName = config['app_name'] as String; - final String applicationId = config['application_id'] as String; - final String appKey = config['app_key'] as String; - - final YamlMap theme = config['theme'] as YamlMap; - final String accentColor = theme['accent_color'] as String; - final String bgColor = theme['bg_color'] as String; - final String textColor = theme['text_color'] as String; - final String mutedTextColor = theme['muted_text_color'] as String; - - final String appDir = 'apps/$brand'; - - print('\x1B[32m✔ 配置加载完成。\x1B[0m'); - - // 2. 创建 Flutter 应用 - await _createFlutterApp(brand, appDir, applicationId); - - // 3. 添加核心依赖 - await _addCoreDependency(appDir); - - // 4. 覆盖 MainActivity.java 以继承 CoreShellActivity - await _overwriteMainActivity(appDir, applicationId); - - // 5. 覆盖 AndroidManifest.xml 中的应用名称 - await _overwriteManifestLabel(appDir, appName); - - final String? defaultUrl = config['default_url'] as String?; - - // 6. 生成 lib/main.dart - await _generateDartEntrypoint( - appDir, - appName, - appKey, - accentColor, - bgColor, - textColor, - mutedTextColor, - defaultUrl, + final appName = config['app_name'] as String; + final applicationId = config['application_id'] as String; + final appKey = config['app_key'] as String; + final defaultUrl = config['default_url'] as String?; + final bootstrapConfigUrl = config['bootstrap_config_url'] as String?; + final upgradeConfigUrl = config['upgrade_config_url'] as String?; + final preferredOrientations = _readPreferredOrientations( + config['preferred_orientations'], ); - // 7. 生成图标与启动页配置 + final theme = config['theme'] as YamlMap; + final accentColor = theme['accent_color'] as String; + final bgColor = theme['bg_color'] as String; + final textColor = theme['text_color'] as String; + final mutedTextColor = theme['muted_text_color'] as String; + + final appDir = 'apps/$brand'; + + await _createFlutterApp(brand, appDir, applicationId); + await _addCoreDependency(appDir); + await _rewriteAndroidPackageId(appDir, applicationId); + await _overwriteMainActivity(appDir, applicationId); + await _overwriteManifestLabel(appDir, appName); + await _generateDartEntrypoint( + appDir: appDir, + appName: appName, + appKey: appKey, + accentColor: accentColor, + backgroundColor: bgColor, + textColor: textColor, + mutedTextColor: mutedTextColor, + bootstrapConfigUrl: bootstrapConfigUrl, + upgradeConfigUrl: upgradeConfigUrl, + ); + await _generateBootstrapConfig( + appDir: appDir, + defaultUrl: defaultUrl, + preferredOrientations: preferredOrientations, + ); await _generateBrandingAssets(brand, appDir, config); - - // 8. 在 pubspec.yaml 中注册 Flutter assets await _registerFlutterAssets(appDir); - - // 9. 配置签名 (KeyStore) await _configureSigning(appDir, config); print('\x1B[32m✔ 应用 $brand 已生成到 $appDir!\x1B[0m'); @@ -88,29 +80,55 @@ Future main(List args) async { print(' cd $appDir && flutter build apk'); } +List _readPreferredOrientations(Object? raw) { + if (raw is! YamlList && raw is! List) { + return _defaultPreferredOrientations; + } + + final values = []; + for (final item in (raw as List)) { + final normalized = _normalizeOrientation(item?.toString()); + if (normalized != null && !values.contains(normalized)) { + values.add(normalized); + } + } + return values.isEmpty ? _defaultPreferredOrientations : values; +} + +String? _normalizeOrientation(String? raw) { + final value = raw?.trim(); + if (value == null || value.isEmpty) { + return null; + } + return switch (value) { + 'portraitUp' || 'DeviceOrientation.portraitUp' => 'portraitUp', + 'portraitDown' || 'DeviceOrientation.portraitDown' => 'portraitDown', + 'landscapeLeft' || 'DeviceOrientation.landscapeLeft' => 'landscapeLeft', + 'landscapeRight' || 'DeviceOrientation.landscapeRight' => 'landscapeRight', + _ => null, + }; +} + Future _createFlutterApp( String brand, String appDir, String applicationId, ) async { print('\x1B[34m[信息] 正在执行 flutter create...\x1B[0m'); - final Directory dir = Directory(appDir); + final dir = Directory(appDir); if (dir.existsSync()) { print('\x1B[33m[警告] 目录 $appDir 已存在,正在清理...\x1B[0m'); dir.deleteSync(recursive: true); } - // 提取组织名 - // 例如:com.yuanxuan.quanxue -> org: com.yuanxuan - final List segments = applicationId.split('.'); - final String org = segments.sublist(0, segments.length - 1).join('.'); - - final ProcessResult result = await Process.run('flutter', [ + final segments = applicationId.split('.'); + final org = segments.sublist(0, segments.length - 1).join('.'); + final result = await Process.run('flutter', [ 'create', '--org', org, '--project-name', - brand.replaceAll('-', '_'), + brand, '--platforms', 'android', '--android-language', @@ -126,7 +144,7 @@ Future _createFlutterApp( Future _addCoreDependency(String appDir) async { print('\x1B[34m[信息] 正在添加 web_shell_core 依赖...\x1B[0m'); - final ProcessResult result = await Process.run('flutter', [ + final result = await Process.run('flutter', [ 'pub', 'add', 'web_shell_core', @@ -140,13 +158,40 @@ Future _addCoreDependency(String appDir) async { } } +Future _rewriteAndroidPackageId( + String appDir, + String applicationId, +) async { + print('\x1B[34m[信息] 正在校正 Android applicationId / namespace...\x1B[0m'); + final gradleFile = File('$appDir/android/app/build.gradle.kts'); + if (!gradleFile.existsSync()) { + print('\x1B[31m[错误] 未找到 ${gradleFile.path}\x1B[0m'); + exit(1); + } + + var content = await gradleFile.readAsString(); + content = content.replaceFirst( + RegExp(r'namespace\s*=\s*"[^"]+"'), + 'namespace = "$applicationId"', + ); + content = content.replaceFirst( + RegExp(r'applicationId\s*=\s*"[^"]+"'), + 'applicationId = "$applicationId"', + ); + await gradleFile.writeAsString(content); + + final javaDir = Directory('$appDir/android/app/src/main/java'); + if (javaDir.existsSync()) { + javaDir.deleteSync(recursive: true); + } + await javaDir.create(recursive: true); +} + Future _overwriteMainActivity(String appDir, String applicationId) async { print('\x1B[34m[信息] 正在注入 CoreShellActivity 继承关系...\x1B[0m'); - final String mainActivityPath = - "$appDir/android/app/src/main/java/${applicationId.replaceAll('.', '/')}/MainActivity.java"; - - // 准备 Java 文件内容 - final String javaContent = + final mainActivityPath = + '$appDir/android/app/src/main/java/${applicationId.replaceAll('.', '/')}/MainActivity.java'; + final javaContent = ''' package $applicationId; @@ -156,42 +201,45 @@ public class MainActivity extends CoreShellActivity { } '''; - await File(mainActivityPath).create(recursive: true); - await File(mainActivityPath).writeAsString(javaContent); + final mainActivityFile = File(mainActivityPath); + await mainActivityFile.parent.create(recursive: true); + await mainActivityFile.writeAsString(javaContent); } Future _overwriteManifestLabel(String appDir, String appName) async { print('\x1B[34m[信息] 正在更新 AndroidManifest.xml 应用名...\x1B[0m'); - final File manifestFile = File( - '$appDir/android/app/src/main/AndroidManifest.xml', - ); - String content = await manifestFile.readAsString(); - - // 使用简单正则替换 android:label="..." + final manifestFile = File('$appDir/android/app/src/main/AndroidManifest.xml'); + var content = await manifestFile.readAsString(); content = content.replaceAll( RegExp(r'android:label="[^"]*"'), 'android:label="$appName"', ); - await manifestFile.writeAsString(content); } -Future _generateDartEntrypoint( - String appDir, - String appName, - String appKey, - String accentColor, - String bgColor, - String textColor, - String mutedTextColor, - String? defaultUrl, -) async { +Future _generateDartEntrypoint({ + required String appDir, + required String appName, + required String appKey, + required String accentColor, + required String backgroundColor, + required String textColor, + required String mutedTextColor, + String? bootstrapConfigUrl, + String? upgradeConfigUrl, +}) async { print('\x1B[34m[信息] 正在生成 lib/main.dart...\x1B[0m'); - final File mainFile = File('$appDir/lib/main.dart'); + final mainFile = File('$appDir/lib/main.dart'); - final String urlParam = defaultUrl != null ? "initialUrl: '$defaultUrl'," : ""; + final extraLines = [ + " bootstrapConfigAsset: 'assets/config/bootstrap.json',", + if (bootstrapConfigUrl != null && bootstrapConfigUrl.trim().isNotEmpty) + " bootstrapConfigUrl: '${bootstrapConfigUrl.trim()}',", + if (upgradeConfigUrl != null && upgradeConfigUrl.trim().isNotEmpty) + " upgradeConfigUrl: '${upgradeConfigUrl.trim()}',", + ].join('\n'); - final String dartContent = + final dartContent = ''' import 'package:flutter/material.dart'; import 'package:web_shell_core/web_shell_core.dart'; @@ -202,11 +250,11 @@ void main() { appName: '$appName', appKey: '$appKey', accentColor: const Color($accentColor), - backgroundColor: const Color($bgColor), + backgroundColor: const Color($backgroundColor), textColor: const Color($textColor), mutedTextColor: const Color($mutedTextColor), splashImage: const AssetImage('assets/branding/splash.png'), - $urlParam +$extraLines ), ); } @@ -214,13 +262,31 @@ void main() { await mainFile.writeAsString(dartContent); - // 清理 flutter create 默认生成的测试文件(因为它依赖已删除的 MyApp 类) - final File defaultTestFile = File('$appDir/test/widget_test.dart'); + final defaultTestFile = File('$appDir/test/widget_test.dart'); if (defaultTestFile.existsSync()) { await defaultTestFile.delete(); } } +Future _generateBootstrapConfig({ + required String appDir, + required String? defaultUrl, + required List preferredOrientations, +}) async { + print('\x1B[34m[信息] 正在生成默认启动配置 assets/config/bootstrap.json...\x1B[0m'); + final file = File('$appDir/assets/config/bootstrap.json'); + await file.parent.create(recursive: true); + + final data = { + if (defaultUrl != null && defaultUrl.trim().isNotEmpty) + 'initialUrl': defaultUrl.trim(), + 'preferredOrientations': preferredOrientations, + }; + + final content = const JsonEncoder.withIndent(' ').convert(data); + await file.writeAsString('$content\n'); +} + Future _generateBrandingAssets( String brand, String appDir, @@ -231,62 +297,50 @@ Future _generateBrandingAssets( print('\x1B[33m[警告] 配置中未找到 branding 段,跳过资源生成。\x1B[0m'); return; } - final YamlMap branding = config['branding'] as YamlMap; - // ── 1. 复制品牌资源到生成的应用目录 ── - final Directory brandSourceDir = Directory('flavors/$brand'); - final Directory brandTargetDir = Directory('$appDir/assets/branding'); + final branding = config['branding'] as YamlMap; + final brandSourceDir = Directory('flavors/$brand'); + final brandTargetDir = Directory('$appDir/assets/branding'); if (!brandSourceDir.existsSync()) { print('\x1B[31m[错误] 品牌资源目录不存在:${brandSourceDir.path}\x1B[0m'); - print( - '\x1B[33m请在 flavors/$brand/ 目录下放置 icon.png、' - 'icon_foreground.png、splash.png 等资源文件。\x1B[0m', - ); exit(1); } await brandTargetDir.create(recursive: true); for (final entity in brandSourceDir.listSync(recursive: true)) { - if (entity is File) { - final relativePath = entity.path.substring( - brandSourceDir.path.length + 1, - ); - final targetFile = File('${brandTargetDir.path}/$relativePath'); - await targetFile.parent.create(recursive: true); - await entity.copy(targetFile.path); + if (entity is! File) { + continue; } + final relativePath = entity.path.substring(brandSourceDir.path.length + 1); + final targetFile = File('${brandTargetDir.path}/$relativePath'); + await targetFile.parent.create(recursive: true); + await entity.copy(targetFile.path); } + print('\x1B[32m✔ 品牌资源已复制到 ${brandTargetDir.path}\x1B[0m'); - // ── 2. 添加资源生成器的 dev 依赖 ── print('\x1B[34m[信息] 正在添加资源生成器依赖...\x1B[0m'); - final ProcessResult addDevDepsResult = await Process.run('flutter', [ + final addDevDepsResult = await Process.run('flutter', [ 'pub', 'add', '--dev', 'flutter_launcher_icons', 'flutter_native_splash', ], workingDirectory: appDir); - if (addDevDepsResult.exitCode != 0) { - print( - '\x1B[31m[错误] 添加资源生成器依赖失败:' - '\n${addDevDepsResult.stderr}\x1B[0m', - ); + print('\x1B[31m[错误] 添加资源生成器依赖失败:\n${addDevDepsResult.stderr}\x1B[0m'); exit(1); } - // ── 3. 确保依赖已解析 ── - await Process.run('flutter', ['pub', 'get'], workingDirectory: appDir); + await Process.run('flutter', [ + 'pub', + 'get', + ], workingDirectory: appDir); - // ── 4. 生成 flutter_launcher_icons 配置 ── - // 资源路径指向复制后的位置(相对于 appDir) - final String iconPath = 'assets/branding/${branding['icon']}'; - final String iconForeground = - 'assets/branding/${branding['icon_foreground']}'; - final String iconBackground = branding['icon_background'] as String; - - final String iconsYaml = + final iconPath = 'assets/branding/${branding['icon']}'; + final iconForeground = 'assets/branding/${branding['icon_foreground']}'; + final iconBackground = branding['icon_background'] as String; + final iconsYaml = ''' flutter_launcher_icons: android: true @@ -296,11 +350,9 @@ flutter_launcher_icons: '''; await File('$appDir/flutter_launcher_icons.yaml').writeAsString(iconsYaml); - // ── 4. 生成 flutter_native_splash 配置 ── - final String splashPath = 'assets/branding/${branding['splash']}'; - final String splashColor = branding['splash_color'] as String; - - final String splashYaml = + final splashPath = 'assets/branding/${branding['splash']}'; + final splashColor = branding['splash_color'] as String; + final splashYaml = ''' flutter_native_splash: color: "$splashColor" @@ -311,39 +363,33 @@ flutter_native_splash: '''; await File('$appDir/flutter_native_splash.yaml').writeAsString(splashYaml); - // ── 5. 执行资源生成器 ── print('\x1B[34m[信息] 正在生成应用图标...\x1B[0m'); - final ProcessResult iconsResult = await Process.run('dart', [ + final iconsResult = await Process.run('dart', [ 'run', 'flutter_launcher_icons', '-f', 'flutter_launcher_icons.yaml', ], workingDirectory: appDir); - if (iconsResult.exitCode != 0) { print('\x1B[31m[错误] 图标生成失败:\n${iconsResult.stderr}\x1B[0m'); print('\x1B[33mstdout:\n${iconsResult.stdout}\x1B[0m'); exit(1); } - print('\x1B[32m✔ 应用图标已生成。\x1B[0m'); print('\x1B[34m[信息] 正在生成启动页...\x1B[0m'); - final ProcessResult splashResult = await Process.run('dart', [ + final splashResult = await Process.run('dart', [ 'run', 'flutter_native_splash:create', '--path=flutter_native_splash.yaml', ], workingDirectory: appDir); - if (splashResult.exitCode != 0) { print('\x1B[31m[错误] 启动页生成失败:\n${splashResult.stderr}\x1B[0m'); print('\x1B[33mstdout:\n${splashResult.stdout}\x1B[0m'); exit(1); } - print('\x1B[32m✔ 启动页已生成。\x1B[0m'); - // ── 6. 移除资源生成器依赖以避免干扰 release 打包 ── print('\x1B[34m[信息] 正在移除资源生成器依赖...\x1B[0m'); - await Process.run('flutter', [ + await Process.run('flutter', [ 'pub', 'remove', 'flutter_launcher_icons', @@ -353,38 +399,43 @@ flutter_native_splash: Future _registerFlutterAssets(String appDir) async { print('\x1B[34m[信息] 正在注册 Flutter assets...\x1B[0m'); - final File pubspecFile = File('$appDir/pubspec.yaml'); - String content = await pubspecFile.readAsString(); + final pubspecFile = File('$appDir/pubspec.yaml'); + var content = await pubspecFile.readAsString(); - // 检查是否已有未注释的 assets: 声明 - final bool hasAssets = RegExp(r'^\s+assets:', multiLine: true).hasMatch(content); - if (!hasAssets) { - // 必须匹配行首的顶层 flutter: 键,忽略缩进的 flutter: (SDK 依赖) + if (!RegExp(r'^\s+assets:\s*$', multiLine: true).hasMatch(content)) { content = content.replaceFirst( RegExp(r'^flutter:\s*\n', multiLine: true), - 'flutter:\n assets:\n - assets/branding/\n', + 'flutter:\n assets:\n - assets/branding/\n - assets/config/\n', ); - await pubspecFile.writeAsString(content); + } else { + if (!content.contains(' - assets/branding/')) { + content = content.replaceFirst( + RegExp(r'^\s+assets:\s*\n', multiLine: true), + ' assets:\n - assets/branding/\n', + ); + } + if (!content.contains(' - assets/config/')) { + content = content.replaceFirst( + ' - assets/branding/\n', + ' - assets/branding/\n - assets/config/\n', + ); + } } - print('\x1B[32m✔ Flutter assets 已注册。\x1B[0m'); + + await pubspecFile.writeAsString(content); } Future _configureSigning(String appDir, YamlMap config) async { print('\x1B[34m[信息] 正在配置应用签名...\x1B[0m'); - - // 提取配置,默认为工具根目录下的 key.jks - final YamlMap? signing = config['signing'] as YamlMap?; - final String keyAlias = signing?['key_alias'] as String? ?? 'my-key-alias'; - final String keyPassword = signing?['key_password'] as String? ?? '123456'; - final String storePassword = signing?['store_password'] as String? ?? '123456'; - - // key.properties 会放在 apps//android/ 目录下 - // 但它被 app/build.gradle.kts 读取和求值,所以在 build.gradle.kts 中它的相对基准是 android/app/ - // 指向 tool/key.jks 的相对路径是: ../../../../tool/key.jks - final String storeFile = signing?['store_file'] as String? ?? '../../../../tool/key.jks'; + final signing = config['signing'] as YamlMap?; + final keyAlias = signing?['key_alias'] as String? ?? 'my-key-alias'; + final keyPassword = signing?['key_password'] as String? ?? '123456'; + final storePassword = signing?['store_password'] as String? ?? '123456'; + final storeFile = + signing?['store_file'] as String? ?? '../../../../tool/key.jks'; - // 1. 写入 android/key.properties - final String keyPropsContent = ''' + final keyPropsContent = + ''' storePassword=$storePassword keyPassword=$keyPassword keyAlias=$keyAlias @@ -392,16 +443,13 @@ storeFile=$storeFile '''; await File('$appDir/android/key.properties').writeAsString(keyPropsContent); - // 2. 修改 build.gradle.kts - final File gradleFile = File('$appDir/android/app/build.gradle.kts'); + final gradleFile = File('$appDir/android/app/build.gradle.kts'); if (!gradleFile.existsSync()) { print('\x1B[33m[警告] 未找到 build.gradle.kts,跳过注入签名配置。\x1B[0m'); return; } - - String content = await gradleFile.readAsString(); - // 注入 Properties 加载代码 + var content = await gradleFile.readAsString(); if (!content.contains('val keystoreProperties = Properties()')) { content = content.replaceFirst('android {', ''' import java.util.Properties @@ -417,9 +465,8 @@ android { '''); } - // 注入 signingConfigs 并修改 buildTypes.release - if (!content.contains('signingConfigs {\\n create("release") {')) { - final String newBuildTypes = ''' + if (!content.contains('signingConfigs {\n create("release") {')) { + const newBuildTypes = ''' signingConfigs { create("release") { keyAlias = keystoreProperties["keyAlias"] as String? @@ -435,12 +482,11 @@ android { } } '''; - - // 匹配原始的 buildTypes { release { ... } } 块 - final RegExp buildTypeRegex = RegExp(r'\s*buildTypes\s*\{[\s\S]*?signingConfigs\.getByName\("debug"\)\s*\}\s*\}'); + final buildTypeRegex = RegExp( + r'\s*buildTypes\s*\{[\s\S]*?signingConfigs\.getByName\("debug"\)\s*\}\s*\}', + ); content = content.replaceFirst(buildTypeRegex, '\n$newBuildTypes'); } await gradleFile.writeAsString(content); - print('\x1B[32m✔ 签名配置注入完成。\x1B[0m'); } diff --git a/tool/key.jks b/tool/key.jks new file mode 100644 index 0000000000000000000000000000000000000000..877f11eddb9a216e259e54f7a4e7df9e8bafaa00 GIT binary patch literal 2692 zcma)8XE+;-7EU4wi9|wUuN1LIf?8E-kJ8qv)!3m%?S4iFO-YNI^%=Kf&!VVN^C};0 zDWw!4RP9l%RZU0Ab;r{__t(8Y&htL!J@0#dzUSb{@K_L#1y6>{LSeELQwjqKWCa$I z;euc?ocEYMfhR-S|7!%P29qHb$8^bY6+qel<3hlJph7a_>@lg0_x|O8An}fPjo&Q- zF9*hSoz7B{pT+o*p?uO;Fgmx!QzX_vASpBmh{YqIZ2#T}f~E6GI@QxTuvLu%4khy?*f0y=s%)<)9gn`Q(5NNQpyLr<>< zZPeTH9O=L&^qzl1p}IL-Bkf#rg8H@Z*p!(AMtb5x>)Az=ZXuO@x>r|=>C2E}pj&B4 z=^Wek$fFOb%>Dk127TiG5~q8Y<6z1T`{i3OIhpZN+Sg~0^6n|ll8ZU`oQpn+?R&N@ zXF~9OV*5&40h%v`zZ$9POB(8yRlRuaVzHt!l-^cZ>Nhj#Dyh^I9;#u%7Ct30!9w$f zY<@pukSx>o7q`E+QH%XSqBjgRHP&FJF2KCX$oF+@YsFl)8rktSFB_-=3qBt`h_@d; zd5QBSfo((BNAMopaK0(j%|Jild0(&FPbezR|-~o^7%PEzJr>~0`9Un~UhTTB?;XtUvrWpKw z!R;^JuvQCH8hLcV#=)Ba-bL5$6_W-+7wbRK5VZ;_(_9s|dubQG9VjhHSaz*7=DHN- zVx%abZ(sY)M&2IABNvUR`mfd1sv?;0GM!%1%0=tSLwrB;+ee>U`x3#H62hQ#4B@uX z#n?nS)){9DM zO67p7O4a)aX{+>Le+tvnoJGg^E*}=s0}at+rCL zcX#MS0cYby-jUepZ$5dW!enE)V#PU_YKzv0I;3PEC>8`B6jo_nX0!ie&<-c+r3eMg zE1FQ)N`8b(+k2dVFs?wLW|#$ER6qw*NqI<;3K7Zxv=M7%YuD=por~ImKWwE&qrA|M z>Bz-F>65J+bI?P?`=c)meb3ni?q*J%&-$%lf}J8Et&?`m_}PylnU)owud|pgZd_O_kzLfj)b{IfPR$?cT3+#JDoLv&25Oym<3o$mOgY-J|C}Fs|U2B8TgZw>xbn25hV}n;o53@uyK&!vc5En@`I9Zr)`c zX#kYQX008*$n-dpKJ_(@jfbmya5FCZrj7`}uKyWi=9&;P`@%~>=Erc)AAG#Fx5NiP zfZSJk@uJyLdgNR2mCZw8#uun9`AG9{Pp@o!4hm!Ap|^;9JF6P6%_)8QV*Q;v)_fh) zqS}f#2o`v$UvUY;5ClZ{09OG~00n?Qfb=hP2Lu3o0q%e>{K?-!ZUP3(W$SyDge9n} zs;a1|sHqbOs%m&L*y@*v6mO4(ARyov)PGNa|0}Wuv=aM;Td7t_voez#i3dW6 zIp)p(C$jkjci~*~M?7w*PZ|Ji@9kJJ%QJ3Ac&ihjX)C&NSt~O)Grls~h2YGC+rD71 zA3(Ld-dH<*KK?rHVZk|RWhB#WK>d@nwv+at$((g2$Cj)cCu%ior+QDk^hUI#q7#{H zNbSq53fTNwJeZQtzRvIP`p!r8GlikD#oLeBn}}N8+IB)e#&zq~Dgn= zWZBYjBhGfl71g|qw;t~=ku5Qa_WDo!6lUkM%Z@G)?upJ_1ussaPv&@XM-nOD3Qq7W z@D+CVV?HS8DFYhN)!%2L>?xihE3=k--#-?vvj~N7qeBuT6%Ih{iSE4mjjP?CE+a$P z;>P6rw(Rup8#oGcFjV^pQ6Y;lT-bz=4KGhWQso-&-#9G0`fWDw)8zT5e?5Y+V>@|k zFOKI1^P-Xq0Y+u+-_hent`Kr&~1#t7nlhq{3XkyRuH`%~c{*7;1$!n~^CgW8K>6r)7M7jaFC!yTsi z;dBEe#)o#$EhPAij4h56CdRi8qs0gw?-RVIma!u7xOu|3va@W{L~|74U6_UVjs8&7 zT(EyAtGO?V-59=Zp=ClmcbWExo*YErqVFMDC2D-XH;QC*aq>DZ8*DEvPaAK-8qb%R zMIcgB5PLnZ6=~tmdPPJ1L9DQ>>{s&@g1f2g>S+S1qraqA;RDf~BEFu0 zd7)GFW577in$2azO5u^!@OC8i9Gn?C$C+;Vv;Kr-HZiudH+|Ep$6?HqeqZy{h7xcB zeF~r~no6pxkHOtObY^9i^8kx?gK5z^OZIWI!zOIa1rou!YBO8%gpM7yNIyOly9c;A zlazmuR^kv4CO*&vxg07CmAGOkDD$+?STo0FZzXohs}OK4HNcknjCOh zJWaUn1r;xx?!+lwr5SSs(6pzZGdS~gjjT0AP7D)>Zc5rBG1PXRi1`jLy55Ty;kh@! zRpH%R_-$?=D8b_r%hVbh?}|+iB*j zQN743aw9_*(v3J~Ej$=dj9N`}*rlkxmt8bxp2hixBIf?IlYw0>d}JKgA2 zCT@n0!;LM?6yoFERrwA$;PG?kyWvt>c7S;NR7Ng@KSRDLdz1}~dG0@Xt+XT$&$#d` Qev~QfIM|!)@y`hP2c`h9;Q#;t literal 0 HcmV?d00001