feat: 重构启动配置与升级配置分离,修复 WebView 错误浮层竞态条件

- 将 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 配置
This commit is contained in:
Max 2026-03-20 17:23:03 +08:00
parent bf0f0e082f
commit 0d259334ee
33 changed files with 1742 additions and 258 deletions

20
.vscode/launch.json vendored
View File

@ -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",

View File

@ -0,0 +1,7 @@
{
"initialUrl": "http://xszy.lzzneng.com/login.html",
"preferredOrientations": [
"portraitUp",
"portraitDown"
]
}

View File

@ -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',
),
);
}

View File

@ -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"

View File

@ -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.

View File

@ -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

View File

@ -1,6 +0,0 @@
package com.yuanxuan.test;
import io.flutter.embedding.android.FlutterActivity;
public class MainActivity extends FlutterActivity {
}

View File

@ -0,0 +1,7 @@
{
"initialUrl": "http://192.168.2.57:8080/test_bridge.html",
"preferredOrientations": [
"portraitUp",
"portraitDown"
]
}

View File

@ -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',
),
);
}

View File

@ -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"

View File

@ -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.

View File

@ -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"
]
}

View File

@ -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',
),
);
}

View File

@ -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"

View File

@ -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.

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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 组件
- 导航路由 · 启动配置解析 · 方向配置 · 组件渲染
## 平台约束

View File

@ -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<DeviceOrientation> get _shellPreferredOrientations =>
_env.preferredOrientations ??
const <DeviceOrientation>[
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
];
//
///
/// `main.dart` [ShellEnvironment]
Future<void> 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<void> _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<void> _enterImmersiveMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setSystemUIOverlayStyle(

View File

@ -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<DeviceOrientation>? 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<DeviceOrientation>? 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,
);
}
}

View File

@ -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;
}

View File

@ -0,0 +1,132 @@
part of '../../core_app.dart';
///
class ShellBootstrapConfig {
///
final String? initialUrl;
/// `null` 沿宿
final List<DeviceOrientation>? preferredOrientations;
ShellBootstrapConfig({
this.initialUrl,
this.preferredOrientations,
});
factory ShellBootstrapConfig.fromJson(Map<String, dynamic> json) {
return ShellBootstrapConfig(
initialUrl: json['initialUrl']?.toString(),
preferredOrientations: _parsePreferredOrientations(
json['preferredOrientations'] ?? json['orientations'],
),
);
}
static List<DeviceOrientation>? _parsePreferredOrientations(Object? value) {
if (value is! List) {
return null;
}
if (value.isEmpty) {
return const <DeviceOrientation>[];
}
final orientations = <DeviceOrientation>[];
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<ShellBootstrapConfig?> loadDefaultConfig(
String assetPath,
) async {
try {
final content = await rootBundle.loadString(assetPath);
return _parseConfigString(content);
} catch (e) {
debugPrint('读取 WebShell 本地启动配置失败: $e');
return null;
}
}
///
static Future<ShellBootstrapConfig?> 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<ShellBootstrapConfig?> _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<String, dynamic>) {
final data = jsonMap['data'] is Map<String, dynamic>
? jsonMap['data'] as Map<String, dynamic>
: jsonMap;
return ShellBootstrapConfig.fromJson(data);
}
} catch (e) {
debugPrint('解析 WebShell 启动配置异常: $e');
}
return null;
}
}

View File

@ -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<String, dynamic> 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<void> 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<ShellUpgradeConfig?> _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<String, dynamic>) {
final data = jsonMap['data'] is Map<String, dynamic>
? jsonMap['data'] as Map<String, dynamic>
: 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,
],
);
}
}

View File

@ -16,6 +16,20 @@ class ShellCoreTestHooks {
/// URI
Uri get initialUri => _initialUri;
///
List<DeviceOrientation> get preferredOrientations =>
_shellPreferredOrientations;
///
ShellBootstrapConfig? parseBootstrapConfigString(String content) {
return ShellBootstrapConfigService._parseConfigString(content);
}
///
Future<ShellBootstrapConfig?> loadDefaultBootstrapConfig(String assetPath) {
return ShellBootstrapConfigService.loadDefaultConfig(assetPath);
}
///
void initializeEnvironment(ShellEnvironment environment) {
_env = environment;

View File

@ -57,6 +57,8 @@ class _WebShellPageState extends State<WebShellPage>
}
_hasTriggeredInitialLoad = true;
unawaited(_loadInitialPage());
//
unawaited(ShellUpgradeService.instance.checkVersion(context));
});
}
@ -339,7 +341,9 @@ class _WebShellPageState extends State<WebShellPage>
}
_recordWebViewEvent('页面加载完成:$url');
_cancelStartupWatchdog();
unawaited(_injectAppShellBridge(_controller, url));
if (!_hasMainFrameError) {
unawaited(_injectAppShellBridge(_controller, url));
}
if (!mounted) {
return;
}
@ -349,8 +353,9 @@ class _WebShellPageState extends State<WebShellPage>
_progress = 100;
_isLoadingPage = false;
_hasBootstrapped = true;
_hasMainFrameError = false;
_startupRetryCount = 0;
if (!_hasMainFrameError) {
_startupRetryCount = 0;
}
});
},
onHttpError: (error) {
@ -1038,7 +1043,10 @@ class _WebShellPageState extends State<WebShellPage>
),
child: Stack(
children: <Widget>[
_webViewWidget,
Visibility(
visible: !_hasMainFrameError,
child: _webViewWidget,
),
if (showProgressBar)
Positioned(
top: 0,
@ -1050,16 +1058,20 @@ class _WebShellPageState extends State<WebShellPage>
),
),
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<WebShellPage>
);
}
}
// coverage:ignore-end

