Files
app/lib/presentation/pages/auth/captcha_challenge_page.dart
T
2026-05-27 19:19:11 +08:00

728 lines
24 KiB
Dart

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<CaptchaChallengePage> createState() => _CaptchaChallengePageState();
}
class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
// ── 移动端 ──
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<void> _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: '<div id="widget"></div>',
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));
}
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
<script>
''',
);
case 'recaptcha':
return _baseHtml(
title: 'reCAPTCHA V2',
body: '<div id="widget"></div>',
script: '''
function onRecaptchaLoad() {
try {
grecaptcha.render('widget', {
sitekey: '${_js(config.siteKey!)}',
callback: function(token) { solved(token); },
'expired-callback': function() { expired(); },
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
});
markStatus('请完成人机验证', false);
} catch (e) {
failed(e && e.message ? e.message : String(e));
}
}
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
<script>
''',
);
case 'cap':
final endpoint = _capEndpoint(config.instanceUrl!, config.siteKey!);
final scriptUrl = _capWidgetScript(config.assetServer);
final safeEndpoint = const HtmlEscape().convert(endpoint);
final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
return _baseHtml(
title: 'Cap',
body:
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
script: '''
window.CAP_DISABLE_HAPTICS = true;
const cap = document.getElementById('cap');
if (cap) {
cap.addEventListener('solve', function(e) {
solved(e.detail && e.detail.token ? e.detail.token : '');
});
cap.addEventListener('progress', function(e) {
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
markStatus('正在验证... ' + progress, false);
sendBridge({ type: 'progress', progress: progress });
});
cap.addEventListener('error', function(e) {
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
failed(message);
});
markStatus('请完成人机验证', false);
}
</script>
<script type="module" src="$safeScriptUrl"></script>
<script>
''',
);
default:
return _baseHtml(
title: '验证码错误',
body:
'<div id="widget" class="error">${const HtmlEscape().convert('不支持的验证码类型: ${config.type}')}</div>',
script: "failed('不支持的验证码类型');",
);
}
}
String _baseHtml({
required String title,
required String body,
required String script,
}) {
final safeTitle = const HtmlEscape().convert(title);
return '''
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>$safeTitle</title>
<style>
html, body {
margin: 0;
padding: 0;
min-height: 100%;
background: #ffffff;
color: #0f172a;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
}
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.card {
width: 100%;
max-width: 380px;
border: 1px solid #e5e7eb;
border-radius: 18px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
padding: 24px;
box-sizing: border-box;
}
h1 {
font-size: 18px;
margin: 0 0 8px;
text-align: center;
color: #111827;
}
p {
font-size: 13px;
color: #64748b;
text-align: center;
margin: 0 0 20px;
line-height: 1.5;
}
#widget {
display: flex;
justify-content: center;
min-height: 78px;
align-items: center;
}
.status {
margin-top: 14px;
font-size: 12px;
text-align: center;
color: #64748b;
word-break: break-word;
}
.error {
color: #dc2626;
}
cap-widget {
display: block;
margin: 0 auto;
}
</style>
</head>
<body>
<div class="page">
<div class="card">
<h1>$safeTitle</h1>
<p>完成验证后会自动返回登录页。</p>
$body
<div id="status" class="status">正在加载验证码...</div>
</div>
</div>
<script>
function sendBridge(payload) {
payload._jsTs = Date.now();
var json = JSON.stringify(payload);
try {
if (typeof CaptchaBridge !== 'undefined' && CaptchaBridge.postMessage) {
CaptchaBridge.postMessage(json);
} else if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) {
window.flutter_inappwebview.callHandler('CaptchaBridge', json);
}
} catch (e) {}
}
function markStatus(text, isError) {
var el = document.getElementById('status');
if (!el) return;
el.textContent = text || '';
el.className = isError ? 'status error' : 'status';
}
function solved(token) {
markStatus('验证完成,正在返回...', false);
sendBridge({ type: 'success', token: token });
}
function failed(message) {
markStatus(message || '验证码加载失败', true);
sendBridge({ type: 'error', message: message || '验证码加载失败' });
}
function expired() {
markStatus('验证码已过期,请重新验证', true);
sendBridge({ type: 'expired' });
}
$script
</script>
</body>
</html>
''';
}
// ═════════════════════════════════════════════════════
// 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');
}
}