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", "type": "dart",
"flutterMode": "release" "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", "name": "test",
"cwd": "apps/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), textColor: const Color(0xFF1F2937),
mutedTextColor: const Color(0xFF6B7280), mutedTextColor: const Color(0xFF6B7280),
splashImage: const AssetImage('assets/branding/splash.png'), 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" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@ -73,6 +81,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.3.5+2" 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: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -105,6 +121,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "7.0.3" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -182,6 +214,11 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -200,6 +237,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: http:
dependency: transitive dependency: transitive
description: description:
@ -280,6 +333,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.2.2" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -312,6 +373,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.1.0" 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: matcher:
dependency: transitive dependency: transitive
description: description:
@ -344,6 +413,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.0.0" 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: nm:
dependency: transitive dependency: transitive
description: description:
@ -352,6 +429,30 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.5.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -360,6 +461,54 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.9.1" 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: permission_handler:
dependency: transitive dependency: transitive
description: description:
@ -416,6 +565,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "7.0.2" 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: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -424,6 +581,70 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.8" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -628,6 +849,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.0" 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: xml:
dependency: transitive dependency: transitive
description: description:
@ -636,6 +865,23 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.6.1" 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: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.0" flutter: ">=3.38.4"

View File

@ -55,6 +55,7 @@ dev_dependencies:
flutter: flutter:
assets: assets:
- assets/branding/ - assets/branding/
- assets/config/
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.

View File

