diff --git a/.gitignore b/.gitignore index a0462f3..d6ed355 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ migrate_working_dir/ .frontend/ # custom +.claude/ CLAUDE.md claude_project.md PROJECT_REQUIREMENTS.md @@ -67,6 +68,7 @@ native/logs sync_refactory.md tools/* *.diff +android/app/src/main/jniLibs/* # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7c15f2d..5cf6d6e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -30,6 +30,14 @@ if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } +val releaseStoreFile = keystoreProperties.getProperty("storeFile")?.let { rootProject.file(it) } +val hasReleaseSigning = listOf( + "keyAlias", + "keyPassword", + "storePassword", +).all { !keystoreProperties.getProperty(it).isNullOrBlank() } && + releaseStoreFile?.exists() == true + android { namespace = "com.limo.cloudreve4_flutter" compileSdk = getLocalProperty("flutter.compileSdkVersion", 36) @@ -38,10 +46,12 @@ android { // 2. 配置签名选项 signingConfigs { create("release") { - keyAlias = keystoreProperties["keyAlias"] as String? - keyPassword = keystoreProperties["keyPassword"] as String? - storeFile = keystoreProperties["storeFile"]?.let { file(it) } - storePassword = keystoreProperties["storePassword"] as String? + if (hasReleaseSigning) { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = releaseStoreFile + storePassword = keystoreProperties.getProperty("storePassword") + } } } @@ -54,15 +64,25 @@ android { jvmTarget = JavaVersion.VERSION_17.toString() } + sourceSets { + getByName("main") { + jniLibs.srcDirs("src/main/jniLibs") + } + } + defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.limo.cloudreve4_flutter" + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = getLocalProperty("flutter.minSdkVersion", 24) targetSdk = getLocalProperty("flutter.targetSdkVersion", 35) versionCode = flutter.versionCode versionName = flutter.versionName + + ndk { + abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a")) + } } packaging { @@ -76,7 +96,7 @@ android { buildTypes { release { // 1. 将原来的 getByName("debug") 替换为 getByName("release") - signingConfig = signingConfigs.getByName("release") + signingConfig = signingConfigs.getByName(if (hasReleaseSigning) "release" else "debug") // 2. 建议同时开启混淆,这能大幅减小网盘应用的体积并保护代码 isMinifyEnabled = true diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..d5109c3 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx3072m -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +org.gradle.workers.max=2 +org.gradle.vfs.watch=false +kotlin.compiler.execution.strategy=in-process +kotlin.incremental=false android.useAndroidX=true diff --git a/lib/core/constants/storage_keys.dart b/lib/core/constants/storage_keys.dart index 564f5f2..e76a5ed 100644 --- a/lib/core/constants/storage_keys.dart +++ b/lib/core/constants/storage_keys.dart @@ -35,4 +35,8 @@ class StorageKeys { // 日志级别 static const String logLevel = 'app_log_level'; + + // 公告 + static const String siteAnnouncementDismissedFingerprint = + 'site_announcement_dismissed_fingerprint'; } diff --git a/lib/core/utils/file_utils.dart b/lib/core/utils/file_utils.dart index ca09858..80a6fc2 100644 --- a/lib/core/utils/file_utils.dart +++ b/lib/core/utils/file_utils.dart @@ -55,12 +55,42 @@ class FileUtils { 'bmp', 'svg', 'heic', + 'heif', + 'avif', + 'tif', + 'tiff', ]; return imageExtensions.contains(getFileExtension(fileName)); } + static bool isFlutterRenderableImageFile(String fileName) { + const renderableImageExtensions = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'bmp', + ]; + return renderableImageExtensions.contains(getFileExtension(fileName)); + } + static bool isVideoFile(String fileName) { - const videoExtensions = ['mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv']; + const videoExtensions = [ + 'mp4', + 'webm', + 'mkv', + 'avi', + 'mov', + 'flv', + 'wmv', + 'm4v', + 'mpg', + 'mpeg', + '3gp', + 'ts', + 'm2ts', + ]; return videoExtensions.contains(getFileExtension(fileName)); } @@ -69,6 +99,17 @@ class FileUtils { return audioExtensions.contains(getFileExtension(fileName)); } + static bool isPsdFile(String fileName) { + const psdExtensions = ['psd', 'psb']; + return psdExtensions.contains(getFileExtension(fileName)); + } + + static bool isThumbnailableFile(String fileName) { + final ext = getFileExtension(fileName); + if (ext.isEmpty || ext == 'svg') return false; + return isImageFile(fileName) || isVideoFile(fileName) || isPsdFile(fileName); + } + static bool isPdfFile(String fileName) { return getFileExtension(fileName) == 'pdf'; } @@ -155,13 +196,33 @@ class FileUtils { 'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml', + 'bmp': 'image/bmp', + 'heic': 'image/heic', + 'heif': 'image/heif', + 'avif': 'image/avif', + 'tif': 'image/tiff', + 'tiff': 'image/tiff', + 'psd': 'image/vnd.adobe.photoshop', + 'psb': 'image/vnd.adobe.photoshop', 'mp4': 'video/mp4', 'webm': 'video/webm', 'mkv': 'video/x-matroska', 'avi': 'video/x-msvideo', + 'mov': 'video/quicktime', + 'flv': 'video/x-flv', + 'wmv': 'video/x-ms-wmv', + 'm4v': 'video/x-m4v', + 'mpg': 'video/mpeg', + 'mpeg': 'video/mpeg', + '3gp': 'video/3gpp', + 'ts': 'video/mp2t', + 'm2ts': 'video/mp2t', 'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'ogg': 'audio/ogg', + 'flac': 'audio/flac', + 'aac': 'audio/aac', + 'm4a': 'audio/mp4', 'pdf': 'application/pdf', 'txt': 'text/plain', 'json': 'application/json', diff --git a/lib/core/utils/win_env.dart b/lib/core/utils/win_env.dart new file mode 100644 index 0000000..397050b --- /dev/null +++ b/lib/core/utils/win_env.dart @@ -0,0 +1,26 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; + +/// 通过 Win32 SetEnvironmentVariableW 设置/清除进程环境变量 +/// 仅 Windows 有效,其他平台直接返回 false +bool winSetEnvVar(String name, String? value) { + if (!Platform.isWindows) return false; + + final dylib = DynamicLibrary.open('kernel32.dll'); + final fn = dylib.lookupFunction< + Int32 Function(Pointer, Pointer), + int Function(Pointer, Pointer)>( + 'SetEnvironmentVariableW', + ); + + final namePtr = name.toNativeUtf16(); + final valuePtr = value != null ? value.toNativeUtf16() : nullptr; + try { + return fn(namePtr, valuePtr) != 0; + } finally { + calloc.free(namePtr); + if (value != null) calloc.free(valuePtr); + } +} diff --git a/lib/presentation/pages/auth/captcha_challenge_page.dart b/lib/presentation/pages/auth/captcha_challenge_page.dart index b0b0545..5f55c89 100644 --- a/lib/presentation/pages/auth/captcha_challenge_page.dart +++ b/lib/presentation/pages/auth/captcha_challenge_page.dart @@ -1,7 +1,59 @@ 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:webview_flutter/webview_flutter.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; @@ -21,12 +73,20 @@ class CaptchaWebConfig { const CaptchaWebConfig.recaptchaV2({ required String siteKey, String displayName = 'reCAPTCHA V2', - }) : this._(type: 'recaptcha', displayName: displayName, siteKey: siteKey); + }) : this._( + type: 'recaptcha', + displayName: displayName, + siteKey: siteKey, + ); const CaptchaWebConfig.turnstile({ required String siteKey, String displayName = 'Cloudflare Turnstile', - }) : this._(type: 'turnstile', displayName: displayName, siteKey: siteKey); + }) : this._( + type: 'turnstile', + displayName: displayName, + siteKey: siteKey, + ); const CaptchaWebConfig.cap({ required String instanceUrl, @@ -34,22 +94,36 @@ class CaptchaWebConfig { String? assetServer, String displayName = 'Cap', }) : this._( - type: 'cap', - displayName: displayName, - instanceUrl: instanceUrl, - siteKey: siteKey, - assetServer: assetServer, - ); + 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 @@ -57,61 +131,98 @@ class CaptchaChallengePage extends StatefulWidget { } class _CaptchaChallengePageState extends State { - late final WebViewController _controller; + // ── 移动端 ── + 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); - _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(); - }); - } - }, - ), - ); + // Windows: 在 WebView2 创建前设置代理环境变量 + if (_isDesktop && widget.proxyConfig != null && Platform.isWindows) { + _applyWebView2Proxy(widget.proxyConfig); + _proxyEnvSet = true; + } - _loadCaptcha(); + 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); }); - final html = _buildHtml(widget.config); - await _controller.loadHtmlString(html, baseUrl: widget.baseUrl); + 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; @@ -120,8 +231,18 @@ class _CaptchaChallengePageState extends State { if (type == 'success') { final token = decoded['token']?.toString() ?? ''; - if (token.isNotEmpty && mounted) { + 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; } @@ -130,7 +251,8 @@ class _CaptchaChallengePageState extends State { final progress = decoded['progress']?.toString(); if (mounted) { setState(() { - _statusText = progress == null ? '正在验证...' : '正在验证... $progress'; + _statusText = + progress == null ? '正在验证...' : '正在验证... $progress'; }); } return; @@ -145,6 +267,11 @@ class _CaptchaChallengePageState extends State { return; } + if (type == 'debug') { + AppLogger.d('JS debug: ${decoded['message']}'); + return; + } + if (type == 'expired') { if (mounted) { setState(() { @@ -153,25 +280,131 @@ class _CaptchaChallengePageState extends State { } return; } - } catch (_) { - // 忽略非 JSON 消息。 + } 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 _buildTurnstileHtml(config.siteKey!); + 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)); + } + } + + + + + + + - - - - - -