diff --git a/README.md b/README.md index 2e7a572..9f8ff5d 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,11 @@ # im_webview_app -Flutter WebView 套壳 App,启动后会请求 `/client_config/query` 获取 H5 线路配置,并加载第一条可用线路。 +Flutter WebView 套壳 App。启动时只会生成并加载一个 H5 URL;如果当前 URL 探测或加载失败,壳层会重新生成下一个 URL 并替换当前 WebView,项目运行过程中始终只持有一个 H5 URL。 -默认情况下,非本地 Web 访问会使用当前服务地址的 origin 作为 H5 线路和接口域名,例如访问 `https://app.example.com/app/` 时会请求 `https://app.example.com/client_config/query`。本地访问(如 `localhost`、`127.0.0.1`)继续使用固定兜底域名 `https://h5-test.imharry.work/`。 +默认 URL 来自代码中的泛域名模板,会生成 16 位数字子域名,并通过查询参数把 Flutter 壳请求地址传给 H5: -## H5 线路切换 - -线路切换在 Flutter 套壳层完成。远程配置不可用时,会先使用启动兜底线路;也可以在打包时覆盖启动线路或配置接口地址: - -```bash -flutter build apk --release --dart-define=H5_LINE_URLS=https://h5-one.example/,https://h5-two.example/ -flutter build apk --release --dart-define=CLIENT_CONFIG_QUERY_URL=https://api.example.com/client_config/query -flutter build apk --release --dart-define=BOOTSTRAP_H5_LINE_URL=https://h5-one.example/ +```text +https://1234567890123456.albzyuxq.vip/?flutter_shell=1&flutter_shell_url=https%3A%2F%2F1234567890123456.albzyuxq.vip ``` ## 本地打包 diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 9c8d3e9..36218d4 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -12,175 +12,52 @@ class H5Line { Uri get uri => Uri.parse(url); } -enum Environment { - development, - staging, - production, -} - class AppConfig { static const String appName = '本地打包'; static const String appLogo = ''; - static const String clientConfigDevice = 'h5'; - static const String clientConfigSerialNo = 'h5-domain'; - static const String _dartDefinedH5LineUrls = - String.fromEnvironment('H5_LINE_URLS'); - static const String _dartDefinedClientConfigQueryUrl = - String.fromEnvironment('CLIENT_CONFIG_QUERY_URL'); - // Bootstrap only: used before /client_config/query returns dynamic H5 domains. - static const String _bootstrapH5LineUrl = String.fromEnvironment( - 'BOOTSTRAP_H5_LINE_URL', - defaultValue: 'https://h5-test.imharry.work/'); - static const Environment currentEnvironment = Environment.production; - // Compatibility for the packaging service's legacy domain rewrite step. - static final Map> _environmentHosts = { - Environment.production: [ - _bootstrapH5LineUrl, - ], - }; - static final Random _wildcardRandom = Random.secure(); - static List get h5Lines { - final configuredUrls = _dartDefinedH5LineUrls.trim().isEmpty - ? _normalizedUniqueUrls(defaultH5LineUrlsForBaseUri(Uri.base)) - : _normalizedUniqueUrls(_dartDefinedH5LineUrls.split(',')); - final urls = configuredUrls.isEmpty - ? _normalizedUniqueUrls(const [_bootstrapH5LineUrl]) - : configuredUrls; + static const String flutterShellMarkerQueryKey = 'flutter_shell'; + static const String flutterShellUrlQueryKey = 'flutter_shell_url'; + static const List wildcardH5DomainTemplates = [ + '*.albzyuxq.vip', + '*.zdyswvnt.vip', + '*.gxifpxcr.vip', + ]; - return [ - for (var index = 0; index < urls.length; index += 1) - H5Line(label: '线路${index + 1}', url: urls[index]), - ]; - } + static final Random _random = Random.secure(); - static Map get clientConfigQueryPayload => const { - 'device': clientConfigDevice, - 'serialNo': clientConfigSerialNo, - }; + static H5Line get h5Line => createRandomH5Line(); - static List get clientConfigQueryUris { - final dartDefinedUrl = _dartDefinedClientConfigQueryUrl.trim(); - if (dartDefinedUrl.isNotEmpty) { - return [Uri.parse(dartDefinedUrl)]; - } - - final lines = h5Lines; - if (lines.isEmpty) { - return const []; - } - - return clientConfigQueryUrisForHomeUri(lines.first.uri); - } - - static List defaultH5LineUrlsForBaseUri(Uri baseUri) { - final runtimeHomeUrl = _runtimeHomeUrlFromBaseUri(baseUri); - if (runtimeHomeUrl != null) { - return [runtimeHomeUrl]; - } - - final environmentHosts = _environmentHosts[currentEnvironment]; - if (environmentHosts == null || environmentHosts.isEmpty) { - return const [_bootstrapH5LineUrl]; - } - return environmentHosts; - } - - static List clientConfigQueryUrisForHomeUri(Uri homeUri) { - return [ - _originUriWithPath(homeUri, '/client_config/query'), - _originUriWithPath(homeUri, '/api/user/client_config/query'), - ]; - } - - static List h5LinesFromResourceUrl( - String resourceUrl, { + static H5Line createRandomH5Line({ + int attempt = 0, String Function()? wildcardFactory, }) { - final urls = _normalizedUniqueUrls( - splitResourceUrlLines(resourceUrl).map( - (value) => _replaceWildcardHost( - value, - wildcardFactory: wildcardFactory, - ), + final template = wildcardH5DomainTemplates[ + attempt.abs() % wildcardH5DomainTemplates.length]; + return h5LineFromUrl( + _buildFlutterShellHomeUrl( + _replaceWildcardHost(template, wildcardFactory: wildcardFactory), ), ); - - return [ - for (var index = 0; index < urls.length; index += 1) - H5Line(label: '线路${index + 1}', url: urls[index]), - ]; } - static List splitResourceUrlLines(String resourceUrl) { - if (resourceUrl.trim().isEmpty) { - return const []; - } - - final lines = []; - final seen = {}; - for (final value - in resourceUrl.split(RegExp(r'\\r\\n|\\n|\\r|\\t|[\r\n\t]+'))) { - final line = value.trim(); - if (line.isEmpty || seen.contains(line)) { - continue; - } - lines.add(line); - seen.add(line); - } - return List.unmodifiable(lines); + static H5Line h5LineFromUrl(String value) { + return H5Line(label: '线路1', url: _normalizeHomeUrl(value)); } - static bool isLoginPageUrl(String? url) { - if (url == null || url.isEmpty) { - return false; - } - - final uri = Uri.tryParse(url); - if (uri == null) { - return false; - } - - return _isLoginPath(uri.path) || _isLoginPath(uri.fragment); + static String originForUrl(String value) { + final uri = Uri.parse(value); + return '${uri.scheme}://${uri.authority}'; } - static bool _isLoginPath(String path) { - final normalized = path.trim(); - if (normalized.isEmpty) { - return false; - } - - final pathOnly = normalized.split('?').first.split('#').first; - final segments = pathOnly.split('/').where((segment) => segment.isNotEmpty); - if (segments.isEmpty) { - return false; - } - - return segments.last == 'login'; - } - - static List _normalizedUniqueUrls(Iterable values) { - final urls = []; - final seen = {}; - - for (final value in values) { - final normalized = _tryNormalizeHomeUrl(value); - if (normalized == null || seen.contains(normalized)) { - continue; - } - urls.add(normalized); - seen.add(normalized); - } - - return List.unmodifiable(urls); - } - - static String? _tryNormalizeHomeUrl(String value) { - try { - return _normalizeHomeUrl(value); - } catch (_) { - return null; - } + static String _buildFlutterShellHomeUrl(String host) { + final normalized = _normalizeHomeUrl(host); + final uri = Uri.parse(normalized); + final origin = originForUrl(normalized); + final queryParameters = Map.from(uri.queryParameters) + ..[flutterShellMarkerQueryKey] = '1' + ..[flutterShellUrlQueryKey] = origin; + return uri.replace(queryParameters: queryParameters).toString(); } static String _normalizeHomeUrl(String host) { @@ -198,69 +75,6 @@ class AppConfig { return uri.replace(path: path).toString(); } - static String? _runtimeHomeUrlFromBaseUri(Uri baseUri) { - if (_isLocalRuntimeUri(baseUri)) { - return null; - } - - return _originUriWithPath(baseUri, '/').toString(); - } - - static Uri _originUriWithPath(Uri uri, String path) { - final normalizedPath = path.startsWith('/') ? path : '/$path'; - return Uri.parse('${uri.scheme}://${uri.authority}$normalizedPath'); - } - - static bool _isLocalRuntimeUri(Uri uri) { - if (uri.scheme != 'http' && uri.scheme != 'https') { - return true; - } - - final host = _normalizeHostForCompare(uri.host); - if (host.isEmpty) { - return true; - } - - if (host == 'localhost' || host.endsWith('.localhost') || host == '::1') { - return true; - } - - final ipv4Parts = _tryParseIpv4Address(host); - if (ipv4Parts == null) { - return false; - } - - return ipv4Parts[0] == 127 || ipv4Parts.every((part) => part == 0); - } - - static String _normalizeHostForCompare(String host) { - final normalized = host.trim().toLowerCase(); - if (normalized.endsWith('.')) { - return normalized.substring(0, normalized.length - 1); - } - return normalized; - } - - static List? _tryParseIpv4Address(String host) { - final parts = host.split('.'); - if (parts.length != 4) { - return null; - } - - final octets = []; - for (final part in parts) { - if (part.isEmpty || !RegExp(r'^\d+$').hasMatch(part)) { - return null; - } - final value = int.tryParse(part); - if (value == null || value < 0 || value > 255) { - return null; - } - octets.add(value); - } - return octets; - } - static String _replaceWildcardHost( String value, { String Function()? wildcardFactory, @@ -276,12 +90,10 @@ class AppConfig { } static String _randomWildcardLabel() { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - return String.fromCharCodes( - List.generate( - 16, - (_) => chars.codeUnitAt(_wildcardRandom.nextInt(chars.length)), - ), - ); + final digits = StringBuffer(_random.nextInt(9) + 1); + for (var index = 1; index < 16; index += 1) { + digits.write(_random.nextInt(10)); + } + return digits.toString(); } } diff --git a/lib/main.dart b/lib/main.dart index 2088d05..905eae2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,7 +25,20 @@ const _h5CacheChannel = MethodChannel('io.openim.flutter.im_webview_app/h5_cache'); const _keyboardAnimationDuration = Duration(milliseconds: 250); const _initialH5CacheClearedKey = 'initial_h5_cache_cleared_v1'; +const _h5DomainCacheDateKey = 'h5_random_domain_date_v1'; +const _h5DomainCacheSessionKey = 'h5_random_domain_session_v1'; +const _h5DomainCacheUrlKey = 'h5_random_domain_url_v1'; const _lineProbeTimeout = Duration(seconds: 5); +const _maxGeneratedLineAttempts = 6; +final String _h5DomainProcessSession = + 'session-${DateTime.now().microsecondsSinceEpoch}'; + +String _domainDateKey(DateTime value) { + final year = value.year.toString().padLeft(4, '0'); + final month = value.month.toString().padLeft(2, '0'); + final day = value.day.toString().padLeft(2, '0'); + return '$year-$month-$day'; +} Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -111,44 +124,45 @@ class H5ShellPage extends StatefulWidget { State createState() => _H5ShellPageState(); } -class _H5ShellPageState extends State { - late List _h5Lines; - late List<_H5LineWebViewSlot> _lineSlots; +class _H5ShellPageState extends State with WidgetsBindingObserver { + late H5Line _h5Line; + late _H5LineWebViewSlot _lineSlot; bool _shellBrandingLoaded = false; bool _isPreparingInitialLine = true; - bool _isAutoSwitchingLine = false; - int _currentLineIndex = 0; + bool _isReplacingUnavailableLine = false; double _lastKeyboardInset = 0; bool _lastKeyboardVisible = false; int _keyboardSyncToken = 0; - int _lineProbeGeneration = 0; + int _lineGeneration = 0; + int _h5LineAttempt = 0; late ShellBranding _shellBranding; Future? _cacheClearTask; + Timer? _domainExpiryTimer; + String _activeDomainDateKey = ''; - H5Line get _currentLine => _h5Lines[_currentLineIndex]; - _H5LineWebViewSlot get _currentSlot => _lineSlots[_currentLineIndex]; + _H5LineWebViewSlot get _currentSlot => _lineSlot; @override void initState() { super.initState(); - _h5Lines = AppConfig.h5Lines; + WidgetsBinding.instance.addObserver(this); + _h5Line = AppConfig.h5Line; _shellBranding = widget.initialShellBranding; - _lineSlots = _createLineSlots(_h5Lines); + _lineSlot = _createLineSlot(_h5Line); + _scheduleDomainExpiryTimer(); unawaited(_initializeLines()); } - List<_H5LineWebViewSlot> _createLineSlots(List lines) { - return [ - for (var index = 0; index < lines.length; index += 1) - _H5LineWebViewSlot( - line: lines[index], - controller: _buildController(index), - ), - ]; + _H5LineWebViewSlot _createLineSlot(H5Line line) { + final generation = _lineGeneration; + return _H5LineWebViewSlot( + line: line, + controller: _buildController(generation), + ); } - WebViewController _buildController(int lineIndex) { + WebViewController _buildController(int generation) { final controller = WebViewController( onPermissionRequest: (request) { unawaited(_handleWebViewPermissionRequest(request)); @@ -157,51 +171,59 @@ class _H5ShellPageState extends State { ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel( 'OpenIMShell', - onMessageReceived: (message) => _handleShellMessage(lineIndex, message), + onMessageReceived: (message) => _handleShellMessage( + generation, + message, + ), ) ..addJavaScriptChannel( 'OpenIMFlutterShell', - onMessageReceived: (message) => - _handleFlutterShellMessage(lineIndex, message), + onMessageReceived: (message) => _handleFlutterShellMessage( + generation, + message, + ), ) ..setBackgroundColor(Colors.white) ..setNavigationDelegate( NavigationDelegate( onProgress: (progress) { - final slot = _lineSlots[lineIndex]; + if (!_isCurrentLineGeneration(generation)) { + return; + } + final slot = _lineSlot; final nextProgress = progress.clamp(0, 100).toInt(); if (slot.progress == nextProgress) { return; } slot.progress = nextProgress; - if (mounted && - lineIndex == _currentLineIndex && - slot.isAwaitingFirstScreen) { + if (mounted && slot.isAwaitingFirstScreen) { setState(() {}); } }, onPageStarted: (url) { - final slot = _lineSlots[lineIndex]; + if (!_isCurrentLineGeneration(generation)) { + return; + } + final slot = _lineSlot; final shouldShowCover = !slot.hasPresentedFirstScreen; slot.shellCoverFallbackTimer?.cancel(); - slot.setCurrentUrl(url); slot.loadError = null; slot.progress = 0; slot.showShellCover = shouldShowCover; - if (mounted && lineIndex == _currentLineIndex) { + if (mounted) { setState(() {}); } }, onPageFinished: (_) { - unawaited(_handlePageFinished(lineIndex)); - }, - onUrlChange: (change) { - _updateSlotUrl(lineIndex, change.url); + unawaited(_handlePageFinished(generation)); }, onWebResourceError: (error) { + if (!_isCurrentLineGeneration(generation)) { + return; + } if (error.isForMainFrame ?? true) { - _handleMainFrameLoadError(lineIndex, error.description); + _handleMainFrameLoadError(error.description); } }, onNavigationRequest: _handleNavigationRequest, @@ -246,45 +268,131 @@ class _H5ShellPageState extends State { @override void dispose() { - for (final slot in _lineSlots) { - slot.dispose(); - } + WidgetsBinding.instance.removeObserver(this); + _domainExpiryTimer?.cancel(); + _lineSlot.dispose(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_refreshExpiredH5LinesIfNeeded()); + } + } + Future _initializeLines() async { await _clearInitialH5CachesIfNeeded(); + await _prepareAndLoadH5Line(forceNew: false); + } + + Future _prepareAndLoadH5Line({required bool forceNew}) async { + var shouldCreateNewLine = forceNew; + for (var attempt = 0; attempt < _maxGeneratedLineAttempts; attempt += 1) { + final line = await _loadCachedOrCreateH5Line( + forceNew: shouldCreateNewLine, + ); + if (!mounted) { + return; + } + + _replaceLineSlotIfNeeded(line); + final available = await _probeCurrentLine(); + if (!mounted) { + return; + } + + if (available) { + setState(() { + _isPreparingInitialLine = false; + }); + await _loadCurrentLine(forceReload: true); + _scheduleDomainExpiryTimer(); + return; + } + + shouldCreateNewLine = true; + } - final runtimeLines = await _fetchRuntimeH5Lines(); if (!mounted) { return; } - if (runtimeLines.isNotEmpty && !_hasSameLineUrls(_h5Lines, runtimeLines)) { - setState(() { - _replaceLineSlots(runtimeLines); - }); - } - - final firstAvailableIndex = await _probeLinesUntilFirstAvailable(); - if (!mounted) { - return; - } - - if (firstAvailableIndex == null) { - setState(() { - _isPreparingInitialLine = false; - _currentLineIndex = 0; - _currentSlot.loadError = '所有线路暂不可用,请稍后重试'; - _currentSlot.showShellCover = false; - }); - return; - } - setState(() { _isPreparingInitialLine = false; + _currentSlot.loadError = '当前线路暂不可用,请稍后重试'; + _currentSlot.showShellCover = false; }); - await _loadLine(firstAvailableIndex); + _scheduleDomainExpiryTimer(); + } + + Future _loadCachedOrCreateH5Line({ + required bool forceNew, + }) async { + final today = _domainDateKey(DateTime.now()); + _activeDomainDateKey = today; + + try { + final preferences = await SharedPreferences.getInstance(); + final cachedUrl = preferences.getString(_h5DomainCacheUrlKey); + final cachedLine = + cachedUrl == null ? null : AppConfig.h5LineFromUrl(cachedUrl); + final canUseCachedLine = !forceNew && + preferences.getString(_h5DomainCacheDateKey) == today && + preferences.getString(_h5DomainCacheSessionKey) == + _h5DomainProcessSession && + cachedLine != null; + + if (canUseCachedLine) { + _logH5Line('复用本进程当天随机线路', cachedLine); + return cachedLine; + } + + final line = AppConfig.createRandomH5Line(attempt: _h5LineAttempt); + _h5LineAttempt += 1; + await preferences.setString(_h5DomainCacheDateKey, today); + await preferences.setString( + _h5DomainCacheSessionKey, + _h5DomainProcessSession, + ); + await preferences.setString(_h5DomainCacheUrlKey, line.url); + _logH5Line('生成当天随机线路', line); + return line; + } catch (_) { + final line = AppConfig.createRandomH5Line(attempt: _h5LineAttempt); + _h5LineAttempt += 1; + _logH5Line('本地缓存不可用,生成临时随机线路', line); + return line; + } + } + + Future _refreshExpiredH5LinesIfNeeded() async { + final today = _domainDateKey(DateTime.now()); + if (_isPreparingInitialLine || _activeDomainDateKey == today) { + _scheduleDomainExpiryTimer(); + return; + } + + if (mounted) { + setState(() { + _isPreparingInitialLine = true; + }); + } + + await _clearAllH5Caches(); + await _prepareAndLoadH5Line(forceNew: true); + } + + void _scheduleDomainExpiryTimer() { + _domainExpiryTimer?.cancel(); + + final now = DateTime.now(); + final nextMidnight = DateTime(now.year, now.month, now.day + 1); + final delay = nextMidnight.difference(now) + const Duration(seconds: 1); + _domainExpiryTimer = Timer( + delay, + () => unawaited(_refreshExpiredH5LinesIfNeeded()), + ); } Future _clearInitialH5CachesIfNeeded() async { @@ -319,17 +427,8 @@ class _H5ShellPageState extends State { Future _clearAllH5CachesInternal() async { await _clearNativeH5WebsiteData(); await _clearCookiesSafely(); - - if (_lineSlots.isEmpty) { - final controller = WebViewController(); - await _clearControllerStorage(controller); - return; - } - - for (final slot in _lineSlots) { - await _clearPageStorage(slot.controller); - await _clearControllerStorage(slot.controller); - } + await _clearPageStorage(_lineSlot.controller); + await _clearControllerStorage(_lineSlot.controller); } Future _clearNativeH5WebsiteData() async { @@ -393,149 +492,10 @@ class _H5ShellPageState extends State { } catch (_) {} } - Future> _fetchRuntimeH5Lines() async { - _logH5Lines('启动兜底线路', _h5Lines); - for (final uri in AppConfig.clientConfigQueryUris) { - try { - _logH5LineDebug( - '请求线路配置: $uri payload=${jsonEncode(AppConfig.clientConfigQueryPayload)}', - ); - final lines = await _fetchRuntimeH5LinesFrom(uri); - if (lines.isNotEmpty) { - _logH5Lines('接口换算后的线路', lines); - return lines; - } - _logH5LineDebug('线路配置接口没有返回可用 resourceUrl: $uri'); - } catch (error) { - _logH5LineDebug('线路配置请求失败: $uri error=$error'); - // Try the next compatible endpoint shape. - } - } - _logH5LineDebug('没有获取到远程线路配置,将继续使用启动兜底线路'); - return const []; - } - - Future> _fetchRuntimeH5LinesFrom(Uri uri) async { - final client = HttpClient()..connectionTimeout = _lineProbeTimeout; - try { - final request = await client.postUrl(uri).timeout(_lineProbeTimeout); - request.headers.contentType = ContentType.json; - request.headers.set(HttpHeaders.acceptHeader, 'application/json'); - request.headers.set('operationID', _createOperationID()); - request.write(jsonEncode(AppConfig.clientConfigQueryPayload)); - - final response = await request.close().timeout(_lineProbeTimeout); - final body = await utf8.decoder.bind(response).join(); - _logH5LineDebug( - '线路配置响应: $uri status=${response.statusCode} body=${_previewForLog(body)}', - ); - if (response.statusCode < 200 || response.statusCode >= 300) { - return const []; - } - - final decoded = jsonDecode(body); - final resourceUrl = _firstResourceUrlFromClientConfig(decoded); - if (resourceUrl == null || resourceUrl.trim().isEmpty) { - _logH5LineDebug('线路配置未找到 resourceUrl: $uri'); - return const []; - } - _logH5LineDebug( - 'resourceUrl 原始值: ${_escapeForLog(resourceUrl)}', - ); - final resourceLines = AppConfig.splitResourceUrlLines(resourceUrl); - _logStringList('resourceUrl 拆分结果', resourceLines); - return AppConfig.h5LinesFromResourceUrl(resourceUrl); - } finally { - client.close(force: true); - } - } - - String _createOperationID() => - 'flutter-h5-line-${DateTime.now().microsecondsSinceEpoch}'; - - String? _firstResourceUrlFromClientConfig(Object? decoded) { - if (decoded is! Map) { - return null; - } - - final data = decoded['data']; - final entries = decoded['entries'] ?? - (data is Map ? data['entries'] : null) ?? - (data is List ? data : null); - if (entries is! List) { - return null; - } - - for (final entry in entries) { - if (entry is! Map) { - continue; - } - - final resourceType = entry['resourceType']?.toString().trim() ?? ''; - final resourceUrl = entry['resourceUrl']?.toString(); - if (resourceUrl == null || resourceUrl.trim().isEmpty) { - continue; - } - if (resourceType.isNotEmpty && resourceType != 'text') { - continue; - } - return resourceUrl; - } - - return null; - } - - Future _probeLinesUntilFirstAvailable() async { - final generation = ++_lineProbeGeneration; - for (var index = 0; index < _lineSlots.length; index += 1) { - if (!mounted || generation != _lineProbeGeneration) { - return null; - } - - if (await _probeLineAndUpdate(index, generation: generation)) { - unawaited(_probeRemainingLines(index + 1, generation)); - return index; - } - } - return null; - } - - Future _probeRemainingLines(int startIndex, int generation) async { - for (var index = startIndex; index < _lineSlots.length; index += 1) { - if (!mounted || generation != _lineProbeGeneration) { - return; - } - await _probeLineAndUpdate(index, generation: generation); - } - } - - Future _refreshAllLineAvailability() async { - final generation = ++_lineProbeGeneration; - if (mounted) { - setState(() { - for (final slot in _lineSlots) { - slot.availability = _LineAvailability.checking; - } - }); - } - - await Future.wait([ - for (var index = 0; index < _lineSlots.length; index += 1) - _probeLineAndUpdate(index, generation: generation), - ]); - } - - Future _probeLineAndUpdate( - int index, { - required int generation, - }) async { - if (index < 0 || index >= _lineSlots.length) { - return false; - } - - final line = _lineSlots[index].line; + Future _probeCurrentLine() async { + final generation = _lineGeneration; + final line = _lineSlot.line; _setLineAvailability( - index, _LineAvailability.checking, generation: generation, line: line, @@ -543,15 +503,12 @@ class _H5ShellPageState extends State { final available = await _isLineReachable(line); if (!mounted || - generation != _lineProbeGeneration || - index < 0 || - index >= _lineSlots.length || - _lineSlots[index].line.url != line.url) { + generation != _lineGeneration || + _lineSlot.line.url != line.url) { return false; } _setLineAvailability( - index, available ? _LineAvailability.available : _LineAvailability.unavailable, generation: generation, line: line, @@ -580,131 +537,111 @@ class _H5ShellPageState extends State { } void _setLineAvailability( - int index, _LineAvailability availability, { required int generation, required H5Line line, }) { if (!mounted || - generation != _lineProbeGeneration || - index < 0 || - index >= _lineSlots.length || - _lineSlots[index].line.url != line.url || - _lineSlots[index].availability == availability) { + generation != _lineGeneration || + _lineSlot.line.url != line.url || + _lineSlot.availability == availability) { return; } setState(() { - _lineSlots[index].availability = availability; + _lineSlot.availability = availability; }); } - void _replaceLineSlots(List lines) { - for (final slot in _lineSlots) { - slot.dispose(); + void _replaceLineSlotIfNeeded(H5Line line) { + if (_h5Line.url == line.url) { + _logH5Line('WebView 使用的线路', _h5Line); + return; } - _lineProbeGeneration += 1; - _h5Lines = lines; - _lineSlots = _createLineSlots(lines); - _currentLineIndex = 0; - _isAutoSwitchingLine = false; - _logH5Lines('WebView 使用的线路', _h5Lines); + + setState(() { + _replaceLineSlot(line); + }); } - void _logH5Lines(String title, List lines) { - _logH5LineDebug('$title count=${lines.length}'); - for (var index = 0; index < lines.length; index += 1) { - _logH5LineDebug( - '$title[${index + 1}] ${lines[index].label} => ${lines[index].url}', - ); - } + void _replaceLineSlot(H5Line line) { + _lineSlot.dispose(); + _lineGeneration += 1; + _h5Line = line; + _lineSlot = _createLineSlot(line); + _logH5Line('WebView 使用的线路', _h5Line); } - void _logStringList(String title, List values) { - _logH5LineDebug('$title count=${values.length}'); - for (var index = 0; index < values.length; index += 1) { - _logH5LineDebug('$title[${index + 1}] ${_escapeForLog(values[index])}'); - } + void _logH5Line(String title, H5Line line) { + _logH5LineDebug('$title ${line.label} => ${line.url}'); } void _logH5LineDebug(String message) { debugPrint('[H5LineDebug] $message'); } - String _previewForLog(String value, {int maxLength = 1200}) { - final escaped = _escapeForLog(value); - if (escaped.length <= maxLength) { - return escaped; - } - return '${escaped.substring(0, maxLength)}...<${escaped.length} chars>'; + bool _isCurrentLineGeneration(int generation) { + return generation == _lineGeneration; } - String _escapeForLog(String value) { - return value - .replaceAll('\r', r'\r') - .replaceAll('\n', r'\n') - .replaceAll('\t', r'\t'); - } - - bool _hasSameLineUrls(List left, List right) { - if (left.length != right.length) { - return false; - } - - for (var index = 0; index < left.length; index += 1) { - if (left[index].url != right[index].url) { - return false; - } - } - return true; - } - - Future _runJavaScriptSafely(int lineIndex, String source) async { + Future _runJavaScriptSafely(String source) async { try { - await _lineSlots[lineIndex].controller.runJavaScript(source); + await _lineSlot.controller.runJavaScript(source); } catch (_) { // WebView can reject JavaScript while a page is still navigating. } } - Future _handlePageFinished(int lineIndex) async { + Future _handlePageFinished(int generation) async { + if (!_isCurrentLineGeneration(generation)) { + return; + } + await _loadShellBrandingIfNeeded(); - await _syncShellBranding(lineIndex); - await _installRouteObserver(lineIndex); - await _syncKeyboardState(lineIndex); - final slot = _lineSlots[lineIndex]; + if (!_isCurrentLineGeneration(generation)) { + return; + } + + await _syncShellRequestUrl(); + await _syncShellBranding(); + await _syncKeyboardState(); + final slot = _lineSlot; slot.availability = _LineAvailability.available; slot.progress = 100; - if (mounted && - lineIndex == _currentLineIndex && - slot.isAwaitingFirstScreen) { + if (mounted && slot.isAwaitingFirstScreen) { setState(() {}); } if (slot.showShellCover) { - _scheduleShellCoverFallback(lineIndex); + _scheduleShellCoverFallback(generation); } else { slot.shellCoverFallbackTimer?.cancel(); } } - void _handleShellMessage(int lineIndex, JavaScriptMessage message) { + void _handleShellMessage(int generation, JavaScriptMessage message) { + if (!_isCurrentLineGeneration(generation)) { + return; + } + try { final decoded = jsonDecode(message.message); if (decoded is Map && decoded['type'] == 'first-screen-ready') { - _hideShellCover(lineIndex); - } else if (decoded is Map && decoded['type'] == 'route-changed') { - _updateSlotUrl(lineIndex, decoded['url']?.toString()); + _hideShellCover(generation); } else if (decoded is Map && decoded['type'] == 'keyboard-bridge-ready') { - unawaited(_syncKeyboardState(lineIndex)); + unawaited(_syncKeyboardState()); } } catch (_) { if (message.message == 'first-screen-ready') { - _hideShellCover(lineIndex); + _hideShellCover(generation); } } } - void _handleFlutterShellMessage(int lineIndex, JavaScriptMessage message) { + void _handleFlutterShellMessage(int generation, JavaScriptMessage message) { + if (!_isCurrentLineGeneration(generation)) { + return; + } + try { final decoded = jsonDecode(message.message); if (decoded is! Map) { @@ -719,7 +656,7 @@ class _H5ShellPageState extends State { } unawaited( _handleShellMediaPermissionRequest( - lineIndex: lineIndex, + generation: generation, requestId: requestId, audio: decoded['audio'] == true, video: decoded['video'] == true, @@ -728,15 +665,19 @@ class _H5ShellPageState extends State { case 'openAppSettings': unawaited(openAppSettings()); case 'keyboard-bridge-ready': - unawaited(_syncKeyboardState(lineIndex)); + unawaited(_syncKeyboardState()); } } catch (_) { // Ignore malformed shell messages from web content. } } - void _scheduleShellCoverFallback(int lineIndex) { - final slot = _lineSlots[lineIndex]; + void _scheduleShellCoverFallback(int generation) { + if (!_isCurrentLineGeneration(generation)) { + return; + } + + final slot = _lineSlot; if (!slot.showShellCover) { return; } @@ -744,12 +685,16 @@ class _H5ShellPageState extends State { slot.shellCoverFallbackTimer?.cancel(); slot.shellCoverFallbackTimer = Timer( const Duration(milliseconds: 1800), - () => _hideShellCover(lineIndex), + () => _hideShellCover(generation), ); } - void _hideShellCover(int lineIndex) { - final slot = _lineSlots[lineIndex]; + void _hideShellCover(int generation) { + if (!_isCurrentLineGeneration(generation)) { + return; + } + + final slot = _lineSlot; slot.shellCoverFallbackTimer?.cancel(); if (slot.hasPresentedFirstScreen && !slot.showShellCover) { return; @@ -758,7 +703,7 @@ class _H5ShellPageState extends State { slot.hasPresentedFirstScreen = true; slot.progress = 100; slot.showShellCover = false; - if (!mounted || lineIndex != _currentLineIndex) { + if (!mounted) { return; } @@ -782,7 +727,7 @@ class _H5ShellPageState extends State { }); } - Future _syncShellBranding(int lineIndex) { + Future _syncShellBranding() { final payload = jsonEncode({ 'name': _shellBranding.appName, 'logo': _shellBranding.appLogo, @@ -796,7 +741,22 @@ class _H5ShellPageState extends State { } catch (_) {} })(); '''; - return _runJavaScriptSafely(lineIndex, script); + return _runJavaScriptSafely(script); + } + + Future _syncShellRequestUrl() { + final url = AppConfig.originForUrl(_lineSlot.line.url); + final payload = jsonEncode({'url': url}); + final script = ''' +(() => { + try { + const payload = $payload; + window.sessionStorage.setItem('OPENIM_FLUTTER_SHELL_REQUEST_URL', payload.url); + window.dispatchEvent(new CustomEvent('openim-shell-request-url-updated', { detail: payload })); + } catch (_) {} +})(); +'''; + return _runJavaScriptSafely(script); } void _scheduleKeyboardStateSync(double bottomInset) { @@ -815,15 +775,11 @@ class _H5ShellPageState extends State { if (!mounted || token != _keyboardSyncToken) { return; } - unawaited(_syncKeyboardState(_currentLineIndex)); + unawaited(_syncKeyboardState()); }); } - Future _syncKeyboardState(int lineIndex) { - if (lineIndex < 0 || lineIndex >= _lineSlots.length) { - return Future.value(); - } - + Future _syncKeyboardState() { final payload = jsonEncode({ 'height': _lastKeyboardInset.round(), 'visible': _lastKeyboardVisible, @@ -843,53 +799,11 @@ class _H5ShellPageState extends State { } catch (_) {} })(); '''; - return _runJavaScriptSafely(lineIndex, script); - } - - Future _installRouteObserver(int lineIndex) { - const script = ''' -(() => { - try { - const notify = () => { - try { - window.OpenIMShell.postMessage(JSON.stringify({ - type: 'route-changed', - url: window.location.href - })); - } catch (_) {} - }; - - if (window.__OPENIM_FLUTTER_ROUTE_OBSERVER__) { - window.__OPENIM_FLUTTER_ROUTE_OBSERVER__.notify(); - return; - } - - const originalPushState = window.history.pushState; - const originalReplaceState = window.history.replaceState; - - window.history.pushState = function() { - const result = originalPushState.apply(this, arguments); - notify(); - return result; - }; - - window.history.replaceState = function() { - const result = originalReplaceState.apply(this, arguments); - notify(); - return result; - }; - - window.addEventListener('popstate', notify); - window.__OPENIM_FLUTTER_ROUTE_OBSERVER__ = { notify }; - notify(); - } catch (_) {} -})(); -'''; - return _runJavaScriptSafely(lineIndex, script); + return _runJavaScriptSafely(script); } Future _handleShellMediaPermissionRequest({ - required int lineIndex, + required int generation, required String requestId, required bool audio, required bool video, @@ -898,6 +812,10 @@ class _H5ShellPageState extends State { audio: audio, video: video, ); + if (!_isCurrentLineGeneration(generation)) { + return; + } + final payload = jsonEncode({ 'requestId': requestId, 'granted': result.granted, @@ -911,7 +829,7 @@ class _H5ShellPageState extends State { } catch (_) {} })(); '''; - await _runJavaScriptSafely(lineIndex, script); + await _runJavaScriptSafely(script); } Future _handleWebViewPermissionRequest( @@ -970,82 +888,43 @@ class _H5ShellPageState extends State { ); } - void _handleMainFrameLoadError(int lineIndex, String description) { - if (lineIndex < 0 || lineIndex >= _lineSlots.length) { - return; - } - - final slot = _lineSlots[lineIndex]; + void _handleMainFrameLoadError(String description) { + final slot = _lineSlot; slot.shellCoverFallbackTimer?.cancel(); slot.availability = _LineAvailability.unavailable; slot.loadError = description; slot.showShellCover = false; - if (mounted && lineIndex == _currentLineIndex) { + if (mounted) { setState(() {}); - unawaited(_switchToNextUsableLine(lineIndex)); + unawaited(_replaceUnavailableLine()); } } - Future _switchToNextUsableLine(int failedIndex) async { - if (_isAutoSwitchingLine || _lineSlots.length <= 1) { + Future _replaceUnavailableLine() async { + if (_isReplacingUnavailableLine) { return; } - _isAutoSwitchingLine = true; + _isReplacingUnavailableLine = true; try { - for (final index in _fallbackLineIndexes(failedIndex)) { - if (!mounted || failedIndex != _currentLineIndex) { - return; - } - - final slot = _lineSlots[index]; - if (slot.availability == _LineAvailability.unavailable) { - continue; - } - - var available = slot.availability == _LineAvailability.available; - if (!available) { - final generation = _lineProbeGeneration; - available = await _probeLineAndUpdate(index, generation: generation); - } - - if (available && mounted && failedIndex == _currentLineIndex) { - await _loadLine(index); - return; - } + await _clearAllH5Caches(); + if (mounted) { + setState(() { + _isPreparingInitialLine = true; + _currentSlot.loadError = null; + _currentSlot.progress = 0; + _currentSlot.showShellCover = true; + }); } + await _prepareAndLoadH5Line(forceNew: true); } finally { - _isAutoSwitchingLine = false; + _isReplacingUnavailableLine = false; } } - Iterable _fallbackLineIndexes(int failedIndex) sync* { - for (var index = failedIndex + 1; index < _lineSlots.length; index += 1) { - yield index; - } - for (var index = 0; index < failedIndex; index += 1) { - yield index; - } - } - - void _updateSlotUrl(int lineIndex, String? url) { - if (url == null || lineIndex < 0 || lineIndex >= _lineSlots.length) { - return; - } - - final slot = _lineSlots[lineIndex]; - final changed = slot.setCurrentUrl(url); - if (changed && mounted && lineIndex == _currentLineIndex) { - setState(() {}); - } - } - - Future _loadLineHomeRequest( - int lineIndex, { - bool force = false, - }) async { - final slot = _lineSlots[lineIndex]; - if (slot.hasLoadedInitialRequest && !force) { + Future _loadCurrentLine({bool forceReload = false}) async { + final slot = _lineSlot; + if (slot.hasLoadedInitialRequest && !forceReload) { return; } @@ -1054,7 +933,6 @@ class _H5ShellPageState extends State { if (mounted) { slot.shellCoverFallbackTimer?.cancel(); setState(() { - slot.setCurrentUrl(slot.line.url); slot.loadError = null; slot.progress = 0; slot.showShellCover = shouldShowCover; @@ -1062,10 +940,17 @@ class _H5ShellPageState extends State { } await slot.controller.loadRequest(Uri.parse(slot.line.url)); + await _syncKeyboardState(); } Future _reloadCurrentLine() async { final slot = _currentSlot; + if (slot.availability == _LineAvailability.unavailable || + slot.loadError != null) { + await _replaceUnavailableLine(); + return; + } + final shouldShowCover = !slot.hasPresentedFirstScreen; slot.shellCoverFallbackTimer?.cancel(); if (mounted) { @@ -1079,33 +964,17 @@ class _H5ShellPageState extends State { if (slot.hasLoadedInitialRequest) { await slot.controller.reload(); } else { - await _loadLineHomeRequest(_currentLineIndex); + await _loadCurrentLine(); } } - Future _loadLine(int index, {bool forceReload = false}) async { - final safeIndex = index < 0 || index >= _h5Lines.length ? 0 : index; - if (_lineSlots[safeIndex].availability == _LineAvailability.unavailable) { - return; - } - - if (mounted) { - setState(() { - _currentLineIndex = safeIndex; - }); - } - - await _loadLineHomeRequest(safeIndex, force: forceReload); - await _syncKeyboardState(safeIndex); - } - - Widget _buildWebViewWidget(int index) { + Widget _buildWebViewWidget() { PlatformWebViewWidgetCreationParams params = PlatformWebViewWidgetCreationParams( - controller: _lineSlots[index].controller.platform, + controller: _lineSlot.controller.platform, ); - if (_lineSlots[index].controller.platform is AndroidWebViewController) { + if (_lineSlot.controller.platform is AndroidWebViewController) { params = AndroidWebViewWidgetCreationParams .fromPlatformWebViewWidgetCreationParams( params, @@ -1115,44 +984,12 @@ class _H5ShellPageState extends State { return RepaintBoundary( child: WebViewWidget.fromPlatformCreationParams( - key: ValueKey(index), + key: ValueKey(_lineSlot.line.url), params: params, ), ); } - Future _showLineSwitcher() async { - await _clearAllH5Caches(); - await _refreshAllLineAvailability(); - if (!mounted) { - return; - } - - showModalBottomSheet( - context: context, - useSafeArea: true, - showDragHandle: true, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (sheetContext) { - return _LineSwitcherSheet( - lines: _h5Lines, - slots: _lineSlots, - currentIndex: _currentLineIndex, - onSelect: (index) { - if (_lineSlots[index].availability != _LineAvailability.available) { - return; - } - Navigator.of(sheetContext).pop(); - unawaited(_loadLine(index, forceReload: true)); - }, - ); - }, - ); - } - Future _handleNavigationRequest( NavigationRequest request, ) async { @@ -1190,11 +1027,6 @@ class _H5ShellPageState extends State { final topInset = MediaQuery.paddingOf(context).top; final bottomInset = MediaQuery.viewInsetsOf(context).bottom; _scheduleKeyboardStateSync(bottomInset); - final showLineSwitch = !_isPreparingInitialLine && - !currentSlot.showShellCover && - currentSlot.loadError == null && - currentSlot.isLoginPage && - bottomInset == 0; final shouldPaintShellFallback = (_isPreparingInitialLine || currentSlot.isAwaitingFirstScreen) && currentSlot.loadError == null; @@ -1219,15 +1051,7 @@ class _H5ShellPageState extends State { child: _ShellFallback(progress: currentSlot.progress), ), Positioned.fill( - child: IndexedStack( - index: _currentLineIndex, - children: [ - for (var index = 0; - index < _lineSlots.length; - index += 1) - _buildWebViewWidget(index), - ], - ), + child: _buildWebViewWidget(), ), if (currentSlot.showShellCover && !currentSlot.hasPresentedFirstScreen) @@ -1250,16 +1074,6 @@ class _H5ShellPageState extends State { message: currentSlot.loadError!, onRetry: () => unawaited(_reloadCurrentLine()), ), - if (showLineSwitch) - Positioned( - left: 20, - right: 20, - bottom: MediaQuery.paddingOf(context).bottom + 14, - child: _LineSwitchBar( - currentLine: _currentLine, - onSwitch: () => unawaited(_showLineSwitcher()), - ), - ), ], ), ), @@ -1307,23 +1121,10 @@ class _H5LineWebViewSlot { bool showShellCover = true; bool hasPresentedFirstScreen = false; bool hasLoadedInitialRequest = false; - bool isLoginPage = false; - String? currentUrl; Timer? shellCoverFallbackTimer; bool get isAwaitingFirstScreen => !hasPresentedFirstScreen; - bool setCurrentUrl(String url) { - final nextIsLoginPage = AppConfig.isLoginPageUrl(url); - if (currentUrl == url && isLoginPage == nextIsLoginPage) { - return false; - } - - currentUrl = url; - isLoginPage = nextIsLoginPage; - return true; - } - void dispose() { shellCoverFallbackTimer?.cancel(); } @@ -1370,238 +1171,6 @@ class _ErrorPanel extends StatelessWidget { } } -class _LineSwitchBar extends StatelessWidget { - const _LineSwitchBar({ - required this.currentLine, - required this.onSwitch, - }); - - final H5Line currentLine; - final VoidCallback onSwitch; - - @override - Widget build(BuildContext context) { - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: SizedBox( - width: double.infinity, - child: Material( - color: Colors.white.withValues(alpha: 0.94), - elevation: 8, - shadowColor: const Color(0xFF0F4C81).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(32), - child: InkWell( - onTap: onSwitch, - borderRadius: BorderRadius.circular(32), - child: Container( - height: 64, - padding: const EdgeInsets.symmetric(horizontal: 18), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(32), - border: Border.all(color: const Color(0xFFE1EAF5)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.sync_rounded, - size: 19, - color: _shellSubText, - ), - const SizedBox(width: 8), - const Text( - '当前: ', - style: TextStyle( - color: _shellSubText, - fontSize: 15, - height: 1.2, - fontWeight: FontWeight.w600, - letterSpacing: 0, - ), - ), - Flexible( - child: Text( - currentLine.label, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFF17233D), - fontSize: 16, - height: 1.2, - fontWeight: FontWeight.w700, - letterSpacing: 0, - ), - ), - ), - const SizedBox(width: 12), - const Text( - '切换', - style: TextStyle( - color: _shellAccent, - fontSize: 15, - height: 1.2, - fontWeight: FontWeight.w700, - letterSpacing: 0, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -class _LineSwitcherSheet extends StatelessWidget { - const _LineSwitcherSheet({ - required this.lines, - required this.slots, - required this.currentIndex, - required this.onSelect, - }); - - final List lines; - final List<_H5LineWebViewSlot> slots; - final int currentIndex; - final ValueChanged onSelect; - - @override - Widget build(BuildContext context) { - final sheetHeight = MediaQuery.sizeOf(context).height * 0.58; - final maxHeight = sheetHeight > 420 ? 420.0 : sheetHeight; - - return Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '切换线路', - style: TextStyle( - color: Color(0xFF17233D), - fontSize: 18, - height: 1.2, - fontWeight: FontWeight.w700, - letterSpacing: 0, - ), - ), - const SizedBox(height: 14), - ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), - child: ListView.separated( - shrinkWrap: true, - itemCount: lines.length, - separatorBuilder: (context, index) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final line = lines[index]; - final availability = slots[index].availability; - final selected = index == currentIndex; - - return _LineOptionTile( - line: line, - availability: availability, - selected: selected, - onTap: () => onSelect(index), - ); - }, - ), - ), - ], - ), - ); - } -} - -class _LineOptionTile extends StatelessWidget { - const _LineOptionTile({ - required this.line, - required this.availability, - required this.selected, - required this.onTap, - }); - - final H5Line line; - final _LineAvailability availability; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final enabled = availability == _LineAvailability.available; - final foregroundColor = enabled ? const Color(0xFF17233D) : _shellSubText; - final borderColor = selected && enabled - ? _shellAccent - : enabled - ? const Color(0xFFE1EAF5) - : const Color(0xFFE4E8EE); - - return Material( - color: selected && enabled - ? const Color(0xFFEFF7FF) - : enabled - ? Colors.white - : const Color(0xFFF4F6F9), - borderRadius: BorderRadius.circular(8), - child: InkWell( - onTap: enabled ? onTap : null, - borderRadius: BorderRadius.circular(8), - child: Container( - constraints: const BoxConstraints(minHeight: 58), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: borderColor, - ), - ), - child: Row( - children: [ - Icon( - selected - ? Icons.radio_button_checked_rounded - : Icons.radio_button_unchecked_rounded, - color: selected && enabled ? _shellAccent : _shellSubText, - size: 22, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - line.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: foregroundColor, - fontSize: 16, - height: 1.2, - fontWeight: FontWeight.w700, - letterSpacing: 0, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Icon( - Icons.chevron_right_rounded, - color: enabled ? _shellSubText : const Color(0xFFC1CBD8), - size: 22, - ), - ], - ), - ), - ), - ); - } -} - class _ShellFallback extends StatelessWidget { const _ShellFallback({required this.progress}); diff --git a/test/widget_test.dart b/test/widget_test.dart index 2570572..145afce 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -11,125 +11,58 @@ void main() { ); }); - test('exposes bootstrap H5 URL as Flutter shell lines', () { - final lines = AppConfig.h5Lines; + test('creates one wildcard H5 line with a numeric 16 digit label', () { + final line = AppConfig.h5Line; - expect(lines, isNotEmpty); - expect(lines.first.label, '线路1'); - expect(Uri.parse(lines.first.url).scheme, 'https'); - expect(Uri.parse(lines.first.url).path, '/'); - }); - - test('uses current non-local service origin as default H5 line', () { - final urls = AppConfig.defaultH5LineUrlsForBaseUri( - Uri.parse('https://app.example.com/shell/index.html?from=local#hash'), - ); - - expect(urls, ['https://app.example.com/']); - }); - - test('preserves current service port when building default H5 line', () { - final urls = AppConfig.defaultH5LineUrlsForBaseUri( - Uri.parse('https://chat.example.com:8443/app/'), - ); - - expect(urls, ['https://chat.example.com:8443/']); - }); - - test('keeps fixed bootstrap H5 line for local browser access', () { - final urls = AppConfig.defaultH5LineUrlsForBaseUri( - Uri.parse('http://localhost:3000/'), - ); - - expect(urls, ['https://h5-test.imharry.work/']); - }); - - test('keeps fixed bootstrap H5 line for loopback browser access', () { - final urls = AppConfig.defaultH5LineUrlsForBaseUri( - Uri.parse('http://127.0.0.1:3000/'), - ); - - expect(urls, ['https://h5-test.imharry.work/']); - }); - - test('keeps fixed bootstrap H5 line for native app access', () { - final urls = AppConfig.defaultH5LineUrlsForBaseUri( - Uri.parse('file:///android_asset/flutter_assets/'), - ); - - expect(urls, ['https://h5-test.imharry.work/']); - }); - - test('uses fixed client config query parameters', () { - expect(AppConfig.clientConfigQueryPayload, { - 'device': 'h5', - 'serialNo': 'h5-domain', - }); + expect(line.label, '线路1'); expect( - AppConfig.clientConfigQueryUris.first.path, - '/client_config/query', + line.uri.host, + matches(RegExp(r'^[1-9][0-9]{15}\.(albzyuxq|zdyswvnt|gxifpxcr)\.vip$')), ); }); - test('builds client config query APIs from current service origin', () { - final uris = AppConfig.clientConfigQueryUrisForHomeUri( - Uri.parse('https://app.example.com/app/index.html?from=local#hash'), - ); + test('generates the next single URL from the configured fallback order', () { + var index = 0; + const labels = [ + '1234567890123456', + '2234567890123456', + '3234567890123456', + ]; + final first = AppConfig.createRandomH5Line( + attempt: 0, wildcardFactory: () => labels[index++]); + final second = AppConfig.createRandomH5Line( + attempt: 1, wildcardFactory: () => labels[index++]); + final third = AppConfig.createRandomH5Line( + attempt: 2, wildcardFactory: () => labels[index++]); + expect(first.uri.host, '1234567890123456.albzyuxq.vip'); + expect(second.uri.host, '2234567890123456.zdyswvnt.vip'); + expect(third.uri.host, '3234567890123456.gxifpxcr.vip'); + }); + + test('passes Flutter shell request URL to H5 through query parameters', () { + final line = AppConfig.createRandomH5Line( + wildcardFactory: () => '1234567890123456', + ); + final uri = line.uri; + + expect(uri.queryParameters[AppConfig.flutterShellMarkerQueryKey], '1'); expect( - uris.map((uri) => uri.toString()), - [ - 'https://app.example.com/client_config/query', - 'https://app.example.com/api/user/client_config/query', - ], + uri.queryParameters[AppConfig.flutterShellUrlQueryKey], + 'https://1234567890123456.albzyuxq.vip', + ); + expect( + AppConfig.originForUrl(line.url), + 'https://1234567890123456.albzyuxq.vip', ); }); - test('splits client config resourceUrl into indexed H5 lines', () { - final lines = AppConfig.h5LinesFromResourceUrl( - 'line-one.example\nhttps://line-two.example/app\n\nline-three.example', + test('restores a cached H5 line URL with the single line label', () { + final line = AppConfig.h5LineFromUrl( + 'https://1234567890123456.albzyuxq.vip/?flutter_shell=1', ); - expect(lines.map((line) => line.label), ['线路1', '线路2', '线路3']); - expect(Uri.parse(lines[0].url).host, 'line-one.example'); - expect(Uri.parse(lines[1].url).host, 'line-two.example'); - expect(Uri.parse(lines[1].url).path, '/app'); - expect(Uri.parse(lines[2].url).host, 'line-three.example'); - }); - - test('splits literal backslash-n resourceUrl values', () { - final lines = AppConfig.h5LinesFromResourceUrl( - r'line-one.example\n*.line-two.example\nline-three.example', - wildcardFactory: () => 'abcdefghijklmnop', - ); - - expect(lines.map((line) => line.label), ['线路1', '线路2', '线路3']); - expect(Uri.parse(lines[0].url).host, 'line-one.example'); - expect(Uri.parse(lines[1].url).host, 'abcdefghijklmnop.line-two.example'); - expect(Uri.parse(lines[2].url).host, 'line-three.example'); - }); - - test('expands wildcard H5 domains with a 16 character label', () { - final lines = AppConfig.h5LinesFromResourceUrl( - '*.albzyuxq.vip', - wildcardFactory: () => 'abcdefghijklmnop', - ); - - final host = Uri.parse(lines.single.url).host; - expect(host, 'abcdefghijklmnop.albzyuxq.vip'); - expect(host.split('.').first.length, 16); - }); - - test('detects only H5 login routes for shell line switch display', () { - expect(AppConfig.isLoginPageUrl('https://line-one.example/login'), isTrue); - expect( - AppConfig.isLoginPageUrl('https://line-one.example/app/login'), isTrue); - expect( - AppConfig.isLoginPageUrl('https://line-one.example/#/login'), isTrue); - expect(AppConfig.isLoginPageUrl('https://line-one.example/'), isFalse); - expect( - AppConfig.isLoginPageUrl('https://line-one.example/contact'), isFalse); - expect( - AppConfig.isLoginPageUrl('https://line-one.example/getCode'), isFalse); + expect(line.label, '线路1'); + expect(line.uri.host, '1234567890123456.albzyuxq.vip'); }); }