import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; 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, ); } class CaptchaChallengePage extends StatefulWidget { final CaptchaWebConfig config; final String baseUrl; const CaptchaChallengePage({ super.key, required this.config, required this.baseUrl, }); @override State createState() => _CaptchaChallengePageState(); } class _CaptchaChallengePageState extends State { late final WebViewController _controller; bool _isLoading = true; int _progress = 0; String? _errorMessage; String? _statusText; @override void initState() { super.initState(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(Colors.transparent) ..addJavaScriptChannel( 'CaptchaBridge', onMessageReceived: (message) { _handleBridgeMessage(message.message); }, ) ..setNavigationDelegate( 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(); }); } }, ), ); _loadCaptcha(); } Future _loadCaptcha() async { setState(() { _isLoading = true; _errorMessage = null; _statusText = null; _progress = 0; }); final html = _buildHtml(widget.config); await _controller.loadHtmlString(html, baseUrl: widget.baseUrl); } void _handleBridgeMessage(String rawMessage) { try { final decoded = jsonDecode(rawMessage); if (decoded is! Map) return; final type = decoded['type']?.toString(); if (type == 'success') { final token = decoded['token']?.toString() ?? ''; if (token.isNotEmpty && mounted) { Navigator.of(context).pop(token); } 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 == 'expired') { if (mounted) { setState(() { _statusText = '验证码已过期,请重新验证'; }); } return; } } catch (_) { // 忽略非 JSON 消息。 } } String _buildHtml(CaptchaWebConfig config) { switch (config.type) { case 'turnstile': return _buildTurnstileHtml(config.siteKey!); case 'recaptcha': return _buildRecaptchaHtml(config.siteKey!); case 'cap': return _buildCapHtml( instanceUrl: config.instanceUrl!, siteKey: config.siteKey!, assetServer: config.assetServer, ); default: return _buildErrorHtml('不支持的验证码类型: ${config.type}'); } } String _baseHtml({ required String title, required String body, required String script, }) { final safeTitle = const HtmlEscape().convert(title); return ''' $safeTitle

$safeTitle

完成验证后会自动返回登录页。

$body
正在加载验证码...
'''; } String _buildTurnstileHtml(String siteKey) { return _baseHtml( title: 'Cloudflare Turnstile', body: '
', script: ''' function onTurnstileLoad() { try { turnstile.render('#widget', { sitekey: '${_js(siteKey)}', callback: function(token) { solved(token); }, 'error-callback': function() { failed('Turnstile 验证失败,请重试'); }, 'expired-callback': function() { expired(); } }); markStatus('请完成人机验证', false); } catch (e) { failed(e && e.message ? e.message : String(e)); } }