View File

@ -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:

View File

@ -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<String> platformCalls;
late List<MethodCall> platformMethodCalls;
late Map<String, String> assetContents;
late bool cameraPermissionGranted;
setUp(() {
platformCalls = <String>[];
platformMethodCalls = <MethodCall>[];
assetContents = <String, String>{};
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>[
DeviceOrientation.landscapeLeft,
],
),
);
await tester.pump();
expect(
shellCoreTestHooks.preferredOrientations,
<DeviceOrientation>[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>[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>[
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<String>('fake-webview')),
findsNothing,
);
delegate.onPageFinished?.call(failedUrl);
await tester.pump();
expect(find.text('网络连接失败'), findsOneWidget);
expect(
find.byKey(const ValueKey<String>('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<void> setOnNavigationRequest(
NavigationRequestCallback onNavigationRequest,
) async {}
) async {
this.onNavigationRequest = onNavigationRequest;
}
@override
Future<void> setOnPageStarted(PageEventCallback onPageStarted) async {}
Future<void> setOnPageStarted(PageEventCallback onPageStarted) async {
this.onPageStarted = onPageStarted;
}
@override
Future<void> setOnPageFinished(PageEventCallback onPageFinished) async {}
Future<void> setOnPageFinished(PageEventCallback onPageFinished) async {
this.onPageFinished = onPageFinished;
}
@override
Future<void> setOnHttpError(HttpResponseErrorCallback onHttpError) async {}
Future<void> setOnHttpError(HttpResponseErrorCallback onHttpError) async {
this.onHttpError = onHttpError;
}
@override
Future<void> setOnProgress(ProgressCallback onProgress) async {}
Future<void> setOnProgress(ProgressCallback onProgress) async {
this.onProgress = onProgress;
}
@override
Future<void> setOnWebResourceError(
WebResourceErrorCallback onWebResourceError,
) async {}
) async {
this.onWebResourceError = onWebResourceError;
}
@override
Future<void> setOnUrlChange(UrlChangeCallback onUrlChange) async {}
Future<void> setOnUrlChange(UrlChangeCallback onUrlChange) async {
this.onUrlChange = onUrlChange;
}
}
class _FakePlatformWebViewController extends PlatformWebViewController {

View File

@ -0,0 +1,8 @@
{
"code": 200,
"msg": "success",
"data": {
"initialUrl": "http://192.168.2.57:8080/test_bridge.html",
"preferredOrientations": ["portraitUp", "portraitDown"]
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<String> _defaultPreferredOrientations = <String>[
'portraitUp',
'portraitDown',
];
Future<void> main(List<String> 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<void> main(List<String> args) async {
print(' cd $appDir && flutter build apk');
}
List<String> _readPreferredOrientations(Object? raw) {
if (raw is! YamlList && raw is! List) {
return _defaultPreferredOrientations;
}
final values = <String>[];
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<void> _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<String> 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', <String>[
'create',
'--org',
org,
'--project-name',
brand.replaceAll('-', '_'),
brand,
'--platforms',
'android',
'--android-language',
@ -126,7 +144,7 @@ Future<void> _createFlutterApp(
Future<void> _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', <String>[
'pub',
'add',
'web_shell_core',
@ -140,13 +158,40 @@ Future<void> _addCoreDependency(String appDir) async {
}
}
Future<void> _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<void> _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<void> _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<void> _generateDartEntrypoint(
String appDir,
String appName,
String appKey,
String accentColor,
String bgColor,
String textColor,
String mutedTextColor,
String? defaultUrl,
) async {
Future<void> _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 = <String>[
" 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<void> _generateBootstrapConfig({
required String appDir,
required String? defaultUrl,
required List<String> 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 = <String, Object?>{
if (defaultUrl != null && defaultUrl.trim().isNotEmpty)
'initialUrl': defaultUrl.trim(),
'preferredOrientations': preferredOrientations,
};
final content = const JsonEncoder.withIndent(' ').convert(data);
await file.writeAsString('$content\n');
}
Future<void> _generateBrandingAssets(
String brand,
String appDir,
@ -231,62 +297,50 @@ Future<void> _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', <String>[
'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', <String>[
'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', <String>[
'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', <String>[
'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', <String>[
'pub',
'remove',
'flutter_launcher_icons',
@ -353,38 +399,43 @@ flutter_native_splash:
Future<void> _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<void> _configureSigning(String appDir, YamlMap config) async {
print('\x1B[34m[信息] 正在配置应用签名...\x1B[0m');
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';
// 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/<brand>/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';
// 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');
}

BIN
tool/key.jks Normal file

Binary file not shown.