@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) {
android { android {
namespace = "com.yuanxuan.test" namespace = "com.yuanxuan.test_shell"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@ -31,7 +31,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // 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. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion 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), textColor: const Color(0xFFFFFFFF),
mutedTextColor: const Color(0xFF9CA3AF), mutedTextColor: const Color(0xFF9CA3AF),
splashImage: const AssetImage('assets/branding/splash.png'), 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" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@ -73,6 +81,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.3.5+2" 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: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -105,6 +121,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "7.0.3" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -182,6 +214,11 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -200,6 +237,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: http:
dependency: transitive dependency: transitive
description: description:
@ -280,6 +333,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.2.2" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -312,6 +373,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.1.0" 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: matcher:
dependency: transitive dependency: transitive
description: description:
@ -344,6 +413,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.0.0" 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: nm:
dependency: transitive dependency: transitive
description: description:
@ -352,6 +429,30 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.5.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -360,6 +461,54 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.9.1" 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: permission_handler:
dependency: transitive dependency: transitive
description: description:
@ -416,6 +565,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "7.0.2" 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: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -424,6 +581,70 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.8" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -628,6 +849,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.0" 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: xml:
dependency: transitive dependency: transitive
description: description:
@ -636,6 +865,23 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.6.1" 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: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.0" flutter: ">=3.38.4"

View File

@ -55,6 +55,7 @@ dev_dependencies:
flutter: flutter:
assets: assets:
- assets/branding/ - assets/branding/
- assets/config/
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # 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), textColor: const Color(0xFF1F2937),
mutedTextColor: const Color(0xFF6B7280), mutedTextColor: const Color(0xFF6B7280),
splashImage: const AssetImage('assets/branding/splash.png'), 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" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@ -73,6 +81,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.3.5+2" 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: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -105,6 +121,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "7.0.3" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -182,6 +214,11 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -200,6 +237,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: http:
dependency: transitive dependency: transitive
description: description:
@ -280,6 +333,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.2.2" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -312,6 +373,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.1.0" 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: matcher:
dependency: transitive dependency: transitive
description: description:
@ -344,6 +413,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.0.0" 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: nm:
dependency: transitive dependency: transitive
description: description:
@ -352,6 +429,30 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.5.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -360,6 +461,54 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.9.1" 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: permission_handler:
dependency: transitive dependency: transitive
description: description:
@ -416,6 +565,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "7.0.2" 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: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -424,6 +581,70 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.8" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -628,6 +849,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.0" 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: xml:
dependency: transitive dependency: transitive
description: description:
@ -636,6 +865,23 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "6.6.1" 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: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.0" flutter: ">=3.38.4"

View File

@ -55,6 +55,7 @@ dev_dependencies:
flutter: flutter:
assets: assets:
- assets/branding/ - assets/branding/
- assets/config/
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.

View File

@ -2,6 +2,11 @@ app_name: "爱学蝶变"
application_id: "com.yuanxuan.aixue" application_id: "com.yuanxuan.aixue"
app_key: "aixue_prod" app_key: "aixue_prod"
default_url: "http://xszy.lzzneng.com/login.html" default_url: "http://xszy.lzzneng.com/login.html"
bootstrap_config_url: ""
upgrade_config_url: ""
preferred_orientations:
- "portraitUp"
- "portraitDown"
theme: theme:
accent_color: "0xFFF97316" accent_color: "0xFFF97316"
bg_color: "0xFFFFFFFF" bg_color: "0xFFFFFFFF"

View File

@ -2,6 +2,11 @@ app_name: "测试壳工程"
application_id: "com.yuanxuan.test_shell" application_id: "com.yuanxuan.test_shell"
app_key: "test_shell" app_key: "test_shell"
default_url: "http://192.168.2.57:8080/test_bridge.html" default_url: "http://192.168.2.57:8080/test_bridge.html"
bootstrap_config_url: ""
upgrade_config_url: ""
preferred_orientations:
- "portraitUp"
- "portraitDown"
theme: theme:
accent_color: "0xFF10B981" accent_color: "0xFF10B981"
bg_color: "0xFF1F2937" bg_color: "0xFF1F2937"

View File

@ -2,6 +2,11 @@ app_name: "云校嗨学"
application_id: "com.yuanxuan.yunxiao" application_id: "com.yuanxuan.yunxiao"
app_key: "yunxiao_prod" 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" 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: theme:
accent_color: "0xFF4F46E5" accent_color: "0xFF4F46E5"
bg_color: "0xFFFFFFFF" bg_color: "0xFFFFFFFF"

View File

@ -14,10 +14,26 @@ Android 平板专用的 H5 壳核心库。所有品牌应用共享此库,只
| **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 | | **权限服务** | camera · microphone · location · photos · videos · storage 统一映射 |
| **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 | | **导航服务** | URL scheme 白名单路由,非 WebView 协议自动跳转外部应用 |
| **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 | | **壳层 UI** | 启动加载动画 · 错误恢复页 · 进度条 · 不支持平台兜底页 |
| **启动配置** | 支持本地默认启动配置文件 + 远程启动配置缓存 |
| **版本检查** | 升级配置独立请求,不再与启动配置共用一个 JSON |
## 使用方式 ## 使用方式
### 1. 准备本地默认启动配置文件
`assets/config/bootstrap.json`
```json
{
"initialUrl": "https://example.com/login",
"preferredOrientations": ["portraitUp", "portraitDown"]
}
```
### 2. 在应用入口传入环境配置
```dart ```dart
import 'package:flutter/material.dart';
import 'package:web_shell_core/web_shell_core.dart'; import 'package:web_shell_core/web_shell_core.dart';
void main() { void main() {
@ -29,12 +45,40 @@ void main() {
backgroundColor: Color(0xFFFFFFFF), backgroundColor: Color(0xFFFFFFFF),
textColor: Color(0xFF1F2937), textColor: Color(0xFF1F2937),
mutedTextColor: Color(0xFF6B7280), 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占位 │ ├── bridge_actions.dart # Action handler占位
│ └── legacy_camera_compat.dart # 旧相机 JS 兼容层 │ └── legacy_camera_compat.dart # 旧相机 JS 兼容层
├── services/ ├── services/
│ ├── config_service.dart # 启动配置读取与缓存
│ ├── media_service.dart # 相机/图库/文件 + 序列化 │ ├── media_service.dart # 相机/图库/文件 + 序列化
│ ├── permission_service.dart # 权限类型映射 │ ├── permission_service.dart # 权限类型映射
│ └── navigation_service.dart # URL 路由 + 外链跳转 │ ├── navigation_service.dart # URL 路由 + 外链跳转
│ └── upgrade_service.dart # 升级配置请求与弹窗转换
├── ui/ ├── ui/
│ ├── shell_app.dart # MaterialApp 入口 │ ├── shell_app.dart # MaterialApp 入口
│ ├── shell_page.dart # WebView 主页面 │ ├── shell_page.dart # WebView 主页面
@ -74,10 +120,10 @@ cd packages/web_shell_core
flutter test flutter test
``` ```
当前 **67 个测试用例**覆盖: 当前测试覆盖:
- 平台检测 · URL 解析 · 兼容性策略 · 错误映射 - 平台检测 · URL 解析 · 兼容性策略 · 错误映射
- Bridge 注入/响应/异常处理 · 媒体序列化 · 权限映射 - Bridge 注入/响应/异常处理 · 媒体序列化 · 权限映射
- 导航路由 · 所有独立 UI 组件 - 导航路由 · 启动配置解析 · 方向配置 · 组件渲染
## 平台约束 ## 平台约束

View File

@ -11,11 +11,17 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.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'; 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/bridge/legacy_camera_compat.dart';
// //
part 'src/services/config_service.dart';
part 'src/services/media_service.dart'; part 'src/services/media_service.dart';
part 'src/services/permission_service.dart';
part 'src/services/navigation_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_app.dart';
part 'src/ui/shell_page.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/ui/unsupported_platform_page.dart';
part 'src/testing/test_hooks.dart';
// //
late ShellEnvironment _env; late ShellEnvironment _env;
@ -51,23 +59,79 @@ Color get _shellAccentColor => _env.accentColor;
Color get _shellBackgroundColor => _env.backgroundColor; Color get _shellBackgroundColor => _env.backgroundColor;
Color get _shellTextColor => _env.textColor; Color get _shellTextColor => _env.textColor;
Color get _shellMutedTextColor => _env.mutedTextColor; Color get _shellMutedTextColor => _env.mutedTextColor;
List<DeviceOrientation> get _shellPreferredOrientations =>
_env.preferredOrientations ??
const <DeviceOrientation>[
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
];
// //
/// ///
/// `main.dart` [ShellEnvironment] /// `main.dart` [ShellEnvironment]
Future<void> runShellApp(ShellEnvironment environment) async { Future<void> runShellApp(ShellEnvironment environment) async {
_env = environment;
_initializeUrls();
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations(const [ _env = environment;
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown, await _applyBootstrapConfig();
]); ShellUpgradeService.instance.setupConfigUrl(_env.upgradeConfigUrl);
_initializeUrls();
await SystemChrome.setPreferredOrientations(_shellPreferredOrientations);
await _enterImmersiveMode(); await _enterImmersiveMode();
runApp(const ShellApp()); 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 { Future<void> _enterImmersiveMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(

View File

@ -13,29 +13,77 @@ class ShellEnvironment {
required this.mutedTextColor, required this.mutedTextColor,
this.splashImage, this.splashImage,
this.initialUrl, this.initialUrl,
this.bootstrapConfigAsset,
this.bootstrapConfigUrl,
this.upgradeConfigUrl,
this.preferredOrientations,
}); });
/// "全学通""点智学" /// "全学通""点智学"
final String appName; final String appName;
/// ///
final String appKey; final String appKey;
/// ///
final Color accentColor; final Color accentColor;
/// ///
final Color backgroundColor; final Color backgroundColor;
/// ///
final Color textColor; final Color textColor;
/// ///
final Color mutedTextColor; final Color mutedTextColor;
/// 使 /// 使
final ImageProvider? splashImage; final ImageProvider? splashImage;
/// 使 ///
final String? initialUrl; 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.connect ||
WebResourceErrorType.io => '没有成功连接到服务器,请检查网络后重试。', WebResourceErrorType.io => '没有成功连接到服务器,请检查网络后重试。',
WebResourceErrorType.failedSslHandshake => '当前站点证书校验失败,请稍后再试。', WebResourceErrorType.failedSslHandshake => '当前站点证书校验失败,请稍后再试。',
_ => _ => _parseRawErrorDescription(error.description),
error.description.trim().isEmpty
? '请稍后重新加载页面。'
: error.description.trim(),
}; };
} }
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
Uri get initialUri => _initialUri; 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) { void initializeEnvironment(ShellEnvironment environment) {
_env = environment; _env = environment;

View File

@ -57,6 +57,8 @@ class _WebShellPageState extends State<WebShellPage>
} }
_hasTriggeredInitialLoad = true; _hasTriggeredInitialLoad = true;
unawaited(_loadInitialPage()); unawaited(_loadInitialPage());
//
unawaited(ShellUpgradeService.instance.checkVersion(context));
}); });
} }
@ -339,7 +341,9 @@ class _WebShellPageState extends State<WebShellPage>
} }
_recordWebViewEvent('页面加载完成:$url'); _recordWebViewEvent('页面加载完成:$url');
_cancelStartupWatchdog(); _cancelStartupWatchdog();
unawaited(_injectAppShellBridge(_controller, url)); if (!_hasMainFrameError) {
unawaited(_injectAppShellBridge(_controller, url));
}
if (!mounted) { if (!mounted) {
return; return;
} }
@ -349,8 +353,9 @@ class _WebShellPageState extends State<WebShellPage>
_progress = 100; _progress = 100;
_isLoadingPage = false; _isLoadingPage = false;
_hasBootstrapped = true; _hasBootstrapped = true;
_hasMainFrameError = false; if (!_hasMainFrameError) {
_startupRetryCount = 0; _startupRetryCount = 0;
}
}); });
}, },
onHttpError: (error) { onHttpError: (error) {
@ -1038,7 +1043,10 @@ class _WebShellPageState extends State<WebShellPage>
), ),
child: Stack( child: Stack(
children: <Widget>[ children: <Widget>[
_webViewWidget, Visibility(
visible: !_hasMainFrameError,
child: _webViewWidget,
),
if (showProgressBar) if (showProgressBar)
Positioned( Positioned(
top: 0, top: 0,
@ -1050,16 +1058,20 @@ class _WebShellPageState extends State<WebShellPage>
), ),
), ),
if (showLaunchOverlay) if (showLaunchOverlay)
LaunchOverlay( Positioned.fill(
progress: _progress, child: LaunchOverlay(
hasMeasuredProgress: _hasMeasuredProgress, progress: _progress,
hasMeasuredProgress: _hasMeasuredProgress,
),
), ),
if (_hasMainFrameError) if (_hasMainFrameError)
ErrorOverlay( Positioned.fill(
title: _errorTitle, child: ErrorOverlay(
message: _errorMessage, title: _errorTitle,
currentUrl: _currentUrl, message: _errorMessage,
onRetry: _reloadPage, currentUrl: _currentUrl,
onRetry: _reloadPage,
),
), ),
], ],
), ),
@ -1068,4 +1080,5 @@ class _WebShellPageState extends State<WebShellPage>
); );
} }
} }
// coverage:ignore-end // coverage:ignore-end

