import 'dart:convert'; import 'dart:io'; import 'package:cloudreve4_flutter/core/utils/app_logger.dart'; import 'package:cloudreve4_flutter/core/utils/win_env.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:webview_flutter/webview_flutter.dart' as mobile; // ═════════════════════════════════════════════════════ // WebView 代理配置(仅 Windows,无认证) // ═════════════════════════════════════════════════════ class CaptchaProxyConfig { final String host; final int port; const CaptchaProxyConfig({required this.host, required this.port}); String get proxyArg => '--proxy-server=http://$host:$port'; @override String toString() => '$host:$port'; } // ═════════════════════════════════════════════════════ // WebView2 代理环境变量管理 // ═════════════════════════════════════════════════════ const _envVarName = 'WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS'; /// 为 WebView2 设置代理环境变量(进程级,不影响其他程序) void _applyWebView2Proxy(CaptchaProxyConfig? proxy) { if (!Platform.isWindows) return; if (proxy != null) { final existing = Platform.environment[_envVarName]; final newValue = existing != null && existing.isNotEmpty ? '$existing ${proxy.proxyArg}' : proxy.proxyArg; winSetEnvVar(_envVarName, newValue); AppLogger.i('WebView2 代理环境变量已设置: $newValue'); } } /// 清除 WebView2 代理环境变量 void _clearWebView2Proxy() { if (!Platform.isWindows) return; winSetEnvVar(_envVarName, null); AppLogger.i('WebView2 代理环境变量已清除'); } // ═════════════════════════════════════════════════════ // CaptchaWebConfig // ═════════════════════════════════════════════════════ class CaptchaWebConfig { final String type; final String displayName; final String? siteKey; final String? instanceUrl; final String? assetServer; const CaptchaWebConfig._({ required this.type, required this.displayName, this.siteKey, this.instanceUrl, this.assetServer, }); const CaptchaWebConfig.recaptchaV2({ required String siteKey, String displayName = 'reCAPTCHA V2', }) : this._( type: 'recaptcha', displayName: displayName, siteKey: siteKey, ); const CaptchaWebConfig.turnstile({ required String siteKey, String displayName = 'Cloudflare Turnstile', }) : this._( type: 'turnstile', displayName: displayName, siteKey: siteKey, ); const CaptchaWebConfig.cap({ required String instanceUrl, required String siteKey, String? assetServer, String displayName = 'Cap', }) : this._( type: 'cap', displayName: displayName, instanceUrl: instanceUrl, siteKey: siteKey, assetServer: assetServer, ); } // ─── 桌面端判断 ─────────────────────────────────────── bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux); // ─── 桌面 User-Agent ────────────────────────────────── const _desktopUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'; // ═════════════════════════════════════════════════════ // CaptchaChallengePage // ═════════════════════════════════════════════════════ class CaptchaChallengePage extends StatefulWidget { final CaptchaWebConfig config; final String baseUrl; final CaptchaProxyConfig? proxyConfig; const CaptchaChallengePage({ super.key, required this.config, required this.baseUrl, this.proxyConfig, }); @override State createState() => _CaptchaChallengePageState(); } class _CaptchaChallengePageState extends State { // ── 移动端 ── mobile.WebViewController? _mobileController; // ── 桌面端 ── InAppWebViewController? _desktopController; Key _desktopKey = UniqueKey(); // ── 共享状态 ── bool _isLoading = true; int _progress = 0; String? _errorMessage; String? _statusText; bool _disposed = false; // ── 是否设置了代理环境变量(用于清理时判断)── bool _proxyEnvSet = false; // ── HTML ── late String _currentHtml; @override void initState() { super.initState(); _currentHtml = _buildHtml(widget.config); // Windows: 在 WebView2 创建前设置代理环境变量 if (_isDesktop && widget.proxyConfig != null && Platform.isWindows) { _applyWebView2Proxy(widget.proxyConfig); _proxyEnvSet = true; } if (!_isDesktop) { _mobileController = mobile.WebViewController() ..setJavaScriptMode(mobile.JavaScriptMode.unrestricted) ..setBackgroundColor(Colors.transparent) ..addJavaScriptChannel( 'CaptchaBridge', onMessageReceived: (message) { _handleBridgeMessage(message.message); }, ) ..setNavigationDelegate( mobile.NavigationDelegate( onProgress: (progress) { if (mounted) setState(() => _progress = progress); }, onPageFinished: (_) { if (mounted) setState(() => _isLoading = false); }, onWebResourceError: (error) { if (mounted) { setState(() { _isLoading = false; _errorMessage = '${error.errorCode}: ${error.description}'.trim(); }); } }, ), ) ..loadHtmlString(_currentHtml, baseUrl: widget.baseUrl); } } // ─── 加载 / 刷新 ──────────────────────────────────── Future _loadCaptcha() async { if (_disposed) return; setState(() { _isLoading = true; _errorMessage = null; _statusText = null; _progress = 0; _currentHtml = _buildHtml(widget.config); }); if (!_isDesktop && _mobileController != null) { await _mobileController!.loadHtmlString( _currentHtml, baseUrl: widget.baseUrl, ); } else if (_isDesktop) { setState(() => _desktopKey = UniqueKey()); } } // ─── Bridge 消息处理 ───────────────────────────────── void _handleBridgeMessage(String rawMessage) { AppLogger.d( 'Bridge 收到消息: ${rawMessage.length > 100 ? "${rawMessage.substring(0, 100)}..." : rawMessage}', ); try { final decoded = jsonDecode(rawMessage); if (decoded is! Map) return; final type = decoded['type']?.toString(); if (type == 'success') { final token = decoded['token']?.toString() ?? ''; final jsTs = decoded['_jsTs']; if (jsTs is num) { final delayMs = DateTime.now().millisecondsSinceEpoch - jsTs.toInt(); AppLogger.d('Bridge 收到 success, JS→Dart 传输延迟=${delayMs}ms, token长度=${token.length}'); } else { AppLogger.d('Bridge 收到 success, token长度=${token.length}'); } if (token.isNotEmpty && mounted && !_disposed) { _disposed = true; AppLogger.d('准备 pop 返回登录页'); Navigator.of(context).pop(token); AppLogger.d('pop 完成'); } return; } if (type == 'progress') { final progress = decoded['progress']?.toString(); if (mounted) { setState(() { _statusText = progress == null ? '正在验证...' : '正在验证... $progress'; }); } return; } if (type == 'error') { if (mounted) { setState(() { _errorMessage = decoded['message']?.toString() ?? '验证码加载失败'; }); } return; } if (type == 'debug') { AppLogger.d('JS debug: ${decoded['message']}'); return; } if (type == 'expired') { if (mounted) { setState(() { _statusText = '验证码已过期,请重新验证'; }); } return; } } catch (_) {} } // ─── WebView 销毁 ─────────────────────────────────── void _cleanupWebView() { if (_isDesktop) { final ctrl = _desktopController; _desktopController = null; AppLogger.d('开始清理 WebView controller'); ctrl?.dispose(); AppLogger.d('WebView controller 已 dispose'); if (_proxyEnvSet) { _clearWebView2Proxy(); _proxyEnvSet = false; } } } @override void dispose() { AppLogger.d('CaptchaChallengePage dispose 开始'); _disposed = true; _cleanupWebView(); super.dispose(); AppLogger.d('CaptchaChallengePage dispose 完成'); } // ═════════════════════════════════════════════════════ // HTML 生成 // ═════════════════════════════════════════════════════ String _buildHtml(CaptchaWebConfig config) { switch (config.type) { case 'turnstile': return _baseHtml( title: 'Cloudflare Turnstile', body: '
', script: ''' function onTurnstileLoad() { try { turnstile.render('#widget', { sitekey: '${_js(config.siteKey!)}', callback: function(token) { solved(token); }, 'error-callback': function() { failed('Turnstile 验证失败,请重试'); }, 'expired-callback': function() { expired(); }, 'after-interactive-callback': function() { sendBridge({ type: 'debug', message: 'after-interactive fired' }); markStatus('正在与 Cloudflare 服务器验证,请稍候...', false); sendBridge({ type: 'progress', progress: '服务器验证中' }); } }); markStatus('请完成人机验证', false); } catch (e) { failed(e && e.message ? e.message : String(e)); } } '''; } // ═════════════════════════════════════════════════════ // Widget 构建 // ═════════════════════════════════════════════════════ @override Widget build(BuildContext context) { final title = widget.config.displayName; return Scaffold( appBar: AppBar( title: Text(title), actions: [ IconButton( icon: const Icon(Icons.refresh), tooltip: '刷新验证码', onPressed: _loadCaptcha, ), ], bottom: PreferredSize( preferredSize: const Size.fromHeight(3), child: _progress > 0 && _progress < 100 ? LinearProgressIndicator(value: _progress / 100) : const SizedBox(height: 3), ), ), body: Stack( children: [ _isDesktop ? _buildDesktopWebView() : _buildMobileWebView(), if (_isLoading) const Center(child: CircularProgressIndicator()), if (_errorMessage != null) _buildOverlayBanner( child: Text( _errorMessage!, textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context).colorScheme.onErrorContainer, ), ), color: Theme.of(context).colorScheme.errorContainer, ) else if (_statusText != null) _buildOverlayBanner( child: Text( _statusText!, textAlign: TextAlign.center, ), ), ], ), ); } // ── 移动端 WebView ────────────────────────────────── Widget _buildMobileWebView() { return mobile.WebViewWidget(controller: _mobileController!); } // ── 桌面端 WebView ────────────────────────────────── Widget _buildDesktopWebView() { AppLogger.i('WebView 验证码 BaseUrl: ${WebUri(widget.baseUrl)}'); return InAppWebView( key: _desktopKey, initialSettings: InAppWebViewSettings( javaScriptEnabled: true, domStorageEnabled: true, safeBrowsingEnabled: true, isInspectable: true, cacheMode: CacheMode.LOAD_NO_CACHE, supportMultipleWindows: true, allowUniversalAccessFromFileURLs: true, allowFileAccessFromFileURLs: true, userAgent: _desktopUserAgent, transparentBackground: true, supportZoom: false, useHybridComposition: true, ), onWebViewCreated: (controller) { _desktopController = controller; controller.addJavaScriptHandler( handlerName: 'CaptchaBridge', callback: (args) { if (args.isNotEmpty) { _handleBridgeMessage(args[0].toString()); } }, ); controller.loadUrl( urlRequest: URLRequest( url: WebUri("${widget.baseUrl}/virtual_captcha.html"), ), ); }, shouldInterceptRequest: (controller, request) async { if (request.url.toString().contains('virtual_captcha.html')) { AppLogger.i('黑魔法 -> 拦截成功,正在注入动态 HTML'); return WebResourceResponse( contentType: 'text/html', contentEncoding: 'utf-8', data: Uint8List.fromList(utf8.encode(_currentHtml)), statusCode: 200, reasonPhrase: 'OK', ); } if (request.url.toString().contains('/h/b/rc') && request.method == 'POST') { AppLogger.w('发现黑魔法后遗症校验请求 (POST),执行强制 404'); await Future.delayed(Duration(seconds: 2)); return WebResourceResponse( contentType: 'text/plain', statusCode: 404, reasonPhrase: 'Not Found', data: Uint8List(0), ); } return null; }, onLoadStart: (controller, url) {}, onLoadStop: (controller, url) { if (mounted) setState(() => _isLoading = false); }, onProgressChanged: (controller, progress) { if (mounted) setState(() => _progress = progress); }, onReceivedError: (controller, request, error) { if (mounted) { setState(() { _isLoading = false; _errorMessage = '${error.type}: ${error.description}'.trim(); }); } }, ); } // ── 通用底部提示条 ────────────────────────────────── Widget _buildOverlayBanner({required Widget child, Color? color}) { return Align( alignment: Alignment.bottomCenter, child: SafeArea( child: Container( width: double.infinity, margin: const EdgeInsets.all(12), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: color ?? Theme.of(context) .colorScheme .surfaceContainerHighest .withValues(alpha: 0.92), borderRadius: BorderRadius.circular(12), ), child: child, ), ), ); } // ═════════════════════════════════════════════════════ // 辅助 // ═════════════════════════════════════════════════════ String _capEndpoint(String instanceUrl, String siteKey) { final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), ''); final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), ''); return '$trimmedInstance/$trimmedSiteKey/'; } String _capWidgetScript(String? assetServer) { final asset = assetServer?.trim(); if (asset != null && asset.isNotEmpty) { if (asset.startsWith('http://') || asset.startsWith('https://')) { return asset; } if (asset.toLowerCase() == 'jsdelivr') { return 'https://cdn.jsdelivr.net/npm/cap-widget'; } if (asset.toLowerCase() == 'unpkg') { return 'https://unpkg.com/cap-widget'; } } return 'https://cdn.jsdelivr.net/npm/cap-widget'; } String _js(String input) { return input .replaceAll(r'\', r'\\') .replaceAll("'", r"\'") .replaceAll('\n', r'\n') .replaceAll('\r', r'\r'); } }