View File

@ -1,6 +1,7 @@
name: web_shell_core name: web_shell_core
description: "Android 平板专用 H5 壳核心库,提供 WebView 引擎、JS Bridge 和宿主服务。" description: "Android 平板专用 H5 壳核心库,提供 WebView 引擎、JS Bridge 和宿主服务。"
version: 0.0.1 version: 0.0.1
publish_to: none
homepage: homepage:
environment: environment:
@ -13,12 +14,18 @@ dependencies:
file_picker: ^10.3.10 file_picker: ^10.3.10
flutter: flutter:
sdk: flutter sdk: flutter
http: ^1.2.0
image_picker: ^1.2.1 image_picker: ^1.2.1
permission_handler: ^12.0.1 permission_handler: ^12.0.1
plugin_platform_interface: ^2.0.2 plugin_platform_interface: ^2.0.2
shared_preferences: ^2.3.2
url_launcher: ^6.3.2 url_launcher: ^6.3.2
webview_flutter: ^4.13.1 webview_flutter: ^4.13.1
webview_flutter_android: ^4.10.13 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: dev_dependencies:
flutter_test: flutter_test:

View File

@ -1,4 +1,6 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -35,10 +37,14 @@ void main() {
late _FakeImagePickerPlatform fakeImagePickerPlatform; late _FakeImagePickerPlatform fakeImagePickerPlatform;
late _FakeUrlLauncherPlatform fakeUrlLauncherPlatform; late _FakeUrlLauncherPlatform fakeUrlLauncherPlatform;
late List<String> platformCalls; late List<String> platformCalls;
late List<MethodCall> platformMethodCalls;
late Map<String, String> assetContents;
late bool cameraPermissionGranted; late bool cameraPermissionGranted;
setUp(() { setUp(() {
platformCalls = <String>[]; platformCalls = <String>[];
platformMethodCalls = <MethodCall>[];
assetContents = <String, String>{};
cameraPermissionGranted = true; cameraPermissionGranted = true;
fakeWebViewPlatform = _FakeWebViewPlatform(); fakeWebViewPlatform = _FakeWebViewPlatform();
fakeImagePickerPlatform = _FakeImagePickerPlatform(); fakeImagePickerPlatform = _FakeImagePickerPlatform();
@ -52,9 +58,24 @@ void main() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_platformChannel, (call) async { .setMockMethodCallHandler(_platformChannel, (call) async {
platformCalls.add(call.method); platformCalls.add(call.method);
platformMethodCalls.add(call);
return null; 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 TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_permissionChannel, (call) async { .setMockMethodCallHandler(_permissionChannel, (call) async {
if (call.method != 'requestPermissions') { if (call.method != 'requestPermissions') {
@ -75,6 +96,8 @@ void main() {
.setMockMethodCallHandler(_platformChannel, null); .setMockMethodCallHandler(_platformChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_permissionChannel, null); .setMockMethodCallHandler(_permissionChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMessageHandler('flutter/assets', null);
}); });
group('运行时与平台', () { 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 { testWidgets('ShellApp 在非 Android 下展示兜底页', (tester) async {
try { try {
debugDefaultTargetPlatformOverride = TargetPlatform.windows; debugDefaultTargetPlatformOverride = TargetPlatform.windows;
@ -664,8 +752,9 @@ void main() {
expect(indicator.value, 0.5); expect(indicator.value, 0.5);
}); });
testWidgets('LaunchOverlay 始终显示不定态 CircularProgressIndicator', testWidgets('LaunchOverlay 始终显示不定态 CircularProgressIndicator', (
(tester) async { tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
const MaterialApp( const MaterialApp(
home: LaunchOverlay(progress: 0, hasMeasuredProgress: false), home: LaunchOverlay(progress: 0, hasMeasuredProgress: false),
@ -1036,6 +1125,26 @@ void main() {
expect(_testEnvironment.textColor, const Color(0xFF1F2937)); expect(_testEnvironment.textColor, const Color(0xFF1F2937));
expect(_testEnvironment.mutedTextColor, const Color(0xFF6B7280)); expect(_testEnvironment.mutedTextColor, const Color(0xFF6B7280));
expect(_testEnvironment.initialUrl, 'example.com/login'); 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 可选字段缺省时使用默认地址', () { test('initialUrl 可选字段缺省时使用默认地址', () {
@ -1533,6 +1642,52 @@ void main() {
// MaterialApp FadeTransition Text // MaterialApp FadeTransition Text
expect(find.text(''), findsNothing); 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 { class _FakePlatformNavigationDelegate extends PlatformNavigationDelegate {
_FakePlatformNavigationDelegate(super.params) : super.implementation(); _FakePlatformNavigationDelegate(super.params) : super.implementation();
NavigationRequestCallback? onNavigationRequest;
PageEventCallback? onPageStarted;
PageEventCallback? onPageFinished;
HttpResponseErrorCallback? onHttpError;
ProgressCallback? onProgress;
WebResourceErrorCallback? onWebResourceError;
UrlChangeCallback? onUrlChange;
@override @override
Future<void> setOnNavigationRequest( Future<void> setOnNavigationRequest(
NavigationRequestCallback onNavigationRequest, NavigationRequestCallback onNavigationRequest,
) async {} ) async {
this.onNavigationRequest = onNavigationRequest;
}
@override @override
Future<void> setOnPageStarted(PageEventCallback onPageStarted) async {} Future<void> setOnPageStarted(PageEventCallback onPageStarted) async {
this.onPageStarted = onPageStarted;
}
@override @override
Future<void> setOnPageFinished(PageEventCallback onPageFinished) async {} Future<void> setOnPageFinished(PageEventCallback onPageFinished) async {
this.onPageFinished = onPageFinished;
}
@override @override
Future<void> setOnHttpError(HttpResponseErrorCallback onHttpError) async {} Future<void> setOnHttpError(HttpResponseErrorCallback onHttpError) async {
this.onHttpError = onHttpError;
}
@override @override
Future<void> setOnProgress(ProgressCallback onProgress) async {} Future<void> setOnProgress(ProgressCallback onProgress) async {
this.onProgress = onProgress;
}
@override @override
Future<void> setOnWebResourceError( Future<void> setOnWebResourceError(
WebResourceErrorCallback onWebResourceError, WebResourceErrorCallback onWebResourceError,
) async {} ) async {
this.onWebResourceError = onWebResourceError;
}
@override @override
Future<void> setOnUrlChange(UrlChangeCallback onUrlChange) async {} Future<void> setOnUrlChange(UrlChangeCallback onUrlChange) async {
this.onUrlChange = onUrlChange;
}
} }
class _FakePlatformWebViewController extends PlatformWebViewController { 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 'dart:io';
import 'package:yaml/yaml.dart'; import 'package:yaml/yaml.dart';
/// 线
final RegExp _validBrandName = RegExp(r'^[a-z][a-z0-9_]*$'); final RegExp _validBrandName = RegExp(r'^[a-z][a-z0-9_]*$');
const List<String> _defaultPreferredOrientations = <String>[
'portraitUp',
'portraitDown',
];
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
if (args.isEmpty) { if (args.isEmpty) {
print('\x1B[31m用法dart run tool/generate_app.dart <品牌名>\x1B[0m'); 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); exit(1);
} }
final String brand = args.first; final brand = args.first;
if (!_validBrandName.hasMatch(brand)) { if (!_validBrandName.hasMatch(brand)) {
print( print('\x1B[31m[错误] 品牌名 "$brand" 格式无效,仅允许小写字母、数字和下划线。\x1B[0m');
'\x1B[31m[错误] 品牌名 "$brand" 格式无效,'
'仅允许小写字母、数字和下划线(且必须以字母开头)。\x1B[0m',
);
exit(1); exit(1);
} }
final File configFile = File('flavors/$brand.yaml'); final configFile = File('flavors/$brand.yaml');
if (!configFile.existsSync()) { if (!configFile.existsSync()) {
print('\x1B[31m[错误] 未找到配置文件:${configFile.path}\x1B[0m'); print('\x1B[31m[错误] 未找到配置文件:${configFile.path}\x1B[0m');
exit(1); exit(1);
} }
print('\x1B[34m[信息] 正在为品牌生成应用:$brand...\x1B[0m'); print('\x1B[34m[信息] 正在为品牌生成应用:$brand...\x1B[0m');
final yamlString = await configFile.readAsString();
final config = loadYaml(yamlString) as YamlMap;
// 1. YAML final appName = config['app_name'] as String;
final String yamlString = await configFile.readAsString(); final applicationId = config['application_id'] as String;
final YamlMap config = loadYaml(yamlString) as YamlMap; final appKey = config['app_key'] as String;
final defaultUrl = config['default_url'] as String?;
final String appName = config['app_name'] as String; final bootstrapConfigUrl = config['bootstrap_config_url'] as String?;
final String applicationId = config['application_id'] as String; final upgradeConfigUrl = config['upgrade_config_url'] as String?;
final String appKey = config['app_key'] as String; final preferredOrientations = _readPreferredOrientations(
config['preferred_orientations'],
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,
); );
// 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); await _generateBrandingAssets(brand, appDir, config);
// 8. pubspec.yaml Flutter assets
await _registerFlutterAssets(appDir); await _registerFlutterAssets(appDir);
// 9. (KeyStore)
await _configureSigning(appDir, config); await _configureSigning(appDir, config);
print('\x1B[32m✔ 应用 $brand 已生成到 $appDir\x1B[0m'); print('\x1B[32m✔ 应用 $brand 已生成到 $appDir\x1B[0m');
@ -88,29 +80,55 @@ Future<void> main(List<String> args) async {
print(' cd $appDir && flutter build apk'); 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( Future<void> _createFlutterApp(
String brand, String brand,
String appDir, String appDir,
String applicationId, String applicationId,
) async { ) async {
print('\x1B[34m[信息] 正在执行 flutter create...\x1B[0m'); print('\x1B[34m[信息] 正在执行 flutter create...\x1B[0m');
final Directory dir = Directory(appDir); final dir = Directory(appDir);
if (dir.existsSync()) { if (dir.existsSync()) {
print('\x1B[33m[警告] 目录 $appDir 已存在,正在清理...\x1B[0m'); print('\x1B[33m[警告] 目录 $appDir 已存在,正在清理...\x1B[0m');
dir.deleteSync(recursive: true); dir.deleteSync(recursive: true);
} }
// final segments = applicationId.split('.');
// com.yuanxuan.quanxue -> org: com.yuanxuan final org = segments.sublist(0, segments.length - 1).join('.');
final List<String> segments = applicationId.split('.'); final result = await Process.run('flutter', <String>[
final String org = segments.sublist(0, segments.length - 1).join('.');
final ProcessResult result = await Process.run('flutter', [
'create', 'create',
'--org', '--org',
org, org,
'--project-name', '--project-name',
brand.replaceAll('-', '_'), brand,
'--platforms', '--platforms',
'android', 'android',
'--android-language', '--android-language',
@ -126,7 +144,7 @@ Future<void> _createFlutterApp(
Future<void> _addCoreDependency(String appDir) async { Future<void> _addCoreDependency(String appDir) async {
print('\x1B[34m[信息] 正在添加 web_shell_core 依赖...\x1B[0m'); print('\x1B[34m[信息] 正在添加 web_shell_core 依赖...\x1B[0m');
final ProcessResult result = await Process.run('flutter', [ final result = await Process.run('flutter', <String>[
'pub', 'pub',
'add', 'add',
'web_shell_core', '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 { Future<void> _overwriteMainActivity(String appDir, String applicationId) async {
print('\x1B[34m[信息] 正在注入 CoreShellActivity 继承关系...\x1B[0m'); print('\x1B[34m[信息] 正在注入 CoreShellActivity 继承关系...\x1B[0m');
final String mainActivityPath = final mainActivityPath =
"$appDir/android/app/src/main/java/${applicationId.replaceAll('.', '/')}/MainActivity.java"; '$appDir/android/app/src/main/java/${applicationId.replaceAll('.', '/')}/MainActivity.java';
final javaContent =
// Java
final String javaContent =
''' '''
package $applicationId; package $applicationId;
@ -156,42 +201,45 @@ public class MainActivity extends CoreShellActivity {
} }
'''; ''';
await File(mainActivityPath).create(recursive: true); final mainActivityFile = File(mainActivityPath);
await File(mainActivityPath).writeAsString(javaContent); await mainActivityFile.parent.create(recursive: true);
await mainActivityFile.writeAsString(javaContent);
} }
Future<void> _overwriteManifestLabel(String appDir, String appName) async { Future<void> _overwriteManifestLabel(String appDir, String appName) async {
print('\x1B[34m[信息] 正在更新 AndroidManifest.xml 应用名...\x1B[0m'); print('\x1B[34m[信息] 正在更新 AndroidManifest.xml 应用名...\x1B[0m');
final File manifestFile = File( final manifestFile = File('$appDir/android/app/src/main/AndroidManifest.xml');
'$appDir/android/app/src/main/AndroidManifest.xml', var content = await manifestFile.readAsString();
);
String content = await manifestFile.readAsString();
// 使 android:label="..."
content = content.replaceAll( content = content.replaceAll(
RegExp(r'android:label="[^"]*"'), RegExp(r'android:label="[^"]*"'),
'android:label="$appName"', 'android:label="$appName"',
); );
await manifestFile.writeAsString(content); await manifestFile.writeAsString(content);
} }
Future<void> _generateDartEntrypoint( Future<void> _generateDartEntrypoint({
String appDir, required String appDir,
String appName, required String appName,
String appKey, required String appKey,
String accentColor, required String accentColor,
String bgColor, required String backgroundColor,
String textColor, required String textColor,
String mutedTextColor, required String mutedTextColor,
String? defaultUrl, String? bootstrapConfigUrl,
) async { String? upgradeConfigUrl,
}) async {
print('\x1B[34m[信息] 正在生成 lib/main.dart...\x1B[0m'); 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:flutter/material.dart';
import 'package:web_shell_core/web_shell_core.dart'; import 'package:web_shell_core/web_shell_core.dart';
@ -202,11 +250,11 @@ void main() {
appName: '$appName', appName: '$appName',
appKey: '$appKey', appKey: '$appKey',
accentColor: const Color($accentColor), accentColor: const Color($accentColor),
backgroundColor: const Color($bgColor), backgroundColor: const Color($backgroundColor),
textColor: const Color($textColor), textColor: const Color($textColor),
mutedTextColor: const Color($mutedTextColor), mutedTextColor: const Color($mutedTextColor),
splashImage: const AssetImage('assets/branding/splash.png'), splashImage: const AssetImage('assets/branding/splash.png'),
$urlParam $extraLines
), ),
); );
} }
@ -214,13 +262,31 @@ void main() {
await mainFile.writeAsString(dartContent); await mainFile.writeAsString(dartContent);
// flutter create MyApp final defaultTestFile = File('$appDir/test/widget_test.dart');
final File defaultTestFile = File('$appDir/test/widget_test.dart');
if (defaultTestFile.existsSync()) { if (defaultTestFile.existsSync()) {
await defaultTestFile.delete(); 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( Future<void> _generateBrandingAssets(
String brand, String brand,
String appDir, String appDir,
@ -231,62 +297,50 @@ Future<void> _generateBrandingAssets(
print('\x1B[33m[警告] 配置中未找到 branding 段,跳过资源生成。\x1B[0m'); print('\x1B[33m[警告] 配置中未找到 branding 段,跳过资源生成。\x1B[0m');
return; return;
} }
final YamlMap branding = config['branding'] as YamlMap;
// 1. final branding = config['branding'] as YamlMap;
final Directory brandSourceDir = Directory('flavors/$brand'); final brandSourceDir = Directory('flavors/$brand');
final Directory brandTargetDir = Directory('$appDir/assets/branding'); final brandTargetDir = Directory('$appDir/assets/branding');
if (!brandSourceDir.existsSync()) { if (!brandSourceDir.existsSync()) {
print('\x1B[31m[错误] 品牌资源目录不存在:${brandSourceDir.path}\x1B[0m'); print('\x1B[31m[错误] 品牌资源目录不存在:${brandSourceDir.path}\x1B[0m');
print(
'\x1B[33m请在 flavors/$brand/ 目录下放置 icon.png、'
'icon_foreground.png、splash.png 等资源文件。\x1B[0m',
);
exit(1); exit(1);
} }
await brandTargetDir.create(recursive: true); await brandTargetDir.create(recursive: true);
for (final entity in brandSourceDir.listSync(recursive: true)) { for (final entity in brandSourceDir.listSync(recursive: true)) {
if (entity is File) { if (entity is! File) {
final relativePath = entity.path.substring( continue;
brandSourceDir.path.length + 1,
);
final targetFile = File('${brandTargetDir.path}/$relativePath');
await targetFile.parent.create(recursive: true);
await entity.copy(targetFile.path);
} }
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'); print('\x1B[32m✔ 品牌资源已复制到 ${brandTargetDir.path}\x1B[0m');
// 2. dev
print('\x1B[34m[信息] 正在添加资源生成器依赖...\x1B[0m'); print('\x1B[34m[信息] 正在添加资源生成器依赖...\x1B[0m');
final ProcessResult addDevDepsResult = await Process.run('flutter', [ final addDevDepsResult = await Process.run('flutter', <String>[
'pub', 'pub',
'add', 'add',
'--dev', '--dev',
'flutter_launcher_icons', 'flutter_launcher_icons',
'flutter_native_splash', 'flutter_native_splash',
], workingDirectory: appDir); ], workingDirectory: appDir);
if (addDevDepsResult.exitCode != 0) { if (addDevDepsResult.exitCode != 0) {
print( print('\x1B[31m[错误] 添加资源生成器依赖失败:\n${addDevDepsResult.stderr}\x1B[0m');
'\x1B[31m[错误] 添加资源生成器依赖失败:'
'\n${addDevDepsResult.stderr}\x1B[0m',
);
exit(1); exit(1);
} }
// 3. await Process.run('flutter', <String>[
await Process.run('flutter', ['pub', 'get'], workingDirectory: appDir); 'pub',
'get',
], workingDirectory: appDir);
// 4. flutter_launcher_icons final iconPath = 'assets/branding/${branding['icon']}';
// appDir final iconForeground = 'assets/branding/${branding['icon_foreground']}';
final String iconPath = 'assets/branding/${branding['icon']}'; final iconBackground = branding['icon_background'] as String;
final String iconForeground = final iconsYaml =
'assets/branding/${branding['icon_foreground']}';
final String iconBackground = branding['icon_background'] as String;
final String iconsYaml =
''' '''
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
@ -296,11 +350,9 @@ flutter_launcher_icons:
'''; ''';
await File('$appDir/flutter_launcher_icons.yaml').writeAsString(iconsYaml); await File('$appDir/flutter_launcher_icons.yaml').writeAsString(iconsYaml);
// 4. flutter_native_splash final splashPath = 'assets/branding/${branding['splash']}';
final String splashPath = 'assets/branding/${branding['splash']}'; final splashColor = branding['splash_color'] as String;
final String splashColor = branding['splash_color'] as String; final splashYaml =
final String splashYaml =
''' '''
flutter_native_splash: flutter_native_splash:
color: "$splashColor" color: "$splashColor"
@ -311,39 +363,33 @@ flutter_native_splash:
'''; ''';
await File('$appDir/flutter_native_splash.yaml').writeAsString(splashYaml); await File('$appDir/flutter_native_splash.yaml').writeAsString(splashYaml);
// 5.
print('\x1B[34m[信息] 正在生成应用图标...\x1B[0m'); print('\x1B[34m[信息] 正在生成应用图标...\x1B[0m');
final ProcessResult iconsResult = await Process.run('dart', [ final iconsResult = await Process.run('dart', <String>[
'run', 'run',
'flutter_launcher_icons', 'flutter_launcher_icons',
'-f', '-f',
'flutter_launcher_icons.yaml', 'flutter_launcher_icons.yaml',
], workingDirectory: appDir); ], workingDirectory: appDir);
if (iconsResult.exitCode != 0) { if (iconsResult.exitCode != 0) {
print('\x1B[31m[错误] 图标生成失败:\n${iconsResult.stderr}\x1B[0m'); print('\x1B[31m[错误] 图标生成失败:\n${iconsResult.stderr}\x1B[0m');
print('\x1B[33mstdout:\n${iconsResult.stdout}\x1B[0m'); print('\x1B[33mstdout:\n${iconsResult.stdout}\x1B[0m');
exit(1); exit(1);
} }
print('\x1B[32m✔ 应用图标已生成。\x1B[0m');
print('\x1B[34m[信息] 正在生成启动页...\x1B[0m'); print('\x1B[34m[信息] 正在生成启动页...\x1B[0m');
final ProcessResult splashResult = await Process.run('dart', [ final splashResult = await Process.run('dart', <String>[
'run', 'run',
'flutter_native_splash:create', 'flutter_native_splash:create',
'--path=flutter_native_splash.yaml', '--path=flutter_native_splash.yaml',
], workingDirectory: appDir); ], workingDirectory: appDir);
if (splashResult.exitCode != 0) { if (splashResult.exitCode != 0) {
print('\x1B[31m[错误] 启动页生成失败:\n${splashResult.stderr}\x1B[0m'); print('\x1B[31m[错误] 启动页生成失败:\n${splashResult.stderr}\x1B[0m');
print('\x1B[33mstdout:\n${splashResult.stdout}\x1B[0m'); print('\x1B[33mstdout:\n${splashResult.stdout}\x1B[0m');
exit(1); exit(1);
} }
print('\x1B[32m✔ 启动页已生成。\x1B[0m');
// 6. release
print('\x1B[34m[信息] 正在移除资源生成器依赖...\x1B[0m'); print('\x1B[34m[信息] 正在移除资源生成器依赖...\x1B[0m');
await Process.run('flutter', [ await Process.run('flutter', <String>[
'pub', 'pub',
'remove', 'remove',
'flutter_launcher_icons', 'flutter_launcher_icons',
@ -353,38 +399,43 @@ flutter_native_splash:
Future<void> _registerFlutterAssets(String appDir) async { Future<void> _registerFlutterAssets(String appDir) async {
print('\x1B[34m[信息] 正在注册 Flutter assets...\x1B[0m'); print('\x1B[34m[信息] 正在注册 Flutter assets...\x1B[0m');
final File pubspecFile = File('$appDir/pubspec.yaml'); final pubspecFile = File('$appDir/pubspec.yaml');
String content = await pubspecFile.readAsString(); var content = await pubspecFile.readAsString();
// assets: if (!RegExp(r'^\s+assets:\s*$', multiLine: true).hasMatch(content)) {
final bool hasAssets = RegExp(r'^\s+assets:', multiLine: true).hasMatch(content);
if (!hasAssets) {
// flutter: flutter: (SDK )
content = content.replaceFirst( content = content.replaceFirst(
RegExp(r'^flutter:\s*\n', multiLine: true), 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 { Future<void> _configureSigning(String appDir, YamlMap config) async {
print('\x1B[34m[信息] 正在配置应用签名...\x1B[0m'); print('\x1B[34m[信息] 正在配置应用签名...\x1B[0m');
final signing = config['signing'] as YamlMap?;
// key.jks final keyAlias = signing?['key_alias'] as String? ?? 'my-key-alias';
final YamlMap? signing = config['signing'] as YamlMap?; final keyPassword = signing?['key_password'] as String? ?? '123456';
final String keyAlias = signing?['key_alias'] as String? ?? 'my-key-alias'; final storePassword = signing?['store_password'] as String? ?? '123456';
final String keyPassword = signing?['key_password'] as String? ?? '123456'; final storeFile =
final String storePassword = signing?['store_password'] as String? ?? '123456'; signing?['store_file'] as String? ?? '../../../../tool/key.jks';
// 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 keyPropsContent =
final String keyPropsContent = ''' '''
storePassword=$storePassword storePassword=$storePassword
keyPassword=$keyPassword keyPassword=$keyPassword
keyAlias=$keyAlias keyAlias=$keyAlias
@ -392,16 +443,13 @@ storeFile=$storeFile
'''; ''';
await File('$appDir/android/key.properties').writeAsString(keyPropsContent); await File('$appDir/android/key.properties').writeAsString(keyPropsContent);
// 2. build.gradle.kts final gradleFile = File('$appDir/android/app/build.gradle.kts');
final File gradleFile = File('$appDir/android/app/build.gradle.kts');
if (!gradleFile.existsSync()) { if (!gradleFile.existsSync()) {
print('\x1B[33m[警告] 未找到 build.gradle.kts跳过注入签名配置。\x1B[0m'); print('\x1B[33m[警告] 未找到 build.gradle.kts跳过注入签名配置。\x1B[0m');
return; return;
} }
String content = await gradleFile.readAsString();
// Properties var content = await gradleFile.readAsString();
if (!content.contains('val keystoreProperties = Properties()')) { if (!content.contains('val keystoreProperties = Properties()')) {
content = content.replaceFirst('android {', ''' content = content.replaceFirst('android {', '''
import java.util.Properties import java.util.Properties
@ -417,9 +465,8 @@ android {
'''); ''');
} }
// signingConfigs buildTypes.release if (!content.contains('signingConfigs {\n create("release") {')) {
if (!content.contains('signingConfigs {\\n create("release") {')) { const newBuildTypes = '''
final String newBuildTypes = '''
signingConfigs { signingConfigs {
create("release") { create("release") {
keyAlias = keystoreProperties["keyAlias"] as String? keyAlias = keystoreProperties["keyAlias"] as String?
@ -435,12 +482,11 @@ android {
} }
} }
'''; ''';
final buildTypeRegex = RegExp(
// buildTypes { release { ... } } r'\s*buildTypes\s*\{[\s\S]*?signingConfigs\.getByName\("debug"\)\s*\}\s*\}',
final RegExp buildTypeRegex = RegExp(r'\s*buildTypes\s*\{[\s\S]*?signingConfigs\.getByName\("debug"\)\s*\}\s*\}'); );
content = content.replaceFirst(buildTypeRegex, '\n$newBuildTypes'); content = content.replaceFirst(buildTypeRegex, '\n$newBuildTypes');
} }
await gradleFile.writeAsString(content); await gradleFile.writeAsString(content);
print('\x1B[32m✔ 签名配置注入完成。\x1B[0m');
} }

BIN
tool/key.jks Normal file

Binary file not shown.