diff --git a/README.md b/README.md index b7d251c..06a99a2 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ and easy to use. * Domain name filtering: Only intercept the traffic you need, and do not intercept other traffic to avoid interference with other applications. * Search: Search requests according to keywords, response types and other conditions * Script: Support writing JavaScript scripts to process requests or responses. -* Request rewrite: Support redirection, support replacement of request or response message, and can also modify request or response according to the increase. -* Request mapping: Do not request remote services, use local configuration or scripts for response -* Request blocking: Support blocking requests according to URL, and do not send requests to the server. +* Request Rewrite: Support redirection, support replacement of request or response message, and can also modify request or response according to the increase. +* Request Mapping: Do not request remote services, use local configuration or scripts for response +* Request Decryption: Configure AES decryption key to automatically decrypt HTTP message body +* Request Blocking: Support blocking requests according to URL, and do not send requests to the server. * History: Automatically save the captured traffic data for easy backtracking and viewing. Support HAR format export and import. * Others: Favorites, toolbox, common encoding tools, as well as QR codes, regular expressions, etc. diff --git a/README_CN.md b/README_CN.md index 53c07a5..42d5721 100644 --- a/README_CN.md +++ b/README_CN.md @@ -13,6 +13,7 @@ * 脚本: 支持编写JavaScript脚本来处理请求或响应。 * 请求重写: 支持重定向,支持替换请求或响应报文,也可以根据增则修改请求或或响应。 * 请求映射: 不请求远程服务,使用本地配置或脚本进行响应 +* 请求解密: 配置AES解密密钥,自动解密HTTP消息体 * 请求屏蔽: 支持根据URL屏蔽请求,不让请求发送到服务器。 * 历史记录:自动保存抓包的流量数据,方便回溯查看。支持HAR格式导出与导入。 * 其他:收藏、工具箱、常用编码工具、以及二维码、正则等 diff --git a/android/app/src/main/kotlin/com/network/proxy/plugin/InstalledAppsPlugin.kt b/android/app/src/main/kotlin/com/network/proxy/plugin/InstalledAppsPlugin.kt index 2b3373f..fd1bffc 100644 --- a/android/app/src/main/kotlin/com/network/proxy/plugin/InstalledAppsPlugin.kt +++ b/android/app/src/main/kotlin/com/network/proxy/plugin/InstalledAppsPlugin.kt @@ -28,7 +28,16 @@ class InstalledAppsPlugin : AndroidFlutterPlugin() { "getInstalledApps" -> { val withIcon = call.argument("withIcon") ?: false val packageNamePrefix = call.argument("packageNamePrefix") ?: "" - result.success(getInstalledApps(withIcon, packageNamePrefix)) + val includeSystemApps = call.argument("includeSystemApps") ?: false + Thread { + result.success( + getInstalledApps( + withIcon, + packageNamePrefix, + includeSystemApps + ) + ) + }.start() } "getAppInfo" -> { @@ -48,27 +57,33 @@ class InstalledAppsPlugin : AndroidFlutterPlugin() { } } + private fun isSystemApp(applicationInfo: ApplicationInfo?): Boolean { + if (applicationInfo == null) return false + return (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 + } + private fun getInstalledApps( withIcon: Boolean, - packageNamePrefix: String + packageNamePrefix: String, + includeSystemApps: Boolean ): List { val packageManager = activity.packageManager var installedApps = packageManager.getInstalledApplications(0) - installedApps = - installedApps.filter { app -> - (app.flags and ApplicationInfo.FLAG_SYSTEM) <= 0 - || (app.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0 - || packageManager.getLaunchIntentForPackage(app.packageName) != null - } - if (packageNamePrefix.isNotEmpty()) + if (!includeSystemApps) { + installedApps = + installedApps.filter { app -> !isSystemApp(app) } + } + + if (packageNamePrefix.isNotEmpty()) { installedApps = installedApps.filter { app -> app.packageName.startsWith( packageNamePrefix.lowercase(Locale.ENGLISH) ) } + } - val threadPoolExecutor = Executors.newFixedThreadPool(6) + val threadPoolExecutor = Executors.newFixedThreadPool(4) installedApps.map { app -> val task: Callable = Callable { ProcessInfo.create(packageManager, app, withIcon) @@ -84,4 +99,3 @@ class InstalledAppsPlugin : AndroidFlutterPlugin() { } } - diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 43c52aa..1b842bc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -357,5 +357,18 @@ "sponsorBuyMeCoffee": "Buy Me a Coffee", "privacyPolicy": "Privacy Policy", - "privacyContent": "This open-source packet capture tool runs entirely on your device. It has no backend server and does not collect, store, or upload any personal data. All captured traffic is processed locally and is only forwarded when you explicitly use remote forwarding. Permissions (e.g., network, storage, and camera for QR codes) are used solely to provide features. You can audit the behavior in the public source code." + "privacyContent": "This open-source packet capture tool runs entirely on your device. It has no backend server and does not collect, store, or upload any personal data. All captured traffic is processed locally and is only forwarded when you explicitly use remote forwarding. Permissions (e.g., network, storage, and camera for QR codes) are used solely to provide features. You can audit the behavior in the public source code.", + + "requestCrypto": "Request Crypto", + "cryptoDecoded": "Decoded", + "cryptoDecodeToggle": "Decrypt", + "optional": "Optional", + "cryptoRuleField": "Field Name", + + "cryptoIvPrefixLabel": "IV Prefix", + "cryptoIvPrefixTooltip": "Use the first N bytes of the response body as IV", + + "local": "Local", + "remoteUrl": "Remote URL", + "view": "View" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ed4c358..b3c00ff 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2091,6 +2091,66 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'This open-source packet capture tool runs entirely on your device. It has no backend server and does not collect, store, or upload any personal data. All captured traffic is processed locally and is only forwarded when you explicitly use remote forwarding. Permissions (e.g., network, storage, and camera for QR codes) are used solely to provide features. You can audit the behavior in the public source code.'** String get privacyContent; + + /// No description provided for @requestCrypto. + /// + /// In en, this message translates to: + /// **'Request Crypto'** + String get requestCrypto; + + /// No description provided for @cryptoDecoded. + /// + /// In en, this message translates to: + /// **'Decoded'** + String get cryptoDecoded; + + /// No description provided for @cryptoDecodeToggle. + /// + /// In en, this message translates to: + /// **'Decrypt'** + String get cryptoDecodeToggle; + + /// No description provided for @optional. + /// + /// In en, this message translates to: + /// **'Optional'** + String get optional; + + /// No description provided for @cryptoRuleField. + /// + /// In en, this message translates to: + /// **'Field Name'** + String get cryptoRuleField; + + /// No description provided for @cryptoIvPrefixLabel. + /// + /// In en, this message translates to: + /// **'IV Prefix'** + String get cryptoIvPrefixLabel; + + /// No description provided for @cryptoIvPrefixTooltip. + /// + /// In en, this message translates to: + /// **'Use the first N bytes of the response body as IV'** + String get cryptoIvPrefixTooltip; + + /// No description provided for @local. + /// + /// In en, this message translates to: + /// **'Local'** + String get local; + + /// No description provided for @remoteUrl. + /// + /// In en, this message translates to: + /// **'Remote URL'** + String get remoteUrl; + + /// No description provided for @view. + /// + /// In en, this message translates to: + /// **'View'** + String get view; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0817873..e0e8560 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1019,4 +1019,34 @@ class AppLocalizationsEn extends AppLocalizations { @override String get privacyContent => 'This open-source packet capture tool runs entirely on your device. It has no backend server and does not collect, store, or upload any personal data. All captured traffic is processed locally and is only forwarded when you explicitly use remote forwarding. Permissions (e.g., network, storage, and camera for QR codes) are used solely to provide features. You can audit the behavior in the public source code.'; + + @override + String get requestCrypto => 'Request Crypto'; + + @override + String get cryptoDecoded => 'Decoded'; + + @override + String get cryptoDecodeToggle => 'Decrypt'; + + @override + String get optional => 'Optional'; + + @override + String get cryptoRuleField => 'Field Name'; + + @override + String get cryptoIvPrefixLabel => 'IV Prefix'; + + @override + String get cryptoIvPrefixTooltip => 'Use the first N bytes of the response body as IV'; + + @override + String get local => 'Local'; + + @override + String get remoteUrl => 'Remote URL'; + + @override + String get view => 'View'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index af08f12..5692af2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -987,10 +987,10 @@ class AppLocalizationsZh extends AppLocalizations { String get requestMapDescribe => '不请求远程服务,使用本地配置或脚本进行响应'; @override - String get automatic => '自动安装'; + String get automatic => '自动'; @override - String get manual => '手动安装'; + String get manual => '手动'; @override String get certNotInstalled => '证书未安装'; @@ -1019,6 +1019,36 @@ class AppLocalizationsZh extends AppLocalizations { @override String get privacyContent => '本项目为开源抓包工具,所有功能均在本地设备上运行;无任何后端服务器,不会收集、存储或上传任何用户信息。抓取的网络数据仅在本地处理,除非您主动使用远程转发功能。所需权限(如网络、存储、相机用于扫码)仅用于实现相应功能。您可在公开的源代码中审计其行为。'; + + @override + String get requestCrypto => '请求解密'; + + @override + String get cryptoDecoded => '已解密'; + + @override + String get cryptoDecodeToggle => '解密'; + + @override + String get optional => '可选'; + + @override + String get cryptoRuleField => '字段名称'; + + @override + String get cryptoIvPrefixLabel => 'IV 前缀'; + + @override + String get cryptoIvPrefixTooltip => '使用响应体前 N 个字节作为 IV'; + + @override + String get local => '本地'; + + @override + String get remoteUrl => '远程URL'; + + @override + String get view => '查看'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -2040,4 +2070,34 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get privacyContent => '本專案為開源抓包工具,所有功能均在本機裝置上運行;無任何後端伺服器,不會蒐集、儲存或上傳任何使用者資訊。擷取的網路資料僅在本機處理,除非您主動使用遠端轉發功能。所需權限(如網路、儲存、相機用於掃碼)僅用於實現相應功能。您可在公開的原始碼中稽核其行為。'; + + @override + String get requestCrypto => '請求解密'; + + @override + String get cryptoDecoded => '已解密'; + + @override + String get cryptoDecodeToggle => '解密'; + + @override + String get optional => '可選'; + + @override + String get cryptoRuleField => '字段'; + + @override + String get cryptoIvPrefixLabel => 'IV 前綴'; + + @override + String get cryptoIvPrefixTooltip => '使用回應內容的前 N 個字節作為 IV'; + + @override + String get local => '本地'; + + @override + String get remoteUrl => '遠端URL'; + + @override + String get view => '檢視'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7fbe9da..ade3608 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -345,8 +345,8 @@ "requestMap": "请求映射", "requestMapDescribe": "不请求远程服务,使用本地配置或脚本进行响应", - "automatic": "自动安装", - "manual": "手动安装", + "automatic": "自动", + "manual": "手动", "certNotInstalled": "证书未安装", "openNewWindow": "新窗口打开", @@ -357,5 +357,18 @@ "sponsorBuyMeCoffee": "Buy Me a Coffee", "privacyPolicy": "隐私协议", - "privacyContent": "本项目为开源抓包工具,所有功能均在本地设备上运行;无任何后端服务器,不会收集、存储或上传任何用户信息。抓取的网络数据仅在本地处理,除非您主动使用远程转发功能。所需权限(如网络、存储、相机用于扫码)仅用于实现相应功能。您可在公开的源代码中审计其行为。" + "privacyContent": "本项目为开源抓包工具,所有功能均在本地设备上运行;无任何后端服务器,不会收集、存储或上传任何用户信息。抓取的网络数据仅在本地处理,除非您主动使用远程转发功能。所需权限(如网络、存储、相机用于扫码)仅用于实现相应功能。您可在公开的源代码中审计其行为。", + + "requestCrypto": "请求解密", + "cryptoDecoded": "已解密", + "cryptoDecodeToggle": "解密", + "optional": "可选", + "cryptoRuleField": "字段名称", + + "cryptoIvPrefixLabel": "IV 前缀", + "cryptoIvPrefixTooltip": "使用响应体前 N 个字节作为 IV", + + "local": "本地", + "remoteUrl": "远程URL", + "view": "查看" } \ No newline at end of file diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 733f4d7..eb059a4 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -312,6 +312,13 @@ "encrypt": "加密", "decrypt": "解密", "cipher": "密文", + "requestCrypto": "請求解密", + "cryptoDecoded": "已解密", + "cryptoDecodeToggle": "解密", + "optional": "可選", + "cryptoRuleField": "字段", + "cryptoIvPrefixLabel": "IV 前綴", + "cryptoIvPrefixTooltip": "使用回應內容的前 N 個字節作為 IV", "appUpdateCheckVersion": "檢查更新", "appUpdateNotAvailableMsg": "已是最新版本", "appUpdateDialogTitle": "有可用更新", @@ -336,5 +343,9 @@ "sponsorBuyMeCoffee": "Buy Me a Coffee", "privacyPolicy": "隱私協議", - "privacyContent": "本專案為開源抓包工具,所有功能均在本機裝置上運行;無任何後端伺服器,不會蒐集、儲存或上傳任何使用者資訊。擷取的網路資料僅在本機處理,除非您主動使用遠端轉發功能。所需權限(如網路、儲存、相機用於掃碼)僅用於實現相應功能。您可在公開的原始碼中稽核其行為。" + "privacyContent": "本專案為開源抓包工具,所有功能均在本機裝置上運行;無任何後端伺服器,不會蒐集、儲存或上傳任何使用者資訊。擷取的網路資料僅在本機處理,除非您主動使用遠端轉發功能。所需權限(如網路、儲存、相機用於掃碼)僅用於實現相應功能。您可在公開的原始碼中稽核其行為。", + + "local": "本地", + "remoteUrl": "遠端URL", + "view": "檢視" } \ No newline at end of file diff --git a/lib/native/installed_apps.dart b/lib/native/installed_apps.dart index 13d0d94..714c351 100644 --- a/lib/native/installed_apps.dart +++ b/lib/native/installed_apps.dart @@ -3,10 +3,16 @@ import 'package:flutter/services.dart'; class InstalledApps { static const MethodChannel _methodChannel = MethodChannel('com.proxy/installedApps'); - static Future> getInstalledApps(bool withIcon, {String? packageNamePrefix}) { - return _methodChannel - .invokeListMethod('getInstalledApps', {"withIcon": withIcon, "packageNamePrefix": packageNamePrefix}).then( - (value) => value?.map((e) => AppInfo.formJson(e)).toList() ?? []); + static Future> getInstalledApps( + bool withIcon, { + String? packageNamePrefix, + bool includeSystemApps = false, + }) { + return _methodChannel.invokeListMethod('getInstalledApps', { + "withIcon": withIcon, + "packageNamePrefix": packageNamePrefix, + "includeSystemApps": includeSystemApps, + }).then((value) => value?.map((e) => AppInfo.formJson(e)).toList() ?? []); } static Future getAppInfo(String packageName) async { diff --git a/lib/network/bin/configuration.dart b/lib/network/bin/configuration.dart index 6e15efb..9a22a4e 100644 --- a/lib/network/bin/configuration.dart +++ b/lib/network/bin/configuration.dart @@ -29,7 +29,7 @@ class Configuration { int port = 9099; //是否启用https抓包 - bool enableSsl = false; + bool enableSsl = Platforms.isMobile(); //是否设置系统代理 bool enableSystemProxy = true; diff --git a/lib/network/components/manager/request_crypto_manager.dart b/lib/network/components/manager/request_crypto_manager.dart new file mode 100644 index 0000000..739ff67 --- /dev/null +++ b/lib/network/components/manager/request_crypto_manager.dart @@ -0,0 +1,268 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/network/util/file_read.dart'; +import 'package:proxypin/network/util/logger.dart'; + +class RequestCryptoManager { + static String separator = Platform.pathSeparator; + + static RequestCryptoManager? _instance; + + RequestCryptoManager._(); + + static Future get instance async { + if (_instance == null) { + final config = await _loadRequestCryptoConfig(); + _instance = RequestCryptoManager._(); + await _instance!._reload(config); + } + return _instance!; + } + + bool enabled = true; + List rules = []; + + Future _reload(Map? map) async { + if (map == null) { + return; + } + + enabled = map['enabled'] == true; + final list = map['rules'] as List? ?? const []; + rules = []; + for (final element in list) { + try { + rules.add(CryptoRule.fromJson(Map.from(element))); + } catch (e) { + logger.e('加载请求加解密配置失败 $element', error: e); + } + } + } + + Future reloadConfig() async { + final config = await _loadRequestCryptoConfig(); + await _reload(config); + } + + static Future?> _loadRequestCryptoConfig() async { + final home = await FileRead.homeDir(); + final file = File('${home.path}${Platform.pathSeparator}request_crypto.json'); + if (!await file.exists()) { + return null; + } + try { + final json = jsonDecode(await file.readAsString()) as Map; + logger.i('加载请求加解密配置文件 [$file]'); + return json; + } catch (e, stack) { + logger.e('解析请求加解密配置失败', error: e, stackTrace: stack); + return null; + } + } + + Future flushConfig() async { + final home = await FileRead.homeDir(); + final file = File('${home.path}${Platform.pathSeparator}request_crypto.json'); + if (!await file.exists()) { + await file.create(recursive: true); + } + final json = jsonEncode(toJson()); + logger.i('刷新请求加解密配置文件 ${file.path}'); + await file.writeAsString(json); + } + + /// Get the first matching rule for the given URL and optional field name + CryptoRule? getMatchingRule(HttpMessage message) { + final url = message.requestUrl; + if (url == null) return null; + if (!enabled) return null; + for (final rule in rules) { + if (!rule.enabled || !rule.matches(url)) continue; + return rule; + } + return null; + } + + /// Add a new crypto rule to the manager + Future addRule(CryptoRule rule) async { + rules.add(rule); + } + + /// Update an existing rule at [index] + Future updateRule(int index, CryptoRule rule) async { + if (index < 0 || index >= rules.length) return; + rules[index] = rule; + } + + /// Remove a single rule by index + Future removeRule(int index) async { + if (index < 0 || index >= rules.length) return; + rules.removeAt(index); + } + + /// Remove multiple rules. Indexes should be sorted or will be sorted descending. + Future removeIndex(List indexes) async { + indexes.sort((a, b) => b.compareTo(a)); + for (final i in indexes) { + if (i >= 0 && i < rules.length) { + rules.removeAt(i); + } + } + } + + Map toJson() => { + 'enabled': enabled, + 'rules': rules.map((e) => e.toJson()).toList(), + }; +} + +class CryptoRule { + final String name; + final String urlPattern; + final String? field; // single field supported + bool enabled; + final CryptoKeyConfig config; + + CryptoRule({ + required this.name, + required this.urlPattern, + this.field, + required this.enabled, + required this.config, + }); + + bool matches(String url) { + try { + return RegExp(urlPattern).hasMatch(url); + } catch (_) { + return url.contains(urlPattern); + } + } + + Map toJson() { + final map = { + 'name': name, + 'urlPattern': urlPattern, + 'field': field, + 'enabled': enabled, + 'config': config.toJson(), + }; + return map; + } + + factory CryptoRule.fromJson(Map json) { + return CryptoRule( + name: json['name'] ?? '', + urlPattern: json['urlPattern'] ?? '', + field: json['field'], + enabled: json['enabled'] ?? true, + config: CryptoKeyConfig.fromJson(Map.from(json['config'] ?? {})), + ); + } + + CryptoRule copyWith({ + String? name, + String? urlPattern, + String? field, + bool? enabled, + CryptoKeyConfig? config, + }) { + return CryptoRule( + name: name ?? this.name, + urlPattern: urlPattern ?? this.urlPattern, + field: field ?? this.field, + enabled: enabled ?? this.enabled, + config: config ?? this.config, + ); + } + + /// Legacy constructor used by UI to create a default empty AesRule + static CryptoRule newRule() { + return CryptoRule( + name: '', + urlPattern: '', + field: '', + enabled: true, + config: CryptoKeyConfig.defaults(), + ); + } +} + +class CryptoKeyConfig { + final String key; + final String iv; + final String ivSource; // 'manual' or 'prefix' + final int ivPrefixLength; + final String mode; + final String padding; + final int keyLength; + + const CryptoKeyConfig({ + required this.key, + required this.iv, + required this.ivSource, + required this.ivPrefixLength, + required this.mode, + required this.padding, + required this.keyLength, + }); + + factory CryptoKeyConfig.defaults() { + return const CryptoKeyConfig( + key: '', iv: '', ivSource: 'manual', ivPrefixLength: 16, mode: 'ECB', padding: 'PKCS7', keyLength: 128); + } + + bool get isReady { + if (key.trim().isEmpty) return false; + if (mode != 'CBC') return true; + // for CBC, either manual IV provided or prefix mode selected + if (ivSource == 'prefix') return true; + return iv.trim().isNotEmpty; + } + + CryptoKeyConfig copyWith({ + String? key, + String? iv, + String? ivSource, + int? ivPrefixLength, + String? mode, + String? padding, + int? keyLength, + }) { + return CryptoKeyConfig( + key: key ?? this.key, + iv: iv ?? this.iv, + ivSource: ivSource ?? this.ivSource, + ivPrefixLength: ivPrefixLength ?? this.ivPrefixLength, + mode: mode ?? this.mode, + padding: padding ?? this.padding, + keyLength: keyLength ?? this.keyLength, + ); + } + + Map toJson() { + return { + 'key': key, + 'iv': iv, + 'ivSource': ivSource, + 'ivPrefixLength': ivPrefixLength, + 'mode': mode, + 'padding': padding, + 'keyLength': keyLength, + }; + } + + factory CryptoKeyConfig.fromJson(Map json) { + return CryptoKeyConfig( + key: json['key'] ?? '', + iv: json['iv'] ?? '', + ivSource: json['ivSource'] ?? 'manual', + ivPrefixLength: json['ivPrefixLength'] ?? 16, + mode: json['mode'] ?? 'ECB', + padding: json['padding'] ?? 'PKCS7', + keyLength: json['keyLength'] ?? 128, + ); + } +} diff --git a/lib/network/components/manager/script_manager.dart b/lib/network/components/manager/script_manager.dart index bbece23..9622ef8 100644 --- a/lib/network/components/manager/script_manager.dart +++ b/lib/network/components/manager/script_manager.dart @@ -20,10 +20,12 @@ import 'dart:io'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter_js/flutter_js.dart'; import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/network/util/cache.dart'; import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/network/util/random.dart'; import 'package:proxypin/ui/component/device.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:http/http.dart' as http; import '../js/script_engine.dart'; @@ -32,7 +34,6 @@ import '../js/script_engine.dart'; /// js脚本 class ScriptManager { static String template = """ -// 在请求到达服务器之前,调用此函数,您可以在此处修改请求数据 // e.g. Add/Update/Remove:Queries、Headers、Body async function onRequest(context, request) { console.log(request.url); @@ -46,8 +47,6 @@ async function onRequest(context, request) { //You can modify the Response Data here before it goes to the client async function onResponse(context, request, response) { - //Update or add Header - // response.headers["Name"] = "Value"; // response.statusCode = 200; //var body = JSON.parse(response.body); @@ -62,7 +61,7 @@ async function onResponse(context, request, response) { bool enabled = true; List list = []; - final Map _scriptMap = {}; + final ExpiringCache _scriptMap = ExpiringCache(Duration(minutes: 15)); static late JavascriptRuntime flutterJs; @@ -98,7 +97,10 @@ async function onResponse(context, request, response) { } static void registerLogHandler(LogHandler logHandler) { - if (!_logHandlers.any((it) => it.channelId == logHandler.channelId)) _logHandlers.add(logHandler); + if (_logHandlers.any((it) => it.channelId == logHandler.channelId)) { + _logHandlers.removeWhere((it) => it.channelId == logHandler.channelId); + } + _logHandlers.add(logHandler); } static void removeLogHandler(int channelId) { @@ -164,18 +166,58 @@ async function onResponse(context, request, response) { return file; } - Future getScript(ScriptItem item) async { + Future getScript(ScriptItem item) async { + // Local script (existing behavior) if (_scriptMap.containsKey(item)) { return _scriptMap[item]!; } + + // Remote script + if (item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty) { + var script = await _fetchRemoteScript(item); + if (script != null) { + _scriptMap[item] = script; + } + return script; + } + final home = await homePath(); var script = await File(home + item.scriptPath!).readAsString(); _scriptMap[item] = script; return script; } + Future _fetchRemoteScript(ScriptItem item) async { + final url = item.remoteUrl!.trim(); + if (!_isHttpUrl(url)) { + return null; + } + + final resp = await http.get(Uri.parse(url)); + + final bytes = resp.bodyBytes; + + final content = utf8.decode(bytes); + _scriptMap[item] = content; + + return content; + } + + bool _isHttpUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return false; + return uri.scheme == 'http' || uri.scheme == 'https'; + } + ///添加脚本 - Future addScript(ScriptItem item, String script) async { + Future addScript(ScriptItem item, String? script) async { + // Remote script: script is treated as initial cache (optional) + if (item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty) { + list.add(item); + return; + } + + script ??= template; final path = await homePath(); String scriptPath = "${separator}scripts$separator${RandomUtil.randomString(16)}.js"; var file = File(path + scriptPath); @@ -188,9 +230,16 @@ async function onResponse(context, request, response) { ///更新脚本 Future updateScript(ScriptItem item, String script) async { + // Remote scripts: update cache file (treat as local override of cache) + if (item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty) { + _scriptMap[item] = script; + return; + } + if (_scriptMap[item] == script) { return; } + final home = await homePath(); File(home + item.scriptPath!).writeAsString(script); _scriptMap[item] = script; @@ -199,15 +248,22 @@ async function onResponse(context, request, response) { ///删除脚本 Future removeScript(int index) async { var item = list.removeAt(index); - final home = await homePath(); - File(home + item.scriptPath!).delete(); + _scriptMap.remove(item); + + if (item.scriptPath != null) { + final home = await homePath(); + File(home + item.scriptPath!).delete(); + } } Future clean() async { + _scriptMap.clear(); while (list.isNotEmpty) { var item = list.removeLast(); - final home = await homePath(); - File(home + item.scriptPath!).delete(); + if (item.scriptPath != null) { + final home = await homePath(); + File(home + item.scriptPath!).delete(); + } } await flushConfig(); } @@ -234,7 +290,11 @@ async function onResponse(context, request, response) { if (item.enabled && item.match(url)) { var context = jsonEncode(scriptContext(item)); var jsRequest = jsonEncode(await JavaScriptEngine.convertJsRequest(request)); - String script = await getScript(item); + String? script = await getScript(item); + if (script == null) { + continue; + } + var jsResult = await flutterJs.evaluateAsync( """var request = $jsRequest, context = $context; request['scriptContext'] = context; $script\n onRequest(context, request)"""); var result = await JavaScriptEngine.jsResultResolve(flutterJs, jsResult); @@ -262,7 +322,11 @@ async function onResponse(context, request, response) { var context = jsonEncode(request.attributes['scriptContext'] ?? scriptContext(item)); var jsRequest = jsonEncode(await JavaScriptEngine.convertJsRequest(request)); var jsResponse = jsonEncode(await JavaScriptEngine.convertJsResponse(response)); - String script = await getScript(item); + String? script = await getScript(item); + if (script == null) { + continue; + } + var jsResult = await flutterJs.evaluateAsync( """var response = $jsResponse, context = $context; response['scriptContext'] = context; $script \n onResponse(context, $jsRequest, response);"""); @@ -314,7 +378,9 @@ class ScriptItem { String? scriptPath; List? urlRegs; - ScriptItem(this.enabled, this.name, dynamic urls, {this.scriptPath}) + String? remoteUrl; + + ScriptItem(this.enabled, this.name, dynamic urls, {this.scriptPath, this.remoteUrl}) : urls = urls is String ? (urls.contains(',') ? urls.split(',').map((e) => e.trim()).toList() : [urls]) : (urls is List ? urls : []); @@ -338,7 +404,14 @@ class ScriptItem { } else { urls = []; } - return ScriptItem(json['enabled'], json['name'], urls, scriptPath: json['scriptPath']); + + return ScriptItem( + json['enabled'], + json['name'], + urls, + scriptPath: json['scriptPath'], + remoteUrl: json['remoteUrl'], + ); } Map toJson() { @@ -346,12 +419,13 @@ class ScriptItem { 'enabled': enabled, 'name': name, 'url': urls.length == 1 ? urls[0] : urls, - 'scriptPath': scriptPath + 'scriptPath': scriptPath, + if (remoteUrl != null) 'remoteUrl': remoteUrl, }; } @override String toString() { - return 'ScriptItem{enabled: $enabled, name: $name, url: $urls, scriptPath: $scriptPath}'; + return 'ScriptItem{enabled: $enabled, name: $name, url: $urls, scriptPath: $scriptPath, remoteUrl: $remoteUrl}'; } } diff --git a/lib/network/http/http.dart b/lib/network/http/http.dart index 387cd48..5009543 100644 --- a/lib/network/http/http.dart +++ b/lib/network/http/http.dart @@ -53,6 +53,8 @@ abstract class HttpMessage { int get contentLength => headers.contentLength; + String? get requestUrl; + //报文大小 int? packageSize; @@ -202,6 +204,7 @@ class HttpRequest extends HttpMessage { return hostAndPort?.domain; } + @override String get requestUrl { if (HostAndPort.startsWithScheme(uri)) { return uri; @@ -260,6 +263,7 @@ class HttpRequest extends HttpMessage { Map toJson() { return { '_class': 'HttpRequest', + '_id': requestId, 'uri': requestUrl, 'method': method.name, 'protocolVersion': protocolVersion, @@ -273,7 +277,8 @@ class HttpRequest extends HttpMessage { factory HttpRequest.fromJson(Map json) { var request = HttpRequest(HttpMethod.valueOf(json['method']), json['uri'], protocolVersion: json['protocolVersion'] ?? "HTTP/1.1"); - + + request.requestId = json['_id'] ?? request.requestId; request.headers.addAll(HttpHeaders.fromJson(json['headers'])); request.body = json['body']?.toString().codeUnits; if (json['requestTime'] != null) { @@ -294,6 +299,10 @@ class HttpResponse extends HttpMessage { HttpStatus status; DateTime responseTime = DateTime.now(); HttpRequest? request; + String? _requestUrl; + + @override + String? get requestUrl => request?.requestUrl ?? _requestUrl; HttpResponse(this.status, {String protocolVersion = "HTTP/1.1"}) : super(protocolVersion); @@ -318,6 +327,7 @@ class HttpResponse extends HttpMessage { httpResponse.responseTime = DateTime.fromMillisecondsSinceEpoch(json['responseTime']); } httpResponse.packageSize = json['packageSize']; + httpResponse._requestUrl = json['requestUrl']; return httpResponse; } @@ -325,6 +335,7 @@ class HttpResponse extends HttpMessage { Map toJson() { return { '_class': 'HttpResponse', + 'requestUrl': request?.requestUrl ?? _requestUrl, 'protocolVersion': protocolVersion, 'packageSize': packageSize, 'status': { diff --git a/lib/network/util/cache.dart b/lib/network/util/cache.dart index 1b83a53..b15124a 100644 --- a/lib/network/util/cache.dart +++ b/lib/network/util/cache.dart @@ -44,6 +44,10 @@ class ExpiringCache { return value; } + bool containsKey(K key) { + return _cache.containsKey(key); + } + V? get(K key) { return _cache[key]; } diff --git a/lib/network/util/system_proxy.dart b/lib/network/util/system_proxy.dart index ea28b87..c838266 100644 --- a/lib/network/util/system_proxy.dart +++ b/lib/network/util/system_proxy.dart @@ -79,7 +79,7 @@ class SystemProxy { return; } - instance._setProxyEnable(enable, sslSetting); + await instance._setProxyEnable(enable, sslSetting); } ///设置代理忽略地址 @@ -146,17 +146,20 @@ class MacSystemProxy implements SystemProxy { @override Future _setSystemProxy(int port, bool sslSetting, String proxyPassDomains) async { _hardwarePort = _hardwarePort ?? await hardwarePort(); - var results = await Process.run('bash', [ - '-c', - _concatCommands([ - 'networksetup -setwebproxy $_hardwarePort 127.0.0.1 $port', - sslSetting == true ? 'networksetup -setsecurewebproxy $_hardwarePort 127.0.0.1 $port' : '', - 'networksetup -setproxybypassdomains $_hardwarePort ${proxyPassDomains.replaceAll(";", " ")}', - 'networksetup -setsocksfirewallproxystate $_hardwarePort off', - ]) - ]); + List commands = [ + 'networksetup -setwebproxy $_hardwarePort 127.0.0.1 $port', + sslSetting == true ? 'networksetup -setsecurewebproxy $_hardwarePort 127.0.0.1 $port' : '', + 'networksetup -setproxybypassdomains $_hardwarePort ${proxyPassDomains.replaceAll(";", " ")}', + 'networksetup -setsocksfirewallproxystate $_hardwarePort off', + ]; + var results = await Process.run('bash', ['-c', _concatCommands(commands)]); logger.d('set proxyServer, name: $_hardwarePort, exitCode: ${results.exitCode}, stdout: ${results.stdout}'); - return results.exitCode == 0; + bool success = results.exitCode == 0; + if (!success) { + logger.e('setSystemProxy failed, stderr: ${results.stderr}'); + return setProxyWithAuth(commands); + } + return success; } ///设置Https代理 @@ -164,13 +167,19 @@ class MacSystemProxy implements SystemProxy { Future _setSslProxyEnable(bool proxyEnable, port) async { var name = _hardwarePort ?? await hardwarePort(); - var results = await Process.run('bash', [ - '-c', + List commands = [ proxyEnable ? 'networksetup -setsecurewebproxy $name 127.0.0.1 $port' : 'networksetup -setsecurewebproxystate $name off' - ]); - return results.exitCode == 0; + ]; + + var results = await Process.run('bash', ['-c', _concatCommands(commands)]); + bool success = results.exitCode == 0; + if (!success) { + logger.e('setSystemProxy failed, stderr: ${results.stderr}'); + return setProxyWithAuth(commands); + } + return success; } ///mac获取当前网络名称 @@ -198,17 +207,36 @@ class MacSystemProxy implements SystemProxy { var proxyMode = proxyEnable ? 'on' : 'off'; _hardwarePort ??= await hardwarePort(); logger.d('set proxyEnable: $proxyEnable, name: $_hardwarePort'); + List commands = [ + 'networksetup -setwebproxystate $_hardwarePort $proxyMode', + sslSetting ? 'networksetup -setsecurewebproxystate $_hardwarePort $proxyMode' : '' + ]; - await Process.run('bash', [ - '-c', - _concatCommands([ - 'networksetup -setwebproxystate $_hardwarePort $proxyMode', - sslSetting ? 'networksetup -setsecurewebproxystate $_hardwarePort $proxyMode' : '' - ]) - ]); + var results = await Process.run('bash', ['-c', _concatCommands(commands)]); + + if (results.exitCode != 0) { + logger.e('setProxyEnable failed, stderr: ${results.stderr}'); + await setProxyWithAuth(commands); + } } - static _concatCommands(List commands) { + Future setProxyWithAuth(List commands) async { + // 使用 quoted form of 确保 shell 指令被 AppleScript 正确转义 + String script = 'do shell script "${commands.join('; ')}" with administrator privileges'; + try { + final result = await Process.run('osascript', ['-e', script]); + bool success = result.exitCode == 0; + if (!success) { + logger.e("操作失败或用户取消: ${result.stderr}"); + } + return success; + } catch (e) { + logger.e("执行 AppleScript 出错: $e"); + return false; + } + } + + static String _concatCommands(List commands) { return commands.where((element) => element.isNotEmpty).join(' && '); } } diff --git a/lib/storage/favorites.dart b/lib/storage/favorites.dart index e4700b3..1ba3c26 100644 --- a/lib/storage/favorites.dart +++ b/lib/storage/favorites.dart @@ -15,10 +15,12 @@ */ import 'dart:collection'; import 'dart:convert'; +import 'dart:io'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/storage/path.dart'; +import 'package:proxypin/utils/har.dart'; /// 收藏存储 /// @author WangHongEn @@ -70,14 +72,68 @@ class FavoriteStorage { } //刷新配置 - static void flushConfig() async { + static Future flushConfig() async { var list = await favorites; - Paths.getPath("favorites.json").then((file) => file.writeAsString(toJson(list))); + await Paths.getPath("favorites.json").then((file) => file.writeAsString(toJson(list))); } static String toJson(Queue list) { return jsonEncode(list.map((e) => e.toJson()).toList()); } + + /// Export all favorites to a given file path + static Future exportToFile(String path) async { + var current = await favorites; + var content = toJson(current); + await File(path).writeAsString(content, flush: true); + } + + /// Export all favorites as HAR to a given file path + static Future exportToHarFile(String path, {String title = 'Favorites'}) async { + var current = await favorites; + final requests = current.map((f) => f.request).toList(growable: false); + await Har.writeFile(requests, File(path), title: title); + } + + /// Import favorites from a JSON or HAR file (merges with current list, de-duping by requestId) + static Future importFromFile(String path) async { + final file = File(path); + if (!await file.exists()) { + throw Exception('File not found'); + } + + final lower = path.toLowerCase(); + List imported; + if (lower.endsWith('.har')) { + // HAR import + final requests = await Har.readFile(file); + imported = requests.map((r) => Favorite(r)).toList(growable: false); + } else { + // JSON import (old format) + final content = await file.readAsString(); + if (content.trim().isEmpty) { + return; + } + final decoded = jsonDecode(content) as List; + imported = decoded.map((e) => Favorite.fromJson(e as Map)).toList(growable: false); + } + + final current = await favorites; + final existingIds = current.map((e) => e.request.requestId).toSet(); + + // Merge without replacing current entries; skip duplicates by requestId + for (var fav in imported.reversed) { + final rid = fav.request.requestId; + if (existingIds.contains(rid)) { + continue; + } + existingIds.add(rid); + current.addFirst(fav); + } + + await flushConfig(); + addNotifier?.call(); + } } class Favorite { diff --git a/lib/storage/histories.dart b/lib/storage/histories.dart index d10a0ce..066ef15 100644 --- a/lib/storage/histories.dart +++ b/lib/storage/histories.dart @@ -78,7 +78,7 @@ class HistoryStorage { return _histories.source; } - addListener(ListenerListEvent listener) { + addListener(ListenerListEvent listener) async { _histories.addListener(listener); } diff --git a/lib/ui/component/history_cache_time.dart b/lib/ui/component/history_cache_time.dart index c41bf40..6270b63 100644 --- a/lib/ui/component/history_cache_time.dart +++ b/lib/ui/component/history_cache_time.dart @@ -24,6 +24,7 @@ class _HistoryCacheTimeState extends State { offset: const Offset(0, 35), icon: const Icon(Icons.av_timer, size: 19), initialValue: widget.configuration.historyCacheTime, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), onSelected: (val) { widget.configuration.historyCacheTime = val; widget.configuration.flushConfig(); diff --git a/lib/ui/component/json/json_text.dart b/lib/ui/component/json/json_text.dart index af08e88..878dc63 100644 --- a/lib/ui/component/json/json_text.dart +++ b/lib/ui/component/json/json_text.dart @@ -93,7 +93,7 @@ class _JsonTextState extends State { chunks = chunks ?? splitTextSpans(textList, 500); return SizedBox( width: double.infinity, - height: MediaQuery.of(context).size.height - 160, + height: MediaQuery.of(context).size.height - 200, child: SelectionArea( child: ScrollablePositionedList.builder( physics: Platforms.isDesktop() ? null : const BouncingScrollPhysics(), diff --git a/lib/ui/component/json/json_viewer.dart b/lib/ui/component/json/json_viewer.dart index c0b7a40..f99665c 100644 --- a/lib/ui/component/json/json_viewer.dart +++ b/lib/ui/component/json/json_viewer.dart @@ -339,7 +339,7 @@ Widget _getValueWidget(dynamic value, ColorTheme colorTheme, style = TextStyle(color: colorTheme.keyword); } else if (value is num) { valueStr = value.toString(); - style = TextStyle(color: colorTheme.keyword); + style = TextStyle(color: colorTheme.number); } else if (value is String) { valueStr = '"$value"'; style = TextStyle(color: colorTheme.string); diff --git a/lib/ui/component/json/theme.dart b/lib/ui/component/json/theme.dart index 8165af3..7445539 100644 --- a/lib/ui/component/json/theme.dart +++ b/lib/ui/component/json/theme.dart @@ -13,12 +13,12 @@ class ColorTheme { ); static ColorTheme dark(ColorScheme colorScheme) => ColorTheme( - background: const Color(0xff2b2b2b), - propertyKey: const Color(0xff9876aa), - colon: const Color(0xffcc7832), - string: const Color(0xff6a8759), - number: const Color(0xff6897bb), - keyword: const Color(0xffcc7832), + background: const Color(0XFF1E1F22), + propertyKey: const Color(0XFFC77DBB), + colon: const Color(0XFFBCBEC4), + string: const Color(0XFF6AAB73), + number: const Color(0XFF2AACB8), + keyword: const Color(0XFFCF8E6D), searchMatchColor: colorScheme.inversePrimary, searchMatchCurrentColor: colorScheme.primary, ); diff --git a/lib/ui/component/multi_window.dart b/lib/ui/component/multi_window.dart index febca3e..f8c3bd1 100644 --- a/lib/ui/component/multi_window.dart +++ b/lib/ui/component/multi_window.dart @@ -23,6 +23,7 @@ import 'package:flutter/material.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:path_provider/path_provider.dart'; import 'package:proxypin/network/bin/server.dart'; +import 'package:proxypin/network/components/manager/request_crypto_manager.dart'; import 'package:proxypin/network/components/manager/request_map_manager.dart'; import 'package:proxypin/network/components/manager/request_rewrite_manager.dart'; import 'package:proxypin/network/components/manager/rewrite_rule.dart'; @@ -41,6 +42,7 @@ import 'package:proxypin/utils/platform.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; +import '../desktop/setting/request_crypto.dart'; import '../desktop/setting/request_map.dart'; import '../toolbox/cert_hash.dart'; import '../toolbox/encoder.dart'; @@ -95,6 +97,12 @@ Widget multiWindow(int windowId, Map argument) { return futureWidget( RequestRewriteManager.instance, (data) => RequestRewriteWidget(windowId: windowId, requestRewrites: data)); } + + // 请求加密 + if (argument['name'] == 'RequestCryptoPage') { + return futureWidget(RequestCryptoManager.instance, (data) => RequestCryptoPage(windowId: windowId, manager: data)); + } + // 请求映射 if (argument['name'] == 'RequestMapPage') { return RequestMapPage(windowId: windowId); } @@ -249,6 +257,13 @@ void registerMethodHandler() { return 'done'; } + if (call.method == 'refreshRequestCrypto') { + await RequestCryptoManager.instance.then((value) { + return value.reloadConfig(); + }); + return 'done'; + } + if (call.method == 'pickFiles') { var extensions = call.arguments != null ? call.arguments['allowedExtensions'] : null; FilePickerResult? result = await FilePicker.platform.pickFiles( diff --git a/lib/ui/component/text_field.dart b/lib/ui/component/text_field.dart index e884fa4..2933cf4 100644 --- a/lib/ui/component/text_field.dart +++ b/lib/ui/component/text_field.dart @@ -72,13 +72,14 @@ class HighlightTextEditingController extends TextEditingController { } } -InputDecoration decoration(BuildContext context, {String? label, String? hintText, Widget? suffixIcon}) { +InputDecoration decoration(BuildContext context, {String? label, String? hintText, Widget? suffixIcon, bool? isDense}) { Color color = Theme.of(context).colorScheme.primary; return InputDecoration( floatingLabelBehavior: FloatingLabelBehavior.always, labelText: label, hintText: hintText, suffixIcon: suffixIcon, + isDense: isDense, hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 15), border: OutlineInputBorder(borderSide: BorderSide(width: 0.8, color: color)), enabledBorder: OutlineInputBorder(borderSide: BorderSide(width: 1.3, color: color)), diff --git a/lib/ui/configuration.dart b/lib/ui/configuration.dart index 3ca3a5c..f5be567 100644 --- a/lib/ui/configuration.dart +++ b/lib/ui/configuration.dart @@ -63,7 +63,7 @@ class ThemeModel { } class AppConfiguration { - static const String version = "1.2.3"; + static const String version = "1.2.4"; ValueNotifier globalChange = ValueNotifier(false); @@ -71,7 +71,7 @@ class AppConfiguration { Locale? _language; //是否显示更新内容公告 - bool upgradeNoticeV23 = true; + bool upgradeNoticeV24 = true; /// 是否启用画中画 ValueNotifier pipEnabled = ValueNotifier(Platform.isAndroid); @@ -82,6 +82,9 @@ class AppConfiguration { /// header默认展示 bool headerExpanded = true; + /// Headers展示模式: table(逐行) / text(原始文本) + String headerViewMode = "table"; + /// 底部导航栏 bool bottomNavigation = true; @@ -196,7 +199,7 @@ class AppConfiguration { _theme = ThemeModel(mode: mode, useMaterial3: config['useMaterial3'] ?? true); _theme.color = config['themeColor'] ?? "Blue"; - upgradeNoticeV23 = config['upgradeNoticeV23'] ?? true; + upgradeNoticeV24 = config['upgradeNoticeV24'] ?? true; _language = config['language'] == null ? null : Locale.fromSubtags( @@ -206,6 +209,7 @@ class AppConfiguration { pipEnabled.value = config['pipEnabled'] ?? true; pipIcon.value = config['pipIcon'] ?? false; headerExpanded = config['headerExpanded'] ?? true; + headerViewMode = config['headerViewMode'] ?? "table"; bottomNavigation = config['bottomNavigation'] ?? true; memoryCleanupThreshold = config['memoryCleanupThreshold']; autoReadEnabled = config['autoReadEnabled'] ?? true; @@ -247,10 +251,11 @@ class AppConfiguration { 'mode': _theme.mode.name, 'themeColor': _theme.color, 'useMaterial3': _theme.useMaterial3, - 'upgradeNoticeV23': upgradeNoticeV23, + 'upgradeNoticeV24': upgradeNoticeV24, "language": _language?.languageCode, "languageScript": _language?.scriptCode, "headerExpanded": headerExpanded, + "headerViewMode": headerViewMode, "autoReadEnabled": autoReadEnabled, if (memoryCleanupThreshold != null) 'memoryCleanupThreshold': memoryCleanupThreshold, if (Platforms.isMobile()) 'pipEnabled': pipEnabled.value, diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index 97d81d4..b2263bd 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -35,6 +35,7 @@ import 'package:proxypin/ui/component/multi_window.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/desktop/setting/request_rewrite.dart'; import 'package:proxypin/ui/mobile/setting/request_rewrite.dart'; +import 'package:proxypin/utils/crypto_body_decoder.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/num.dart'; import 'package:proxypin/utils/platform.dart'; @@ -75,6 +76,8 @@ class HttpBodyState extends State { final SearchTextController searchController = SearchTextController(); AppLocalizations get localizations => AppLocalizations.of(context)!; + bool showDecoded = false; + CryptoDecodedResult? decoded; @override void initState() { @@ -82,6 +85,18 @@ class HttpBodyState extends State { if (widget.windowController != null) { HardwareKeyboard.instance.addHandler(onKeyEvent); } + + _loadDecoded(); + } + + @override + void didUpdateWidget(covariant HttpBodyWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.httpMessage?.requestId != widget.httpMessage?.requestId) { + showDecoded = false; + decoded = null; + _loadDecoded(); + } } /// 按键事件 @@ -96,6 +111,13 @@ class HttpBodyState extends State { return false; } + Future _loadDecoded() async { + final message = widget.httpMessage; + if (message == null) return; + decoded = await CryptoBodyDecoder.maybeDecode(message); + if (mounted) setState(() {}); + } + @override void dispose() { HardwareKeyboard.instance.removeHandler(onKeyEvent); @@ -208,52 +230,49 @@ class HttpBodyState extends State { bool isImage = widget.httpMessage?.contentType == ContentType.image; VisualDensity visualDensity = Platforms.isMobile() ? VisualDensity.compact : VisualDensity.standard; - var list = [ - Text('$type Body', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), - const SizedBox(width: 18), - InkWell( - key: searchIconKey, - child: Icon(Icons.search, size: 20), - // tooltip: localizations.search, - onTap: () { - if (searchController.isSearchOverlayVisible) { - searchController.removeSearchOverlay(); - } else { - RenderBox renderBox = searchIconKey.currentContext?.findRenderObject() as RenderBox; - Offset position = renderBox.localToGlobal(Offset.zero); // 获取搜索图标的位置 - searchController.showSearchOverlay(context, top: position.dy + renderBox.size.height + 50, right: 10); - } - }, - ), - const SizedBox(width: 5), - isImage - ? downloadImageButton() - : IconButton( - visualDensity: visualDensity, - iconSize: 16, - icon: Icon(Icons.copy), - tooltip: localizations.copy, - onPressed: () async { - var body = await bodyKey.currentState?.getBody(); - if (body == null) { - return; - } - Clipboard.setData(ClipboardData(text: body)).then((value) { - if (mounted) FlutterToastr.show(localizations.copied, context); - }); - }), - ]; + final isMobile = Platforms.isMobile(); - if (!widget.hideRequestRewrite) { - list.add(IconButton( - visualDensity: visualDensity, - iconSize: 16, - icon: const Icon(Icons.edit_document), - tooltip: localizations.requestRewrite, - onPressed: showRequestRewrite)); - } + // Build common actions as widgets so we can either display them inline (desktop) + // or move them into an overflow menu (mobile) to avoid hiding important buttons. + final searchBtn = InkWell( + key: searchIconKey, + child: const Icon(Icons.search, size: 20), + onTap: () { + if (searchController.isSearchOverlayVisible) { + searchController.removeSearchOverlay(); + } else { + RenderBox renderBox = searchIconKey.currentContext?.findRenderObject() as RenderBox; + Offset position = renderBox.localToGlobal(Offset.zero); + searchController.showSearchOverlay(context, top: position.dy + renderBox.size.height + 50, right: 10); + } + }, + ); - list.add(IconButton( + final copyBtn = isImage + ? downloadImageButton() + : IconButton( + visualDensity: visualDensity, + iconSize: 16, + icon: const Icon(Icons.copy), + tooltip: localizations.copy, + onPressed: () async { + var body = await bodyKey.currentState?.getBody(); + if (body == null) return; + Clipboard.setData(ClipboardData(text: body)).then((_) { + if (mounted) FlutterToastr.show(localizations.copied, context); + }); + }, + ); + + final rewriteBtn = IconButton( + visualDensity: visualDensity, + iconSize: 16, + icon: const Icon(Icons.edit_document), + tooltip: localizations.requestRewrite, + onPressed: showRequestRewrite, + ); + + final encodeBtn = IconButton( visualDensity: visualDensity, iconSize: 20, icon: const Icon(Icons.text_format), @@ -263,20 +282,92 @@ class HttpBodyState extends State { if (mounted) { encodeWindow(EncoderType.base64, context, body); } - })); - if (!inNewWindow) { - list.add(IconButton( - visualDensity: visualDensity, - iconSize: 16, - icon: const Icon(Icons.open_in_new), - tooltip: localizations.newWindow, - onPressed: () => openNew())); + }); + + final openNewBtn = IconButton( + visualDensity: visualDensity, + iconSize: 16, + icon: const Icon(Icons.open_in_new), + tooltip: localizations.newWindow, + onPressed: () => openNew()); + + Widget? cryptoToggle; + if (decoded != null) { + cryptoToggle = TextButton.icon( + onPressed: () { + setState(() { + showDecoded = !showDecoded; + }); + }, + icon: Icon(showDecoded ? Icons.lock_open : Icons.lock, size: 18), + label: Text(showDecoded ? localizations.cryptoDecoded : localizations.cryptoDecodeToggle), + ); } - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(children: list), - ); + // Mobile UX: + // - If there is NO crypto result, keep the original (previous) horizontal-scroll title bar. + // - Only when crypto is available, switch to the compact overflow-menu layout to keep + // the crypto toggle visible. + if (isMobile && cryptoToggle != null) { + final overflowItems = >[]; + if (!widget.hideRequestRewrite) { + overflowItems.add(PopupMenuItem(value: 'rewrite', child: Text(localizations.requestRewrite))); + } + overflowItems.add(PopupMenuItem(value: 'encode', child: Text(localizations.encode))); + if (!inNewWindow) { + overflowItems.add(PopupMenuItem(value: 'new_window', child: Text(localizations.newWindow))); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$type Body', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + searchBtn, + const SizedBox(width: 4), + copyBtn, + const SizedBox(width: 4), + Flexible(child: cryptoToggle), + if (overflowItems.isNotEmpty) + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 20), + onSelected: (v) { + if (v == 'rewrite') showRequestRewrite(); + if (v == 'encode') { + bodyKey.currentState?.getBody().then((body) { + if (mounted) encodeWindow(EncoderType.base64, context, body); + }); + } + if (v == 'new_window') openNew(); + }, + itemBuilder: (_) => overflowItems, + ), + ], + ); + } + + // Default (desktop + mobile without crypto): keep the previous full inline actions + // (horizontal scroll when needed). + final list = [ + Text('$type Body', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + const SizedBox(width: 18), + searchBtn, + const SizedBox(width: 4), + copyBtn, + ]; + + if (!widget.hideRequestRewrite) { + list.add(rewriteBtn); + } + list.add(encodeBtn); + if (!inNewWindow) { + list.add(openNewBtn); + } + if (cryptoToggle != null) { + list.add(cryptoToggle); + } + + return SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: list)); } ///下载图片 @@ -419,6 +510,11 @@ class _BodyState extends State<_Body> { } Future getBody() async { + final parent = context.findAncestorStateOfType(); + if (parent?.showDecoded == true && parent?.decoded?.text != null) { + return parent!.decoded!.text; + } + if (message?.isWebSocket == true) { return message?.messages.map((e) => e.payloadDataAsString).join("\n"); } @@ -446,7 +542,13 @@ class _BodyState extends State<_Body> { } Widget _getBody(ViewType type) { - if (message?.isWebSocket == true || (message?.contentType == ContentType.sse && message?.messages.isNotEmpty == true)) { + final parent = context.findAncestorStateOfType(); + final message = parent?.showDecoded == true && parent?.decoded != null + ? _DecodedHttpMessage(widget.message!, parent!.decoded!) + : widget.message; + + if (message?.isWebSocket == true || + (message?.contentType == ContentType.sse && message?.messages.isNotEmpty == true)) { List? list = message?.messages .map((e) => Container( margin: const EdgeInsets.only(top: 2, bottom: 2), @@ -474,28 +576,28 @@ class _BodyState extends State<_Body> { ); } - if (message == null || message?.body == null) { + if (message == null || message.body == null) { return const SizedBox(); } if (type == ViewType.image) { - return Center(child: Image.memory(Uint8List.fromList(message?.body ?? []), fit: BoxFit.scaleDown)); + return Center(child: Image.memory(Uint8List.fromList(message.body ?? []), fit: BoxFit.scaleDown)); } if (type == ViewType.video) { return const Center(child: Text("video not support preview")); } if (type == ViewType.hex) { - return HexViewer(data: Uint8List.fromList(message!.body!), searchController: widget.searchController); + return HexViewer(data: Uint8List.fromList(message.body!), searchController: widget.searchController); } if (type == ViewType.formUrl) { return HighlightTextWidget( - text: Uri.decodeFull(message!.getBodyString()), + text: Uri.decodeFull(message.getBodyString()), searchController: widget.searchController, contextMenuBuilder: contextMenu); } - return futureWidget(message!.decodeBodyString(), initialData: message!.getBodyString(), (body) { + return futureWidget(message.decodeBodyString(), initialData: message.getBodyString(), (body) { try { if (type == ViewType.jsonText) { var jsonObject = json.decode(body); @@ -643,3 +745,19 @@ class HexViewer extends StatelessWidget { return buffer.toString(); } } + +class _DecodedHttpMessage extends HttpMessage { + final HttpMessage original; + final CryptoDecodedResult decoded; + + _DecodedHttpMessage(this.original, this.decoded) : super(original.protocolVersion) { + headers.addAll(original.headers); + body = decoded.bytes; + } + + @override + Map toJson() => original.toJson(); + + @override + String? get requestUrl => original.requestUrl; +} diff --git a/lib/ui/content/headers.dart b/lib/ui/content/headers.dart new file mode 100644 index 0000000..239437f --- /dev/null +++ b/lib/ui/content/headers.dart @@ -0,0 +1,187 @@ +/* + * Copyright 2025 Hongen Wang All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import 'package:flutter/material.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_highlight/themes/atom-one-dark.dart'; +import 'package:flutter_highlight/themes/atom-one-light.dart'; +import 'package:highlight/languages/http.dart'; +import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/ui/component/utils.dart'; +import 'package:proxypin/ui/configuration.dart'; +import 'package:flutter/services.dart'; +import 'package:proxypin/utils/platform.dart'; + +/// A reusable panel to display request/response headers. +/// +/// Supports two modes: +/// - table mode: each header shown as name/value rows +/// - text mode: raw header lines in a read-only code field +class HeadersWidget extends StatefulWidget { + final String title; + final HttpMessage? message; + final TextStyle valueTextStyle; + final bool initiallyExpanded; + + /// Optional shared controller for raw-text mode, so caller can reuse + /// controllers between rebuilds (e.g. separate for Request/Response). + final CodeController? controller; + + const HeadersWidget({ + super.key, + required this.title, + required this.message, + this.valueTextStyle = const TextStyle(fontSize: 14), + this.initiallyExpanded = true, + this.controller, + }); + + @override + State createState() => _HeadersWidgetState(); +} + +class _HeadersWidgetState extends State { + // 静态缓存:按 title 区分的展开状态(保持同一进程内跨页面实例) + static final Map _lastExpanded = {}; + late CodeController _controller; + + // 当前实例展开状态 + late bool _expanded; + + @override + void initState() { + super.initState(); + _controller = + widget.controller ?? CodeController(readOnly: true, language: http, text: _buildRawHeaders(widget.message)); + // 优先使用按 type 缓存,其次使用全局配置,最后使用 widget 默认 + final key = widget.title; + _expanded = _lastExpanded[key] ?? AppConfiguration.current?.headerExpanded ?? widget.initiallyExpanded; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _buildHeaderModeToggle(BuildContext context) { + + final config = AppConfiguration.current; + if (config == null) return const SizedBox(); + final isText = config.headerViewMode == 'text'; + void setMode(bool text) { + config.headerViewMode = text ? 'text' : 'table'; + config.flushConfig(); + setState(() {}); + } + + return IconButton( + visualDensity: VisualDensity.compact, + iconSize: 18, + tooltip: isText ? 'Headers: Text' : 'Headers: Table', + onPressed: () => setMode(!isText), + icon: Icon(isText ? Icons.text_snippet : Icons.table_rows), + ); + } + + @override + Widget build(BuildContext context) { + final isTextMode = (AppConfiguration.current?.headerViewMode ?? 'table') == 'text'; + return ExpansionTile( + tilePadding: const EdgeInsets.only(left: 0), + dense: true, + title: Row( + children: [ + Expanded( + child: + Text('${widget.title} Headers', style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14))), + _buildHeaderModeToggle(context), + ], + ), + // 使用实例状态作为当前的展开状态 + initiallyExpanded: _expanded, + onExpansionChanged: (expanded) { + if (_expanded == expanded) return; + _expanded = expanded; + _lastExpanded[widget.title] = expanded; + if (mounted) setState(() {}); + }, + shape: const Border(), + children: !isTextMode ? _buildHeaderRows(widget.message) : buildTextMode(widget.message), + ); + } + + List buildTextMode(HttpMessage? message) { + final text = _buildRawHeaders(message); + if (_controller.text != text) { + _controller = CodeController(readOnly: true, language: http, text: text); + } + + return [ + CodeTheme( + data: CodeThemeData( + styles: Theme.brightnessOf(context) == Brightness.light ? atomOneLightTheme : atomOneDarkTheme), + child: CodeField( + background: Colors.transparent, + readOnly: Platforms.isMobile(), + wrap: true, + gutterStyle: const GutterStyle(margin: 0, width: 52, showErrors: false), + textStyle: const TextStyle(fontSize: 15.3), + controller: _controller, + ), + ), + ]; + } + + List _buildHeaderRows(HttpMessage? message) { + final rows = []; + message?.headers.forEach((name, values) { + for (final v in values) { + rows.add(Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText(name, + contextMenuBuilder: contextMenu, + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.deepOrangeAccent, fontSize: 15)), + const Text(': ', + style: TextStyle(fontWeight: FontWeight.w500, color: Colors.deepOrangeAccent, fontSize: 15)), + Expanded( + child: SelectableText( + v, + style: widget.valueTextStyle, + contextMenuBuilder: contextMenu, + maxLines: 8, + minLines: 1, + ), + ), + ], + )); + rows.add(const Divider(thickness: 0.1, height: 10)); + } + }); + return rows; + } + + String _buildRawHeaders(HttpMessage? message) { + if (message == null) return ''; + final buffer = StringBuffer(); + message.headers.forEach((name, values) { + for (final v in values) { + buffer.writeln('$name: $v'); + } + }); + return buffer.toString().trimRight(); + } +} diff --git a/lib/ui/content/panel.dart b/lib/ui/content/panel.dart index eec475a..ee7f381 100644 --- a/lib/ui/content/panel.dart +++ b/lib/ui/content/panel.dart @@ -22,12 +22,12 @@ import 'package:proxypin/network/bin/server.dart'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/ui/component/state_component.dart'; import 'package:proxypin/ui/component/utils.dart'; -import 'package:proxypin/ui/configuration.dart'; import 'package:proxypin/ui/content/web_socket.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/platform.dart'; import 'body.dart'; +import 'headers.dart'; import 'menu.dart'; ///网络请求详情页 @@ -157,6 +157,7 @@ class NetworkTabState extends State with SingleTickerProvi : AppBar( title: widget.title, bottom: tabBar, + centerTitle: true, actions: [ ShareWidget( proxyServer: widget.proxyServer, request: widget.request.get(), response: widget.response.get()), @@ -200,8 +201,11 @@ class NetworkTabState extends State with SingleTickerProvi return SingleChildScrollView( controller: scrollController, - child: - Column(children: [RowWidget("Path", path), ...message(widget.request.get(), "Request", scrollController)])); + child: Column(children: [ + RowWidget("Path", path), + RequestParams(widget.request), + ...message(widget.request.get(), "Request", scrollController) + ])); } Widget response() { @@ -219,48 +223,61 @@ class NetworkTabState extends State with SingleTickerProvi } List message(HttpMessage? message, String type, ScrollController scrollController) { - var headers = []; - message?.headers.forEach((name, values) { - for (var v in values) { - const nameStyle = TextStyle(fontWeight: FontWeight.w500, color: Colors.deepOrangeAccent, fontSize: 14); - headers.add(Row(children: [ - SelectableText(name, contextMenuBuilder: contextMenu, style: nameStyle), - const Text(": ", style: nameStyle), - if (Platforms.isDesktop()) SizedBox(width: 5), - Expanded( - child: SelectableText(v, style: textStyle, contextMenuBuilder: contextMenu, maxLines: 8, minLines: 1)), - ])); - headers.add(const Divider(thickness: 0.1)); - } - }); - Widget bodyWidgets = HttpBodyWidget( key: type == "Request" ? requestHttpBodyKey : responseHttpBodyKey, hideRequestRewrite: widget.windowId != null, httpMessage: message, scrollController: scrollController); - Widget headerWidget = ExpansionTile( - tilePadding: const EdgeInsets.only(left: 0), - title: Text("$type Headers", style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14)), - initiallyExpanded: AppConfiguration.current?.headerExpanded ?? true, - shape: const Border(), - children: headers); - - return [headerWidget, bodyWidgets]; + return [HeadersWidget(title: type, message: message, valueTextStyle: textStyle), bodyWidgets]; } } -Widget expansionTile(String title, List content) { +Widget expansionTile(String title, List content, + {bool initiallyExpanded = true, ValueChanged? onExpansionChanged}) { return ExpansionTile( title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14)), tilePadding: const EdgeInsets.only(left: 0), expandedAlignment: Alignment.topLeft, - initiallyExpanded: true, + initiallyExpanded: initiallyExpanded, + onExpansionChanged: onExpansionChanged, shape: const Border(), children: content); } +class RequestParams extends StatelessWidget { + static bool initiallyExpanded = false; + + final ValueWrap request; + + const RequestParams(this.request, {super.key}); + + @override + Widget build(BuildContext context) { + var request = this.request.get(); + if (request == null) { + return const SizedBox(); + } + var params = request.requestUri?.queryParametersAll; + if (params == null || params.isEmpty) { + return const SizedBox(); + } + var content = []; + params.forEach((name, values) { + for (var val in values) { + content.add(RowWidget(name, val)); + content.add(const Divider(thickness: 0.1, height: 10)); + } + }); + + return expansionTile("Request Params", content, initiallyExpanded: initiallyExpanded, + onExpansionChanged: (expanded) { + //保存展开状态 + initiallyExpanded = expanded; + }); + } +} + class General extends StatelessWidget { final ValueWrap request; @@ -282,32 +299,32 @@ class General extends StatelessWidget { var content = [ const SizedBox(height: 10), RowWidget("Request URL", requestUrl), - const SizedBox(height: 20), + const SizedBox(height: 15), RowWidget("Request Method", request.method.name), - const SizedBox(height: 20), + const SizedBox(height: 15), RowWidget("Protocol", request.protocolVersion), - const SizedBox(height: 20), + const SizedBox(height: 15), RowWidget("Status Code", response?.status.toString()), - const SizedBox(height: 20), + const SizedBox(height: 15), RowWidget("Remote Address", '${response?.remoteHost ?? ''}${response?.remotePort == null ? '' : ':${response?.remotePort}'}'), - const SizedBox(height: 20), + const SizedBox(height: 15), RowWidget("Request Time", request.requestTime.formatMillisecond()), - const SizedBox(height: 20), + const SizedBox(height: 15), RowWidget("Duration", response?.costTime()), - const SizedBox(height: 20), + const SizedBox(height: 15), RowWidget("Request Content-Type", request.headers.contentType), - const SizedBox(height: 20), + const SizedBox(height: 15), RowWidget("Response Content-Type", response?.headers.contentType), - const SizedBox(height: 20), + const SizedBox(height: 15), RowWidget("Request Package", getPackage(request.packageSize)), - const SizedBox(height: 20), + const SizedBox(height: 15), RowWidget("Response Package", getPackage(response?.packageSize)), - const SizedBox(height: 20), + const SizedBox(height: 15), ]; if (request.processInfo != null) { content.add(RowWidget("App", request.processInfo!.name)); - content.add(const SizedBox(height: 20)); + content.add(const SizedBox(height: 15)); } return ListView(children: [expansionTile("General", content)]); @@ -328,7 +345,7 @@ class Cookies extends StatelessWidget { var responseCookie = response.get()?.headers.getList("Set-Cookie")?.expand((e) => _cookieWidget(e)!); return ListView(children: [ requestCookie == null ? const SizedBox() : expansionTile("Request Cookies", requestCookie.toList()), - const SizedBox(height: 20), + const SizedBox(height: 15), responseCookie == null ? const SizedBox() : expansionTile("Response Cookies", responseCookie.toList()), ]); } @@ -338,7 +355,7 @@ class Cookies extends StatelessWidget { cookie?.split(";").map((e) => Strings.splitFirst(e, "=")).where((element) => element != null).forEach((e) { headers.add(RowWidget(e!.key.trim(), e.value)); - headers.add(const Divider(thickness: 0.1)); + headers.add(const Divider(thickness: 0.1, height: 10)); }); return headers; diff --git a/lib/ui/desktop/desktop.dart b/lib/ui/desktop/desktop.dart index 2c6fdd3..bfa4f40 100644 --- a/lib/ui/desktop/desktop.dart +++ b/lib/ui/desktop/desktop.dart @@ -93,7 +93,7 @@ class _DesktopHomePagePageState extends State implements EventL proxyServer.addListener(this); panel = NetworkTabController(tabStyle: const TextStyle(fontSize: 16), proxyServer: proxyServer); - if (widget.appConfiguration.upgradeNoticeV23) { + if (widget.appConfiguration.upgradeNoticeV24) { WidgetsBinding.instance.addPostFrameCallback((_) { showUpgradeNotice(); }); @@ -120,7 +120,8 @@ class _DesktopHomePagePageState extends State implements EventL // color: Theme.of(context).brightness == Brightness.dark ? null : Color(0xFFF9F9F9), border: Border( bottom: BorderSide( - color: Theme.of(context).dividerColor.withOpacity(0.3), width: Platform.isMacOS ? 0.2 : 0.55))), + color: Theme.of(context).dividerColor.withValues(alpha: 0.3), + width: Platform.isMacOS ? 0.2 : 0.55))), child: Platform.isMacOS ? Toolbar(proxyServer, requestListStateKey) : WindowsToolbar(title: Toolbar(proxyServer, requestListStateKey)), @@ -161,7 +162,7 @@ class _DesktopHomePagePageState extends State implements EventL actions: [ TextButton( onPressed: () { - widget.appConfiguration.upgradeNoticeV23 = false; + widget.appConfiguration.upgradeNoticeV24 = false; widget.appConfiguration.flushConfig(); Navigator.pop(context); }, @@ -175,23 +176,22 @@ class _DesktopHomePagePageState extends State implements EventL isCN ? '提示:默认不会开启HTTPS抓包,请安装证书后再开启HTTPS抓包。\n' '点击HTTPS抓包(加锁图标),选择安装根证书,按照提示操作即可。\n\n' - '1. 工具箱增加 WebSocket 请求测试;\n' - '2. 支持数据上报服务器;\n' - '3. 支持 SSE(event-stream)请求;\n' - '4. 增加保存HTTP请求;\n' - '5. 请求重写支持 请求方法匹配;\n' - '6. Android 系统导航栏颜色适配;\n' - '7. 修复 ios26 分享 bug;\n' - '8. bug修复和改进;\n' - : 'Note: HTTPS capture is disabled by default — please install the certificate before enabling HTTPS capture.\n\n' - '1. Added WebSocket request testing in the Toolbox.\n' - '2. Added support for data-reporting servers.\n' - '3. Added support for Server-Sent Events (SSE / event-stream).\n' - '4. Added the ability to save HTTP requests.\n' - "5. Request rewrite rules now support matching by HTTP method.\n" - '6. Improved Android navigation bar color handling.\n' - '7. Fixed a sharing bug on iOS 26.\n' - '8. Various bug fixes and improvements.\n', + '1. 增加收藏导出和导入;\n' + '2. 增加请求解密,可配置AES自动解密消息体;\n' + '3. 脚本支持远程URL获取执行;\n' + '4. HTTP Header 展示增加文本和表格切换;\n' + '5. 增加 Request Param 列表展示;\n' + '6. 应用过滤列表增加是否显示系统应用;\n' + '7. 更新JSON深色主题色,以提高可见度和美观度;\n' + : 'Note: HTTPS capture is disabled by default — please install the certificate before enabling HTTPS capture.\n' + 'Click the HTTPS capture (lock) icon, choose "Install Root Certificate", and follow the prompts to complete installation.\n\n' + '1. Added import/export for Favorites.\n' + '2. Added request decryption with configurable AES automatic body decryption.\n' + '3. Scripts can now be fetched from remote URLs and executed.\n' + '4. HTTP header view now supports switching between text and table modes.\n' + '5. Added a Request Params list view.\n' + '6. App filter list now includes an option to show system apps.\n' + '7. Updated JSON dark-theme colors for better visibility and appearance.\n', style: const TextStyle(fontSize: 14)))); }); } diff --git a/lib/ui/desktop/left_menus/favorite.dart b/lib/ui/desktop/left_menus/favorite.dart index b7a0494..415fde9 100644 --- a/lib/ui/desktop/left_menus/favorite.dart +++ b/lib/ui/desktop/left_menus/favorite.dart @@ -20,6 +20,7 @@ import 'dart:io'; import 'package:date_format/date_format.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -28,6 +29,7 @@ import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:proxypin/network/channel/host_port.dart'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/http/http_client.dart'; +import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/storage/favorites.dart'; import 'package:proxypin/ui/component/app_dialog.dart'; import 'package:proxypin/ui/component/utils.dart'; @@ -78,12 +80,15 @@ class _FavoritesState extends State { } return ListView.separated( - itemCount: favorites.length, + itemCount: favorites.length + 1, itemBuilder: (_, index) { - var request = favorites.elementAt(index); + if (index == 0) { + return _FavoritesActions(onChanged: () => setState(() {})); + } + var request = favorites.elementAt(index - 1); return _FavoriteItem( request, - index: index, + index: index - 1, panel: widget.panel, onRemove: (Favorite favorite) { FavoriteStorage.removeFavorite(favorite); @@ -92,7 +97,8 @@ class _FavoritesState extends State { }, ); }, - separatorBuilder: (_, __) => const Divider(height: 1, thickness: 0.3), + separatorBuilder: (_, idx) => + idx == 0 ? const SizedBox(height: 4) : const Divider(height: 1, thickness: 0.3), ); } else { return const SizedBox(); @@ -287,3 +293,85 @@ class _FavoriteItemState extends State<_FavoriteItem> { widget.panel.change(request, request.response); } } + +class _FavoritesActions extends StatelessWidget { + final VoidCallback onChanged; + + const _FavoritesActions({required this.onChanged}); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 36, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Text( + localizations.favorites, + style: TextStyle( + fontSize: 12.5, + color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.82), + ), + ), + const Spacer(), + // IconButton( + // tooltip: '${localizations.export} HAR', + // padding: const EdgeInsets.symmetric(horizontal: 6), + // constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + // icon: const Icon(Icons.upload, size: 18), + // onPressed: () async { + // final path = await FilePicker.platform.saveFile(fileName: 'favorites.har'); + // if (path == null) return; + // await FavoriteStorage.exportToHarFile(path, title: localizations.favorites); + // FlutterToastr.show(localizations.exportSuccess, context); + // }, + // ), + IconButton( + tooltip: localizations.export, + padding: const EdgeInsets.symmetric(horizontal: 6), + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + icon: const Icon(Icons.upload_file, size: 18), + onPressed: () async { + final path = await FilePicker.platform.saveFile(fileName: 'favorites.json'); + if (path == null) return; + await FavoriteStorage.exportToFile(path); + if (context.mounted) CustomToast.success(localizations.exportSuccess).show(context); + onChanged(); + }, + ), + const SizedBox(width: 3), + IconButton( + tooltip: localizations.import, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + icon: const Icon(Icons.download_for_offline_outlined, size: 18), + onPressed: () async { + final result = + await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['json', 'har']); + final file = result?.files.isNotEmpty == true ? result!.files.first : null; + if (file?.path == null) return; + + try { + await FavoriteStorage.importFromFile(file!.path!); + if (context.mounted) CustomToast.success(localizations.importSuccess).show(context); + onChanged(); + } catch (e) { + logger.e('Import favorites failed: $e'); + if (context.mounted) CustomToast.error('${localizations.importFailed}: $e').show(context); + } + }, + ), + ], + ), + ), + ), + const Divider(height: 1, thickness: 0.4), + ], + ); + } +} diff --git a/lib/ui/desktop/left_menus/history.dart b/lib/ui/desktop/left_menus/history.dart index 8a1d4ab..3e74e9d 100644 --- a/lib/ui/desktop/left_menus/history.dart +++ b/lib/ui/desktop/left_menus/history.dart @@ -153,11 +153,25 @@ class _HistoryListState extends State<_HistoryListWidget> { return Scaffold( appBar: PreferredSize( - preferredSize: const Size.fromHeight(38), + preferredSize: const Size.fromHeight(36), child: AppBar( - title: Text(localizations.historyRecord, style: const TextStyle(fontSize: 14)), + toolbarHeight: 36, + titleSpacing: 8, + centerTitle: false, + title: Text( + localizations.historyRecord, + style: TextStyle( + fontSize: 12.5, + color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.82), + ), + ), + bottom: const PreferredSize(preferredSize: Size.fromHeight(1), child: Divider(height: 1, thickness: 0.4)), actions: [ - IconButton(onPressed: import, icon: const Icon(Icons.input, size: 18), tooltip: localizations.import), + IconButton( + onPressed: import, + icon: const Icon(Icons.input, size: 18), + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + tooltip: localizations.import), const SizedBox(width: 3), HistoryCacheTime(proxyServer.configuration, onSelected: (val) { if (val == 0) { diff --git a/lib/ui/desktop/left_menus/navigation.dart b/lib/ui/desktop/left_menus/navigation.dart index 516aafd..9040b84 100644 --- a/lib/ui/desktop/left_menus/navigation.dart +++ b/lib/ui/desktop/left_menus/navigation.dart @@ -85,6 +85,7 @@ class _LeftNavigationBarState extends State { message: localizations.preference, preferBelow: false, child: IconButton( + iconSize: 22, onPressed: () { showDialog( context: context, @@ -96,6 +97,7 @@ class _LeftNavigationBarState extends State { preferBelow: true, message: localizations.feedback, child: IconButton( + iconSize: 22, onPressed: () => launchUrl(Uri.parse("https://github.com/wanghongenpin/proxypin/issues")), icon: Icon(Icons.feedback_outlined, color: Colors.grey.shade500), )), diff --git a/lib/ui/desktop/preference.dart b/lib/ui/desktop/preference.dart index 154597a..b092000 100644 --- a/lib/ui/desktop/preference.dart +++ b/lib/ui/desktop/preference.dart @@ -82,7 +82,8 @@ class _PreferenceState extends State { items: [ DropdownMenuItem(value: null, child: Text(localizations.followSystem)), const DropdownMenuItem(value: Locale.fromSubtags(languageCode: "zh"), child: Text("简体中文")), - const DropdownMenuItem(value: Locale.fromSubtags(languageCode: "zh", scriptCode: "Hant"), child: Text("繁體中文")), + const DropdownMenuItem( + value: Locale.fromSubtags(languageCode: "zh", scriptCode: "Hant"), child: Text("繁體中文")), const DropdownMenuItem(value: Locale.fromSubtags(languageCode: "en"), child: Text("English")), ]), ]), @@ -122,7 +123,8 @@ class _PreferenceState extends State { const Divider(), ListTile( contentPadding: EdgeInsets.zero, - title: Text(localizations.autoStartup), //默认是否启动 + title: Text(localizations.autoStartup, style: titleStyle), + //默认是否启动 subtitle: Text(localizations.autoStartupDescribe, style: subtitleStyle), trailing: SwitchWidget( scale: 0.75, @@ -133,7 +135,7 @@ class _PreferenceState extends State { })), ListTile( contentPadding: EdgeInsets.zero, - title: Text(localizations.headerExpanded), + title: Text(localizations.headerExpanded, style: titleStyle), subtitle: Text(localizations.headerExpandedSubtitle, style: subtitleStyle), trailing: SwitchWidget( scale: 0.75, @@ -142,10 +144,9 @@ class _PreferenceState extends State { appConfiguration.headerExpanded = value; appConfiguration.flushConfig(); })), - SizedBox(height: 5), ListTile( contentPadding: EdgeInsets.zero, - title: Text(localizations.memoryCleanup), + title: Text(localizations.memoryCleanup, style: titleStyle), subtitle: Text(localizations.memoryCleanupSubtitle, style: subtitleStyle), trailing: memoryCleanup(context, localizations)), diff --git a/lib/ui/desktop/request/report_servers.dart b/lib/ui/desktop/request/report_servers.dart index 4885f95..87ce3b8 100644 --- a/lib/ui/desktop/request/report_servers.dart +++ b/lib/ui/desktop/request/report_servers.dart @@ -17,6 +17,8 @@ Future showReportServersDialog(BuildContext context) { barrierDismissible: false, builder: (ctx) => Dialog( insetPadding: const EdgeInsets.all(16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + clipBehavior: Clip.antiAlias, child: SizedBox( width: 570, height: 560, @@ -144,7 +146,6 @@ class _ReportServersPageState extends State { SizedBox( width: 100, child: DropdownButtonFormField( - // use `value` for compatibility with older SDKs value: compression, decoration: dec(), isDense: true, diff --git a/lib/ui/desktop/request/request.dart b/lib/ui/desktop/request/request.dart index cdfba66..f01e215 100644 --- a/lib/ui/desktop/request/request.dart +++ b/lib/ui/desktop/request/request.dart @@ -93,6 +93,12 @@ class _RequestWidgetState extends State { super.initState(); } + @override + void dispose() { + autoReadRequests.remove(widget.request.requestId); + super.dispose(); + } + @override Widget build(BuildContext context) { var request = widget.request; diff --git a/lib/ui/desktop/setting/about.dart b/lib/ui/desktop/setting/about.dart index 512927a..7ab3de2 100644 --- a/lib/ui/desktop/setting/about.dart +++ b/lib/ui/desktop/setting/about.dart @@ -1,4 +1,3 @@ -import 'dart:ui' show FontFeature; import 'package:flutter/material.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/ui/app_update/app_update_repository.dart'; @@ -44,18 +43,11 @@ class _AppUpdateStateChecking extends State { child: Text(isCN ? "全平台开源免费抓包软件" : "Full platform open source free capture HTTP(S) traffic software", textAlign: TextAlign.center, style: const TextStyle(height: 1.3))), const SizedBox(height: 10), - DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withOpacity(0.4), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Text("Version ${AppConfiguration.version}", - style: TextStyle( - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.primary, - fontFeatures: const [FontFeature.tabularFigures()])), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text( + "Version ${AppConfiguration.version}", + style: TextStyle(fontWeight: FontWeight.w500), ), ), const SizedBox(height: 12), @@ -95,16 +87,15 @@ class _AppUpdateStateChecking extends State { onTap: () { showDialog( context: context, - builder: (ctx) => ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 385), - child: AlertDialog( - title: Text(localizations.privacyPolicy), - content: SingleChildScrollView( - child: Text(localizations.privacyContent, style: const TextStyle(height: 1.35))), - actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(localizations.close)) - ], - ), + builder: (ctx) => AlertDialog( + title: Text(localizations.privacyPolicy), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 385), + child: Text(localizations.privacyContent, style: const TextStyle(height: 1.35)))), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(localizations.close)) + ], )); }), ListTile( diff --git a/lib/ui/desktop/setting/request_crypto.dart b/lib/ui/desktop/setting/request_crypto.dart new file mode 100644 index 0000000..fa21a11 --- /dev/null +++ b/lib/ui/desktop/setting/request_crypto.dart @@ -0,0 +1,794 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_toastr/flutter_toastr.dart'; +import 'package:proxypin/l10n/app_localizations.dart'; +import 'package:proxypin/network/components/manager/request_crypto_manager.dart'; +import 'package:proxypin/network/util/logger.dart'; +import 'package:proxypin/ui/component/utils.dart'; +import 'package:proxypin/ui/component/widgets.dart'; + +bool _refresh = false; + +/// 刷新配置 +Future _refreshConfig({bool force = false}) async { + if (force) { + _refresh = false; + await RequestCryptoManager.instance.then((manager) => manager.flushConfig()); + await DesktopMultiWindow.invokeMethod(0, "refreshRequestCrypto"); + return; + } + + if (_refresh) { + return; + } + _refresh = true; + Future.delayed(const Duration(milliseconds: 1000), () async { + _refresh = false; + await RequestCryptoManager.instance.then((manager) => manager.flushConfig()); + await DesktopMultiWindow.invokeMethod(0, "refreshRequestCrypto"); + }); +} + +class RequestCryptoPage extends StatefulWidget { + final int? windowId; + final RequestCryptoManager manager; + + const RequestCryptoPage({super.key, this.windowId, required this.manager}); + + @override + State createState() => _RequestCryptoPageState(); +} + +class _RequestCryptoPageState extends State { + AppLocalizations get localizations => AppLocalizations.of(context)!; + + RequestCryptoManager get manager => widget.manager; + + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_onKeyEvent); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_onKeyEvent); + super.dispose(); + } + + bool _onKeyEvent(KeyEvent event) { + if (HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.escape) && Navigator.canPop(context)) { + Navigator.maybePop(context); + return true; + } + + if ((HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) && + event.logicalKey == LogicalKeyboardKey.keyW) { + if (Navigator.canPop(context)) { + Navigator.pop(context); + return true; + } + if (widget.windowId != null) WindowController.fromWindowId(widget.windowId!).close(); + return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + bool isEN = Localizations.localeOf(context).languageCode == 'en'; + return Scaffold( + backgroundColor: Theme.of(context).dialogTheme.backgroundColor, + appBar: AppBar( + title: Text(localizations.requestCrypto, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + toolbarHeight: 36, + centerTitle: true), + body: Center( + child: Container( + padding: const EdgeInsets.only(left: 15, right: 10), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + SizedBox( + width: isEN ? 310 : 225, + child: ListTile( + title: Text("${localizations.enable} ${localizations.requestCrypto}"), + trailing: SwitchWidget( + value: manager.enabled, + scale: 0.8, + onChanged: (value) { + manager.enabled = value; + _refreshConfig(); + }))), + const SizedBox(width: 10), + Expanded( + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton.icon( + icon: const Icon(Icons.add, size: 18), label: Text(localizations.add), onPressed: _addRule), + const SizedBox(width: 5), + TextButton.icon( + icon: const Icon(Icons.input_rounded, size: 18), + onPressed: _import, + label: Text(localizations.import)) + ])), + const SizedBox(width: 15) + ]), + const SizedBox(height: 16), + CryptoRuleList(manager: manager, windowId: widget.windowId), + ])))); + } + + Future _addRule() async { + final newRule = + await showDialog(context: context, barrierDismissible: false, builder: (_) => CryptoRuleDialog()); + if (newRule == null) return; + await manager.addRule(newRule); + setState(() {}); + _refreshConfig(force: true); + } + + Future _import() async { + String? path; + if (Platform.isMacOS) { + path = await DesktopMultiWindow.invokeMethod(0, "pickFiles", { + "allowedExtensions": ['json'] + }); + if (widget.windowId != null) WindowController.fromWindowId(widget.windowId!).show(); + } else { + FilePickerResult? result = + await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['json']); + path = result?.files.single.path; + } + if (path == null) return; + try { + final content = await File(path).readAsString(); + final List list = jsonDecode(content); + for (final item in list) { + await manager.addRule(CryptoRule.fromJson(Map.from(item))); + } + _refreshConfig(force: true); + if (mounted) FlutterToastr.show(localizations.importSuccess, context); + } catch (e) { + logger.e('导入失败 $path', error: e); + if (mounted) FlutterToastr.show('${localizations.importFailed} $e', context); + } + } +} + +// Reusable rule list component extracted from _RequestCryptoPageState +class CryptoRuleList extends StatefulWidget { + final int? windowId; + final RequestCryptoManager manager; + + const CryptoRuleList({ + required this.manager, + super.key, + this.windowId, + }); + + @override + State createState() => _CryptoRuleListState(); +} + +class _CryptoRuleListState extends State { + RequestCryptoManager get manager => widget.manager; + Set selected = {}; + bool isPressed = false; + Offset? lastPressPosition; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onSecondaryTap: () { + if (lastPressPosition == null) { + return; + } + showGlobalMenu(lastPressPosition!); + }, + onTapDown: (details) { + if (selected.isEmpty) { + return; + } + if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { + return; + } + setState(() { + selected.clear(); + }); + }, + child: Listener( + onPointerUp: (event) => isPressed = false, + onPointerDown: (event) { + lastPressPosition = event.localPosition; + if (event.buttons == kPrimaryMouseButton) { + isPressed = true; + } + }, + child: Container( + padding: const EdgeInsets.only(top: 10), + constraints: const BoxConstraints(minHeight: 200, maxHeight: 600), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withAlpha((0.2 * 255).round()))), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 5, bottom: 5), + child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + Container(width: 80, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), + SizedBox(width: 80, child: Text(localizations.enable, textAlign: TextAlign.center)), + const VerticalDivider(width: 24), + const Expanded(child: Text('URL', textAlign: TextAlign.center)), + SizedBox(width: 120, child: Text(localizations.cryptoRuleField, textAlign: TextAlign.center)), + SizedBox(width: 220, child: Text('AES Key', textAlign: TextAlign.center)), + ]), + ), + const Divider(thickness: 0.5, height: 5), + Column(children: rows(manager.rules)) + ], + ), + ), + ), + ); + } + + List rows(List rules) { + var primaryColor = Theme.of(context).colorScheme.primary; + + return List.generate(rules.length, (index) { + final rule = rules[index]; + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + hoverColor: primaryColor.withOpacity(0.3), + onDoubleTap: () => showEdit(index), + onSecondaryTapDown: (details) => showMenus(details, index), + onHover: (hover) { + if (isPressed && !selected.contains(index)) { + setState(() { + selected.add(index); + }); + } + }, + onTap: () { + if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { + setState(() { + selected.contains(index) ? selected.remove(index) : selected.add(index); + }); + return; + } + if (selected.isEmpty) { + return; + } + setState(() { + selected.clear(); + }); + }, + child: Container( + color: selected.contains(index) + ? primaryColor.withOpacity(0.6) + : index.isEven + ? Colors.grey.withOpacity(0.1) + : null, + height: 32, + padding: const EdgeInsets.all(5), + child: Row(children: [ + SizedBox( + width: 80, + child: Text(rule.name, + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + ), + SizedBox( + width: 80, + child: SwitchWidget( + scale: 0.7, + value: rule.enabled, + onChanged: (val) { + rules[index].enabled = val; + _refreshConfig(); + })), + const SizedBox(width: 8), + Expanded( + child: Text(rule.urlPattern.isEmpty ? localizations.emptyMatchAll : rule.urlPattern, + overflow: TextOverflow.ellipsis)), + SizedBox( + width: 120, + child: Text(rule.field ?? '', overflow: TextOverflow.ellipsis, textAlign: TextAlign.center)), + SizedBox( + width: 220, + child: Text(_formatKey(rule.config.key), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center)), + ]), + ), + ); + }); + } + + Future showEdit([int? index]) async { + final rule = index == null ? null : manager.rules[index]; + if (!mounted) { + return; + } + + final updated = await showDialog(context: context, builder: (_) => CryptoRuleDialog(rule: rule)); + if (updated == null) return; + if (index == null) { + await manager.addRule(updated); + } else { + await manager.updateRule(index, updated); + } + _refreshConfig(force: true); + setState(() {}); + } + + Future removeRules(List indexes) async { + if (indexes.isEmpty) return; + showConfirmDialog(context, content: localizations.confirmContent, onConfirm: () async { + indexes.sort((a, b) => b.compareTo(a)); + for (final index in indexes) { + await manager.removeRule(index); + } + selected.clear(); + _refreshConfig(force: true); + }); + } + + void showMenus(TapDownDetails details, int index) { + if (selected.length > 1) { + showGlobalMenu(details.globalPosition); + return; + } + setState(() { + selected.add(index); + }); + + showContextMenu(context, details.globalPosition, items: [ + PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => showEdit(index)), + PopupMenuItem(height: 35, child: Text(localizations.delete), onTap: () => removeRules([index])) + ]); + } + + void showGlobalMenu(Offset offset) { + showContextMenu(context, offset, items: [ + PopupMenuItem(height: 35, onTap: showEdit, child: Text(localizations.newBuilt)), + PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => export(selected.toList())), + const PopupMenuDivider(), + PopupMenuItem(height: 35, child: Text(localizations.enableSelect), onTap: () => enableStatus(true)), + PopupMenuItem(height: 35, child: Text(localizations.disableSelect), onTap: () => enableStatus(false)), + const PopupMenuDivider(), + PopupMenuItem(height: 35, child: Text(localizations.deleteSelect), onTap: () => removeRules(selected.toList())) + ]); + } + + Future enableStatus(bool enable) async { + if (selected.isEmpty) return; + for (final entry in selected) { + manager.rules[entry].enabled = enable; + } + setState(() {}); + _refreshConfig(force: true); + } + + Future export(List indexes) async { + if (indexes.isEmpty) return; + indexes.sort(); + final data = indexes.map((i) => manager.rules[i].toJson()).toList(); + String? path; + if (Platform.isMacOS) { + path = await DesktopMultiWindow.invokeMethod(0, "saveFile", {"fileName": 'request_crypto.json'}); + if (widget.windowId != null) WindowController.fromWindowId(widget.windowId!).show(); + } else { + path = await FilePicker.platform.saveFile(fileName: 'request_crypto.json'); + } + if (path == null) return; + await File(path).writeAsString(jsonEncode(data)); + if (mounted) FlutterToastr.show(localizations.exportSuccess, context); + } + + // Format AES key for display: strip optional 'base64:' prefix and truncate long values + String _formatKey(String? raw) { + if (raw == null || raw.trim().isEmpty) return ''; + var k = raw.trim(); + if (k.startsWith('base64:')) { + k = k.substring(7); + } + if (k.length > 40) return '${k.substring(0, 40)}...'; + return k; + } +} + +class CryptoRuleDialog extends StatefulWidget { + final CryptoRule? rule; + + const CryptoRuleDialog({super.key, this.rule}); + + @override + State createState() => _CryptoRuleDialogState(); +} + +class _CryptoRuleDialogState extends State { + late TextEditingController nameController; + late TextEditingController patternController; + late TextEditingController keyController; + late TextEditingController ivController; + late TextEditingController fieldInputController; + String mode = 'CBC'; + String padding = 'PKCS7'; + int length = 128; + bool enabled = true; + + // single field support + late String fieldItem; + final _formKey = GlobalKey(); + String keyFormat = 'text'; + String ivSource = 'manual'; + int ivPrefixLength = 16; + + @override + void initState() { + super.initState(); + final rule = widget.rule; + nameController = TextEditingController(text: rule?.name ?? ''); + patternController = TextEditingController(text: rule?.urlPattern ?? ''); + keyController = TextEditingController(text: rule?.config.key); + ivController = TextEditingController(text: rule?.config.iv); + // single field support: initialize from first existing field if present + fieldInputController = TextEditingController(text: rule?.field ?? ''); + mode = rule?.config.mode ?? 'CBC'; + padding = rule?.config.padding ?? 'PKCS7'; + length = rule?.config.keyLength ?? 256; + enabled = rule?.enabled ?? true; + fieldItem = rule?.field ?? ''; + // detect stored key/iv prefix (support base64: or plain text) + final storedKey = rule?.config.key ?? ''; + if (storedKey.startsWith('base64:')) { + keyFormat = 'base64'; + keyController.text = storedKey.substring(7); + } else { + keyFormat = 'text'; + keyController.text = storedKey; + } + + final storedIv = rule?.config.iv ?? ''; + // keep stored iv as-is if prefixed with base64:, otherwise show raw value + if (storedIv.startsWith('base64:')) { + ivController.text = storedIv.substring(7); + } else { + ivController.text = storedIv; + } + // iv source and prefix length + ivSource = rule?.config.ivSource ?? 'manual'; + ivPrefixLength = rule?.config.ivPrefixLength ?? 16; + } + + @override + void dispose() { + nameController.dispose(); + patternController.dispose(); + keyController.dispose(); + ivController.dispose(); + fieldInputController.dispose(); + super.dispose(); + } + + InputDecoration decorate(BuildContext context, String? label, {String? hint, Widget? suffixIcon}) { + return InputDecoration( + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: label, + hintText: hint, + hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 15), + isDense: true, + border: const OutlineInputBorder()); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return AlertDialog( + title: Text(widget.rule == null ? l10n.newBuilt : l10n.edit), + scrollable: true, + titlePadding: const EdgeInsets.only(top: 10, left: 20), + actionsPadding: const EdgeInsets.only(right: 15, bottom: 15), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), + content: Container( + width: 550, + constraints: const BoxConstraints(minHeight: 200, maxHeight: 560), + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withAlpha((0.5 * 255).round()), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withAlpha((0.2 * 255).round())), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(l10n.match, style: theme.textTheme.titleSmall), + const SizedBox(height: 12), + TextFormField(controller: nameController, decoration: decorate(context, l10n.name)), + const SizedBox(height: 12), + TextFormField( + controller: patternController, + decoration: decorate(context, "URL", hint: 'https://www.example.com/api/*'), + validator: (val) => val == null || val.trim().isEmpty ? l10n.cannotBeEmpty : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: fieldInputController, + decoration: decorate(context, l10n.cryptoRuleField, hint: 'data.field'), + ), + const SizedBox(height: 12), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(l10n.enable), + value: enabled, + onChanged: (value) => setState(() => enabled = value), + ), + ]), + ), + ), + const SizedBox(height: 12), + Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withAlpha((0.5 * 255).round()), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withAlpha((0.2 * 255).round())), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text("AES", style: theme.textTheme.titleSmall), + const SizedBox(height: 12), + Row(children: [ + Text("Mode", style: theme.textTheme.labelMedium), + const SizedBox(width: 8), + Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: mode, + items: const [ + DropdownMenuItem(value: 'ECB', child: Text('ECB')), + DropdownMenuItem(value: 'CBC', child: Text('CBC')), + ], + onChanged: (v) => setState(() => mode = v ?? 'ECB'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + const SizedBox(width: 12), + Text('Padding', style: theme.textTheme.labelMedium), + const SizedBox(width: 8), + Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: padding, + items: const [ + DropdownMenuItem(value: 'PKCS7', child: Text('PKCS7')), + DropdownMenuItem(value: 'ZeroPadding', child: Text('ZeroPadding')), + ], + onChanged: (v) => setState(() => padding = v ?? 'PKCS7'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + const SizedBox(width: 12), + Text('Key Length', style: theme.textTheme.labelMedium), + const SizedBox(width: 8), + Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: length, + items: const [ + DropdownMenuItem(value: 128, child: Text('128')), + DropdownMenuItem(value: 192, child: Text('192')), + DropdownMenuItem(value: 256, child: Text('256')), + ], + onChanged: (v) => setState(() => length = v ?? 128), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ]), + const SizedBox(height: 12), + // Key input and format selector in a single row for nicer UI + Row(children: [ + Container( + height: 42, + width: 92, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: keyFormat, + items: const [ + DropdownMenuItem(value: 'text', child: Text('text')), + DropdownMenuItem(value: 'base64', child: Text('base64')), + ], + onChanged: (v) => setState(() => keyFormat = v ?? 'text'), + style: Theme.of(context).textTheme.bodyMedium, + iconEnabledColor: Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + child: TextFormField( + controller: keyController, + maxLength: 128, + decoration: decorate(context, "Key").copyWith(counterText: ''), + validator: (val) => val == null || val.trim().isEmpty ? l10n.cannotBeEmpty : null, + ), + ), + ), + ]), + const SizedBox(height: 12), + // Compact single-line IV controls for CBC + if (mode == 'CBC') + Row(children: [ + Container( + height: 42, + constraints: const BoxConstraints(minWidth: 92), + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: ivSource, + items: [ + DropdownMenuItem(value: 'manual', child: Text(l10n.manual)), + DropdownMenuItem(value: 'prefix', child: Text(l10n.cryptoIvPrefixLabel)), + ], + onChanged: (v) => setState(() => ivSource = v ?? 'manual'), + style: Theme.of(context).textTheme.bodyMedium, + iconEnabledColor: Theme.of(context).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 8), + // narrow IV input when manual (fixed width for compactness) + if (ivSource == 'manual') + SizedBox( + width: 260, + height: 42, + child: TextFormField( + controller: ivController, + decoration: decorate(context, 'IV').copyWith( + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10)), + validator: (val) => (ivSource == 'manual' && (val == null || val.trim().isEmpty)) + ? l10n.cannotBeEmpty + : null, + ), + ), + if (ivSource == 'manual') const SizedBox(width: 8), + if (ivSource == 'prefix') + Tooltip( + message: l10n.cryptoIvPrefixTooltip, + child: Icon(Icons.info_outline, size: 16, color: theme.dividerColor)), + if (ivSource == 'prefix') const SizedBox(width: 8), + // compact numeric stepper (prefix length) + if (ivSource == 'prefix') + Container( + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor.withAlpha(0x40)), + borderRadius: BorderRadius.circular(4)), + child: Row(children: [ + IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.remove, size: 14), + onPressed: ivSource == 'prefix' + ? () => setState(() => ivPrefixLength = math.max(1, ivPrefixLength - 1)) + : null, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + ), + SizedBox( + width: 36, + child: Center( + child: Text(ivPrefixLength.toString(), style: theme.textTheme.bodySmall))), + IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.add, size: 14), + onPressed: ivSource == 'prefix' + ? () => setState(() => ivPrefixLength = math.min(1024, ivPrefixLength + 1)) + : null, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + ), + ]), + ), + ]), + ]), + ), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(l10n.cancel)), + FilledButton( + onPressed: () { + if (!(_formKey.currentState as FormState).validate()) return; + String outKey = keyController.text.trim(); + // add prefix based on selected keyFormat if user did not already include explicit prefix + if (!(outKey.startsWith('base64:'))) { + if (keyFormat == 'base64') { + outKey = 'base64:$outKey'; + } + } + + // only set explicit IV when manual source is used + String outIv = ''; + if (ivSource == 'manual') { + outIv = ivController.text.trim(); + if (!(outIv.startsWith('base64:'))) { + if (keyFormat == 'base64') { + outIv = 'base64:$outIv'; + } + } + } + + // save single field from the input controller + final savedField = fieldInputController.text.trim(); + final updated = (widget.rule ?? CryptoRule.newRule()).copyWith( + name: nameController.text.trim(), + urlPattern: patternController.text.trim(), + field: savedField, + enabled: enabled, + config: CryptoKeyConfig( + key: outKey, + iv: outIv, + ivSource: ivSource, + ivPrefixLength: ivPrefixLength, + mode: mode, + padding: padding, + keyLength: length), + ); + Navigator.of(context).pop(updated); + }, + child: Text(l10n.save), + ), + ], + ); + } +} diff --git a/lib/ui/desktop/setting/request_rewrite.dart b/lib/ui/desktop/setting/request_rewrite.dart index 2f25195..88f9613 100644 --- a/lib/ui/desktop/setting/request_rewrite.dart +++ b/lib/ui/desktop/setting/request_rewrite.dart @@ -604,6 +604,7 @@ class _RewriteRuleEditState extends State { height: 36, child: DropdownButtonFormField( onSaved: (val) => rule.type = val!, + value: ruleType, decoration: InputDecoration( errorStyle: const TextStyle(height: 0, fontSize: 0), contentPadding: const EdgeInsets.only(left: 7, right: 7), diff --git a/lib/ui/desktop/setting/script.dart b/lib/ui/desktop/setting/script.dart index 8d33ec8..3424173 100644 --- a/lib/ui/desktop/setting/script.dart +++ b/lib/ui/desktop/setting/script.dart @@ -27,6 +27,8 @@ import 'package:proxypin/l10n/app_localizations.dart'; import 'package:flutter_highlight/themes/monokai-sublime.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:highlight/languages/javascript.dart'; +import 'package:http/http.dart' as http; +import 'package:get/get.dart'; import 'package:proxypin/network/components/manager/script_manager.dart'; import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/ui/component/multi_window.dart'; @@ -207,7 +209,7 @@ class _ScriptWidgetState extends State { } /// 添加脚本 - scriptAdd() async { + Future scriptAdd() async { showDialog(barrierDismissible: false, context: context, builder: (_) => const ScriptEdit()).then((value) { if (value != null) { setState(() {}); @@ -323,10 +325,24 @@ class _ScriptConsoleState extends State { class ScriptEdit extends StatefulWidget { final ScriptItem? scriptItem; final String? script; - final String? url; - final String? title; - const ScriptEdit({super.key, this.scriptItem, this.script, this.url, this.title}); + /// Legacy single URL input; prefer [urls]. + final String? url; + + /// Optional multiple URLs input (matches mobile ScriptEdit). + final List? urls; + final String? title; + final bool fromRemoteUrl; + + const ScriptEdit({ + super.key, + this.scriptItem, + this.script, + this.url, + this.urls, + this.title, + this.fromRemoteUrl = false, + }); @override State createState() => _ScriptEditState(); @@ -336,15 +352,62 @@ class _ScriptEditState extends State { late CodeController script; late TextEditingController nameController; late List urlControllers; + late TextEditingController remoteUrlController; + late bool _useRemote; + final RxBool _fetchingRemoteScript = false.obs; AppLocalizations get localizations => AppLocalizations.of(context)!; + Future _fetchRemoteScript() async { + if (_fetchingRemoteScript.value) return; + final remoteUrl = remoteUrlController.text.trim(); + if (remoteUrl.isEmpty) { + FlutterToastr.show("${localizations.remoteUrl} ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top); + return; + } + + final uri = Uri.tryParse(remoteUrl); + if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https')) { + FlutterToastr.show("${localizations.remoteUrl} ${localizations.fail}", context, position: FlutterToastr.top); + return; + } + + try { + _fetchingRemoteScript.value = true; + final resp = await http.get(uri); + if (resp.statusCode < 200 || resp.statusCode >= 300) { + FlutterToastr.show("Fetch failed: HTTP ${resp.statusCode}", context, position: FlutterToastr.top); + return; + } + script.text = resp.body; + if (mounted) { + setState(() {}); + } + } catch (e) { + if (mounted) { + FlutterToastr.show("Fetch failed: $e", context, position: FlutterToastr.top); + } + } finally { + _fetchingRemoteScript.value = false; + } + } + + void _resetScript() { + script.text = ScriptManager.template; + script.text = ScriptManager.template; + } + @override void initState() { super.initState(); script = CodeController(language: javascript, text: widget.script ?? ScriptManager.template); nameController = TextEditingController(text: widget.scriptItem?.name ?? widget.title); - final urls = widget.scriptItem?.urls ?? (widget.url != null && widget.url!.isNotEmpty ? [widget.url!] : []); + remoteUrlController = TextEditingController(text: widget.scriptItem?.remoteUrl ?? ''); + _useRemote = widget.fromRemoteUrl || ((widget.scriptItem?.remoteUrl ?? '').trim().isNotEmpty); + final urls = widget.scriptItem?.urls ?? + (widget.urls != null && widget.urls!.isNotEmpty + ? widget.urls! + : (widget.url != null && widget.url!.isNotEmpty ? [widget.url!] : [])); urlControllers = urls.isNotEmpty ? urls.map((u) => TextEditingController(text: u)).toList() : [TextEditingController()]; } @@ -353,9 +416,12 @@ class _ScriptEditState extends State { void dispose() { script.dispose(); nameController.dispose(); + remoteUrlController.dispose(); for (final c in urlControllers) { c.dispose(); } + + _fetchingRemoteScript.close(); super.dispose(); } @@ -367,7 +433,7 @@ class _ScriptEditState extends State { return AlertDialog( scrollable: true, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)), - titlePadding: const EdgeInsets.only(left: 15, top: 5, right: 15), + titlePadding: const EdgeInsets.only(left: 15, top: 6, right: 15), title: Row(children: [ Text(localizations.scriptEdit, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), const SizedBox(width: 10), @@ -383,6 +449,7 @@ class _ScriptEditState extends State { : 'https://github.com/wanghongenpin/proxypin/wiki/Script'))), const Expanded(child: Align(alignment: Alignment.topRight, child: CloseButton())) ]), + contentPadding: const EdgeInsets.only(left: 15, right: 15), actionsPadding: const EdgeInsets.only(right: 10, bottom: 10), actions: [ ElevatedButton(onPressed: () => Navigator.of(context).pop(), child: Text(localizations.cancel)), @@ -398,13 +465,24 @@ class _ScriptEditState extends State { FlutterToastr.show("URL ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top); return; } + + // Only persist remoteUrl when remote mode is enabled. + final remoteUrl = _useRemote ? remoteUrlController.text.trim() : ''; + final hasRemote = remoteUrl.isNotEmpty; + if (_useRemote && !hasRemote) { + FlutterToastr.show("${localizations.remoteUrl} ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top); + return; + } + if (widget.scriptItem == null) { var scriptItem = ScriptItem(true, nameController.text, urls); + scriptItem.remoteUrl = _useRemote ? remoteUrl : null; await (await ScriptManager.instance).addScript(scriptItem, script.text); } else { widget.scriptItem?.name = nameController.text; widget.scriptItem?.urls = urls; widget.scriptItem?.urlRegs = null; + widget.scriptItem?.remoteUrl = _useRemote ? remoteUrl : null; (await ScriptManager.instance).updateScript(widget.scriptItem!, script.text); } _refreshScript(); @@ -429,7 +507,6 @@ class _ScriptEditState extends State { child: Padding( padding: const EdgeInsets.all(10), child: textField("${localizations.name}:", nameController, localizations.pleaseEnter))), - const SizedBox(height: 10), // URLs section Card( @@ -439,7 +516,7 @@ class _ScriptEditState extends State { side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), borderRadius: BorderRadius.circular(8)), child: Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 10), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ const Text("URL(s):"), @@ -488,7 +565,6 @@ class _ScriptEditState extends State { }), ]))) ]))), - const SizedBox(height: 10), // Script section Card( @@ -498,10 +574,72 @@ class _ScriptEditState extends State { side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), borderRadius: BorderRadius.circular(8)), child: Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.all(6), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Text("${localizations.script}:", style: const TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(width: 12), + SizedBox( + width: 155, + height: 34, + child: DropdownButtonFormField( + initialValue: _useRemote, + items: [ + DropdownMenuItem(value: false, child: Text(localizations.local)), + DropdownMenuItem(value: true, child: Text(localizations.remoteUrl)), + ], + onChanged: (val) { + if (val == null) return; + setState(() { + _useRemote = val; + }); + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder(), + ), + ), + ), + + // Put Remote URL right after type selector. + if (_useRemote) ...[ + const SizedBox(width: 10), + Expanded( + flex: 6, + child: SizedBox( + height: 34, + child: TextFormField( + controller: remoteUrlController, + keyboardType: TextInputType.url, + decoration: InputDecoration( + hintText: 'https://example.com/script.js', + hintStyle: const TextStyle(fontSize: 14, color: Colors.grey), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder(), + ), + onFieldSubmitted: (_) => _fetchRemoteScript(), + ), + ), + ), + const SizedBox(width: 8), + Obx(() => FilledButton.tonal( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10)), + onPressed: _fetchingRemoteScript.value ? null : _fetchRemoteScript, + child: _fetchingRemoteScript.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(localizations.view), + )), + ], + const Spacer(), Tooltip( message: localizations.copy, @@ -512,36 +650,13 @@ class _ScriptEditState extends State { FlutterToastr.show(localizations.copied, context, position: FlutterToastr.top); })), Tooltip( - message: 'Paste', + message: 'Reset', child: IconButton( - icon: const Icon(Icons.content_paste_go_outlined, size: 19), - onPressed: () async { - final data = await Clipboard.getData('text/plain'); - final paste = data?.text; - if (paste == null || paste.isEmpty) return; - final sel = script.selection; - if (sel.isValid) { - final text = script.text; - final start = sel.start; - final end = sel.end; - final newText = text.replaceRange(start, end, paste); - script.value = script.value.copyWith( - text: newText, - selection: TextSelection.collapsed(offset: start + paste.length)); - } else { - script.text += paste; - } - })), - Tooltip( - message: localizations.clear, - child: IconButton( - icon: const Icon(Icons.delete_sweep_outlined, size: 22), - onPressed: () { - script.text = ''; - })), + icon: const Icon(Icons.settings_backup_restore, size: 22), + onPressed: _resetScript)), const SizedBox(width: 5) ]), - const SizedBox(height: 5), + const SizedBox(height: 8), SizedBox( width: 850, height: 380, @@ -555,10 +670,10 @@ class _ScriptEditState extends State { border: Border.all(color: Colors.grey.withOpacity(0.2))), child: SingleChildScrollView( child: CodeField( + readOnly: _useRemote, textStyle: const TextStyle(fontSize: 13, color: Colors.white), controller: script, gutterStyle: const GutterStyle(width: 50, margin: 0), - onTapOutside: (event) => FocusScope.of(context).unfocus(), )))))) ]))) ], @@ -639,7 +754,7 @@ class _ScriptListState extends State { }, child: Container( padding: const EdgeInsets.only(top: 10), - height: 530, + height: 630, decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), child: SingleChildScrollView( child: Column(children: [ @@ -658,6 +773,8 @@ class _ScriptListState extends State { var primaryColor = Theme.of(context).colorScheme.primary; return List.generate(list.length, (index) { + final item = list[index]; + final isRemote = item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty; return InkWell( // onTap: () { // selected[index] = !(selected[index] ?? false); @@ -699,19 +816,27 @@ class _ScriptListState extends State { padding: const EdgeInsets.all(5), child: Row( children: [ - SizedBox(width: 200, child: Text(list[index].name!, style: const TextStyle(fontSize: 13))), + SizedBox( + width: 200, + child: Row(children: [ + Expanded(child: Text(item.name!, style: const TextStyle(fontSize: 13))), + if (isRemote) + const Padding( + padding: EdgeInsets.only(left: 6), + child: Text('R', style: TextStyle(fontSize: 11, color: Colors.blue))), + ])), SizedBox( width: 40, child: Transform.scale( scale: 0.6, child: SwitchWidget( - value: list[index].enabled, + value: item.enabled, onChanged: (val) { - list[index].enabled = val; + item.enabled = val; _refreshScript(); }))), const SizedBox(width: 20), - Expanded(child: Text(list[index].urls.join(', '), style: const TextStyle(fontSize: 13))), + Expanded(child: Text(item.urls.join(', '), style: const TextStyle(fontSize: 13))), ], ))); }); @@ -768,7 +893,14 @@ class _ScriptListState extends State { } Future showEdit([int? index]) async { - String? script = index == null ? null : await (await ScriptManager.instance).getScript(widget.scripts[index]); + String? script; + if (index != null) { + var scriptManager = await ScriptManager.instance; + var scriptItem = widget.scripts[index]; + if (scriptItem.remoteUrl == null || scriptItem.remoteUrl?.isEmpty == true) { + script = await scriptManager.getScript(scriptItem); + } + } if (!mounted) { return; } @@ -805,7 +937,11 @@ class _ScriptListState extends State { var item = widget.scripts[idx]; var map = item.toJson(); map.remove("scriptPath"); - map['script'] = await scriptManager.getScript(item); + + if (item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty) { + map['script'] = await scriptManager.getScript(item); + } + json.add(map); } diff --git a/lib/ui/desktop/setting/setting.dart b/lib/ui/desktop/setting/setting.dart index 5568885..93dddcc 100644 --- a/lib/ui/desktop/setting/setting.dart +++ b/lib/ui/desktop/setting/setting.dart @@ -75,8 +75,9 @@ class _SettingState extends State { item(localizations.requestBlock, onPressed: showRequestBlock), item(localizations.requestRewrite, onPressed: requestRewrite), item(localizations.requestMap, onPressed: requestMap), + item(localizations.requestCrypto, onPressed: showRequestCrypto), item(localizations.script, - onPressed: () => MultiWindow.openWindow(localizations.script, 'ScriptWidget', size: const Size(800, 700))), + onPressed: () => MultiWindow.openWindow(localizations.script, 'ScriptWidget', size: const Size(800, 780))), item(localizations.externalProxy, onPressed: setExternalProxy), item(localizations.about, onPressed: showAbout), ], @@ -140,6 +141,10 @@ class _SettingState extends State { context: context, builder: (context) => RequestBlock(requestBlockManager: requestBlockManager)); } + + void showRequestCrypto() { + MultiWindow.openWindow(localizations.requestCrypto, 'RequestCryptoPage', size: const Size(820, 750)); + } } ///代理菜单 diff --git a/lib/ui/mobile/menu/bottom_navigation.dart b/lib/ui/mobile/menu/bottom_navigation.dart index 12846a1..57a4c76 100644 --- a/lib/ui/mobile/menu/bottom_navigation.dart +++ b/lib/ui/mobile/menu/bottom_navigation.dart @@ -30,6 +30,7 @@ import 'package:proxypin/ui/mobile/mobile.dart'; import 'package:proxypin/ui/mobile/request/favorite.dart'; import 'package:proxypin/ui/mobile/request/history.dart'; import 'package:proxypin/ui/mobile/setting/request_block.dart'; +import 'package:proxypin/ui/mobile/setting/request_crypto.dart'; import 'package:proxypin/ui/mobile/setting/request_rewrite.dart'; import 'package:proxypin/ui/mobile/setting/script.dart'; import 'package:proxypin/ui/mobile/setting/ssl.dart'; @@ -143,6 +144,12 @@ class _ConfigPageState extends State { trailing: arrow, onTap: () => navigator(context, MobileRequestMapPage())), Divider(height: 0, thickness: 0.3, color: Theme.of(context).dividerColor.withOpacity(0.22)), + ListTile( + title: Text(localizations.requestCrypto), + leading: Icon(Icons.lock_outline, color: color), + trailing: arrow, + onTap: () => navigator(context, const MobileRequestCryptoPage())), + Divider(height: 0, thickness: 0.3, color: Theme.of(context).dividerColor.withValues(alpha: 0.22)), ListTile( title: Text(localizations.script), leading: Icon(Icons.javascript_outlined, color: color), diff --git a/lib/ui/mobile/menu/drawer.dart b/lib/ui/mobile/menu/drawer.dart index 5c7833b..25c2388 100644 --- a/lib/ui/mobile/menu/drawer.dart +++ b/lib/ui/mobile/menu/drawer.dart @@ -34,6 +34,7 @@ import 'package:proxypin/ui/mobile/setting/app_filter.dart'; import 'package:proxypin/ui/mobile/setting/filter.dart'; import 'package:proxypin/ui/mobile/setting/request_block.dart'; import 'package:proxypin/ui/mobile/setting/request_rewrite.dart'; +import 'package:proxypin/ui/mobile/setting/request_crypto.dart'; import 'package:proxypin/ui/mobile/setting/script.dart'; import 'package:proxypin/ui/mobile/setting/ssl.dart'; import 'package:proxypin/ui/mobile/widgets/about.dart'; @@ -133,6 +134,10 @@ class DrawerWidget extends StatelessWidget { title: Text(localizations.requestMap), leading: Icon(Icons.swap_horiz_outlined), onTap: () => navigator(context, MobileRequestMapPage())), + ListTile( + title: Text(localizations.requestCrypto), + leading: const Icon(Icons.lock_outline), + onTap: () => navigator(context, const MobileRequestCryptoPage())), ListTile( title: Text(localizations.script), leading: const Icon(Icons.code), diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index 926de31..9453a91 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -117,7 +117,7 @@ class MobileHomeState extends State implements EventListener, Li proxyServer.addListener(this); proxyServer.start(); - if (widget.appConfiguration.upgradeNoticeV23) { + if (widget.appConfiguration.upgradeNoticeV24) { WidgetsBinding.instance.addPostFrameCallback((_) { showUpgradeNotice(); }); @@ -287,26 +287,24 @@ class MobileHomeState extends State implements EventListener, Li String content = isCN ? '提示:默认不会开启HTTPS抓包,请安装证书后再开启HTTPS抓包。\n\n' - '1. 工具箱增加 WebSocket 请求测试;\n' - '2. 支持数据上报服务器;\n' - '3. 支持 SSE(event-stream)请求;\n' - '4. 增加保存HTTP请求;\n' - '5. 请求重写支持 请求方法匹配;\n' - '6. Android 系统导航栏颜色适配;\n' - '7. 修复 ios26 分享 bug;\n' - '8. bug修复和改进;\n' + '1. 增加收藏导出和导入;\n' + '2. 增加请求解密,可配置AES自动解密消息体;\n' + '3. 脚本支持远程URL获取执行;\n' + '4. HTTP Header 展示增加文本和表格切换;\n' + '5. 增加 Request Param 列表展示;\n' + '6. 应用过滤列表增加是否显示系统应用;\n' + '7. 更新JSON深色主题色,以提高可见度和美观度;\n' : 'Note: HTTPS capture is disabled by default — please install the certificate before enabling HTTPS capture.\n\n' - '1. Added WebSocket request testing in the Toolbox.\n' - '2. Added support for data-reporting servers.\n' - '3. Added support for Server-Sent Events (SSE / event-stream).\n' - '4. Added the ability to save HTTP requests.\n' - "5. Request rewrite rules now support matching by HTTP method.\n" - '6. Improved Android navigation bar color handling.\n' - '7. Fixed a sharing bug on iOS 26.\n' - '8. Various bug fixes and improvements.\n'; + '1. Added import/export for Favorites.\n' + '2. Added request decryption with configurable AES automatic body decryption.\n' + '3. Scripts can now be fetched from remote URLs and executed.\n' + '4. HTTP header view now supports switching between text and table modes.\n' + '5. Added a Request Params list view.\n' + '6. App filter list now includes an option to show system apps.\n' + '7. Updated JSON dark-theme colors for better visibility and appearance.\n'; showAlertDialog(isCN ? '更新内容V${AppConfiguration.version}' : "What's new in V${AppConfiguration.version}", content, () { - widget.appConfiguration.upgradeNoticeV23 = false; + widget.appConfiguration.upgradeNoticeV24 = false; widget.appConfiguration.flushConfig(); }); } diff --git a/lib/ui/mobile/request/favorite.dart b/lib/ui/mobile/request/favorite.dart index 648ab02..f56dda0 100644 --- a/lib/ui/mobile/request/favorite.dart +++ b/lib/ui/mobile/request/favorite.dart @@ -16,6 +16,7 @@ import 'dart:collection'; import 'dart:io'; +import 'dart:convert'; import 'package:date_format/date_format.dart'; import 'package:flutter/material.dart'; @@ -40,6 +41,7 @@ import 'package:proxypin/ui/mobile/setting/script.dart'; import 'package:proxypin/utils/curl.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:file_picker/file_picker.dart'; /// 收藏列表页面 /// @author WangHongEn @@ -57,10 +59,59 @@ class MobileFavorites extends StatefulWidget { class _FavoritesState extends State { AppLocalizations get localizations => AppLocalizations.of(context)!; + Future _exportJson() async { + final favorites = await FavoriteStorage.favorites; + final json = FavoriteStorage.toJson(favorites); + final bytes = utf8.encode(json); + final path = await FilePicker.platform.saveFile(fileName: 'favorites.json', bytes: bytes); + if (path == null) return; + if (mounted) FlutterToastr.show(localizations.exportSuccess, context); + } + + Future _materializePickedFile(PlatformFile file) async { + if (file.path != null) return file.path!; + if (file.bytes == null) return null; + final tmp = await File('${Directory.systemTemp.path}/${file.name}').create(); + await tmp.writeAsBytes(file.bytes!, flush: true); + return tmp.path; + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(localizations.favorites, style: const TextStyle(fontSize: 16)), centerTitle: true), + appBar: AppBar( + title: Text(localizations.favorites, style: const TextStyle(fontSize: 16)), + centerTitle: true, + actions: [ + IconButton( + tooltip: localizations.export, + icon: const Icon(Icons.upload_file, size: 20), + onPressed: () async { + try { + await _exportJson(); + } catch (e) { + if (context.mounted) FlutterToastr.show('${localizations.importFailed}: $e', context); + } + }), + IconButton( + tooltip: localizations.import, + icon: const Icon(Icons.download_for_offline_outlined, size: 20), + onPressed: () async { + final result = await FilePicker.platform + .pickFiles(type: FileType.custom, allowedExtensions: ['json', 'har'], withData: true); + final file = result?.files.isNotEmpty == true ? result!.files.first : null; + if (file == null) return; + final path = await _materializePickedFile(file); + if (path == null) return; + try { + await FavoriteStorage.importFromFile(path); + if (context.mounted) FlutterToastr.show(localizations.importSuccess, context); + setState(() {}); + } catch (e) { + if (context.mounted) FlutterToastr.show('${localizations.importFailed}: $e', context); + } + }), + ]), body: FutureBuilder( future: FavoriteStorage.favorites, builder: (BuildContext context, AsyncSnapshot> snapshot) { @@ -168,7 +219,7 @@ class _FavoriteItemState extends State<_FavoriteItem> { } ///右键菜单 - menu(details) { + void menu(details) { // setState(() { // selected = true; // }); @@ -309,14 +360,14 @@ class _FavoriteItemState extends State<_FavoriteItem> { } //显示高级重发 - showCustomRepeat(HttpRequest request) { + void showCustomRepeat(HttpRequest request) { Navigator.of(context).pop(); Navigator.of(context).push(MaterialPageRoute( builder: (context) => futureWidget(SharedPreferences.getInstance(), (prefs) => MobileCustomRepeat(onRepeat: () => onRepeat(request), prefs: prefs)))); } - onRepeat(HttpRequest request) { + void onRepeat(HttpRequest request) { var httpRequest = request.copy(uri: request.requestUrl); var proxyInfo = widget.proxyServer.isRunning ? ProxyInfo.of("127.0.0.1", widget.proxyServer.port) : null; HttpClients.proxyRequest(httpRequest, proxyInfo: proxyInfo); @@ -327,7 +378,7 @@ class _FavoriteItemState extends State<_FavoriteItem> { } //重命名 - rename(Favorite item) { + void rename(Favorite item) { String? name = item.name; showDialog( context: context, diff --git a/lib/ui/mobile/request/request.dart b/lib/ui/mobile/request/request.dart index fb5d1d8..0a70b72 100644 --- a/lib/ui/mobile/request/request.dart +++ b/lib/ui/mobile/request/request.dart @@ -76,7 +76,7 @@ class RequestRowState extends State { AppLocalizations get localizations => AppLocalizations.of(availableContext)!; - change(HttpResponse response) { + void change(HttpResponse response) { setState(() { this.response = response; }); @@ -89,6 +89,12 @@ class RequestRowState extends State { super.initState(); } + @override + void dispose() { + autoReadRequests.remove(widget.request.requestId); + super.dispose(); + } + Color? color(String url) { if (highlightColor != null) { return highlightColor; diff --git a/lib/ui/mobile/setting/app_filter.dart b/lib/ui/mobile/setting/app_filter.dart index 5313cb7..ec78f57 100644 --- a/lib/ui/mobile/setting/app_filter.dart +++ b/lib/ui/mobile/setting/app_filter.dart @@ -16,12 +16,14 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/native/installed_apps.dart'; import 'package:proxypin/native/vpn.dart'; import 'package:proxypin/network/bin/configuration.dart'; import 'package:proxypin/network/bin/server.dart'; import 'package:proxypin/ui/component/widgets.dart'; +import 'package:proxypin/utils/task.dart'; ///应用白名单 目前只支持安卓 ios没办法获取安装的列表 ///@author wang @@ -317,10 +319,41 @@ class InstalledAppsWidget extends StatefulWidget { } class _InstalledAppsWidgetState extends State { - static Future> apps = InstalledApps.getInstalledApps(true); + static List? apps; + static bool includeSystemApps = false; + + RxBool loading = false.obs; String? keyword; + @override + void initState() { + super.initState(); + DelayedTask().cancel("InstalledAppsWidget_release"); + if (apps != null) { + return; + } + refreshApps(); + } + + @override + void dispose() { + DelayedTask().debounce("InstalledAppsWidget_release", const Duration(seconds: 10), () { + apps = null; + includeSystemApps = false; + }); + super.dispose(); + } + + void refreshApps() async { + try { + loading.value = true; + apps = await InstalledApps.getInstalledApps(true, includeSystemApps: includeSystemApps); + } finally { + loading.value = false; + } + } + @override Widget build(BuildContext context) { bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh'); @@ -331,6 +364,18 @@ class _InstalledAppsWidgetState extends State { decoration: InputDecoration( hintText: isCN ? "请输入应用名或包名" : "Please enter the application or package name", border: InputBorder.none, + hintStyle: TextStyle(color: Colors.grey.shade500), + suffixIcon: IconButton( + color: includeSystemApps ? Theme.of(context).colorScheme.primary : null, + icon: const Icon(Icons.visibility_outlined), + tooltip: isCN ? "显示系统应用" : "Show system apps", + onPressed: () { + setState(() { + includeSystemApps = !includeSystemApps; + }); + refreshApps(); + }, + ), ), onChanged: (String value) { keyword = value.toLowerCase(); @@ -340,45 +385,42 @@ class _InstalledAppsWidgetState extends State { ), body: RefreshIndicator( onRefresh: () async { - apps = InstalledApps.getInstalledApps(true); - await apps; - setState(() {}); + refreshApps(); }, - child: FutureBuilder( - future: apps, - builder: (BuildContext context, AsyncSnapshot> snapshot) { - if (snapshot.hasData) { - List appInfoList = snapshot.data!; - appInfoList = appInfoList.toSet().difference(widget.addedList.toSet()).toList(); - if (keyword != null && keyword!.trim().isNotEmpty) { - appInfoList = appInfoList - .where((element) => - element.name!.toLowerCase().contains(keyword!) || - element.packageName!.toLowerCase().contains(keyword!)) - .toList(); - } - - return ListView.builder( - itemCount: appInfoList.length, - itemBuilder: (BuildContext context, int index) { - AppInfo appInfo = appInfoList[index]; - return ListTile( - leading: Image.memory(appInfo.icon ?? Uint8List(0)), - title: Text(appInfo.name ?? ""), - subtitle: Text(appInfo.packageName ?? ""), - onTap: () async { - Navigator.of(context).pop(appInfo.packageName); - }, - ); - }); - } else { - return const Center( + child: Obx(() => loading.value + ? const Center( child: CircularProgressIndicator(), - ); - } - }, - ), + ) + : buildAppListView()), ), ); } + + ListView buildAppListView() { + if (apps == null) { + return ListView(); + } + List appInfoList = apps!; + appInfoList = appInfoList.toSet().difference(widget.addedList.toSet()).toList(); + if (keyword != null && keyword!.trim().isNotEmpty) { + appInfoList = appInfoList + .where((element) => + element.name!.toLowerCase().contains(keyword!) || element.packageName!.toLowerCase().contains(keyword!)) + .toList(); + } + + return ListView.builder( + itemCount: appInfoList.length, + itemBuilder: (BuildContext context, int index) { + AppInfo appInfo = appInfoList[index]; + return ListTile( + leading: Image.memory(appInfo.icon ?? Uint8List(0)), + title: Text(appInfo.name ?? ""), + subtitle: Text(appInfo.packageName ?? ""), + onTap: () async { + Navigator.of(context).pop(appInfo.packageName); + }, + ); + }); + } } diff --git a/lib/ui/mobile/setting/filter.dart b/lib/ui/mobile/setting/filter.dart index 6346f80..3208094 100644 --- a/lib/ui/mobile/setting/filter.dart +++ b/lib/ui/mobile/setting/filter.dart @@ -247,7 +247,7 @@ class _DomainListState extends State { @override Widget build(BuildContext context) { return Scaffold( - persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + persistentFooterButtons: multiple ? [globalMenu()] : null, body: Container( padding: const EdgeInsets.only(top: 10), decoration: BoxDecoration( diff --git a/lib/ui/mobile/setting/report_servers.dart b/lib/ui/mobile/setting/report_servers.dart index a2e3d50..3ffadcd 100644 --- a/lib/ui/mobile/setting/report_servers.dart +++ b/lib/ui/mobile/setting/report_servers.dart @@ -268,7 +268,6 @@ class _ReportServerEditPageMobileState extends State SizedBox( width: 120, child: DropdownButtonFormField( - // use `value` for compatibility with older Flutter SDKs value: _compression, decoration: dec(), items: [ diff --git a/lib/ui/mobile/setting/request_crypto.dart b/lib/ui/mobile/setting/request_crypto.dart new file mode 100644 index 0000000..f65148b --- /dev/null +++ b/lib/ui/mobile/setting/request_crypto.dart @@ -0,0 +1,779 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:collection'; +import 'dart:math' as math; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_toastr/flutter_toastr.dart'; +import 'package:proxypin/l10n/app_localizations.dart'; +import 'package:proxypin/network/components/manager/request_crypto_manager.dart'; +import 'package:proxypin/network/util/logger.dart'; +import 'package:proxypin/ui/component/utils.dart'; +import 'package:proxypin/ui/component/widgets.dart'; + +bool _refresh = false; + +Future _refreshConfig({bool force = false}) async { + if (force) { + _refresh = false; + await RequestCryptoManager.instance.then((manager) => manager.flushConfig()); + return; + } + + if (_refresh) return; + _refresh = true; + Future.delayed(const Duration(milliseconds: 800), () async { + _refresh = false; + await RequestCryptoManager.instance.then((manager) => manager.flushConfig()); + }); +} + +class MobileRequestCryptoPage extends StatefulWidget { + const MobileRequestCryptoPage({super.key}); + + @override + State createState() => _MobileRequestCryptoPageState(); +} + +class _MobileRequestCryptoPageState extends State { + AppLocalizations get localizations => AppLocalizations.of(context)!; + + bool enabled = false; + bool selectionMode = false; + final Set selected = HashSet(); + bool changed = false; + + @override + Widget build(BuildContext context) { + final l10n = localizations; + return Scaffold( + appBar: AppBar( + title: Text(l10n.requestCrypto, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + toolbarHeight: 36, + centerTitle: true, + ), + persistentFooterButtons: selectionMode ? [_buildSelectionFooter()] : null, + body: Padding( + padding: const EdgeInsets.all(10), + child: futureWidget( + RequestCryptoManager.instance, + loading: true, + (manager) { + enabled = manager.enabled; + + return Column( + children: [ + Row( + children: [ + Text("${l10n.enable} ${l10n.requestCrypto}"), + const SizedBox(width: 8), + SwitchWidget( + value: enabled, + scale: 0.8, + onChanged: (val) { + enabled = val; + manager.enabled = val; + changed = true; + setState(() {}); + _refreshConfig(); + }, + ), + ], + ), + Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton.icon( + icon: const Icon(Icons.add, size: 20), + onPressed: () => _addRule(manager), + label: Text(l10n.add), + ), + const SizedBox(width: 5), + TextButton.icon( + icon: const Icon(Icons.input_rounded, size: 20), + onPressed: () => _import(manager), + label: Text(l10n.import), + ), + ]), + const SizedBox(height: 10), + Expanded(child: _buildRuleList(manager)), + ], + ); + }, + ), + ), + ); + } + + Widget _buildRuleList(RequestCryptoManager manager) { + final l10n = localizations; + final primaryColor = Theme.of(context).colorScheme.primary; + final rules = manager.rules; + + return Scaffold( + body: Container( + padding: const EdgeInsets.only(top: 10, bottom: 30), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), + child: rules.isEmpty + ? const Center(child: Text('-')) + : Scrollbar( + child: ListView(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container(width: 70, padding: const EdgeInsets.only(left: 10), child: Text(l10n.name)), + SizedBox(width: 46, child: Text(l10n.enable, textAlign: TextAlign.center)), + const VerticalDivider(), + const Expanded(child: Text('URL')), + ], + ), + const Divider(thickness: 0.5), + Column( + children: List.generate(rules.length, (index) { + final rule = rules[index]; + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + hoverColor: primaryColor.withOpacity(0.3), + onLongPress: () => _showRuleActions(manager, index), + onTap: () { + if (selectionMode) { + setState(() { + if (!selected.add(index)) { + selected.remove(index); + } + }); + return; + } + _editRule(manager, index); + }, + child: Container( + color: selected.contains(index) + ? primaryColor.withOpacity(0.8) + : index.isEven + ? Colors.grey.withOpacity(0.1) + : null, + height: 45, + padding: const EdgeInsets.all(5), + child: Row(children: [ + SizedBox( + width: 70, + child: Text(rule.name.isEmpty ? '-' : rule.name, + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13))), + SizedBox( + width: 35, + child: SwitchWidget( + scale: 0.65, + value: rule.enabled, + onChanged: (val) { + rule.enabled = val; + changed = true; + setState(() {}); + _refreshConfig(); + })), + const SizedBox(width: 20), + Expanded( + child: Text(rule.urlPattern.isEmpty ? l10n.emptyMatchAll : rule.urlPattern, + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13))), + ]))); + })) + ])), + ), + ); + } + + Stack _buildSelectionFooter() { + final l10n = localizations; + return Stack(children: [ + Container( + height: 50, + width: double.infinity, + margin: const EdgeInsets.only(top: 10), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2)))), + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: () {}, + child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + TextButton.icon( + onPressed: selected.isEmpty + ? null + : () async { + // export selected only + final m = await RequestCryptoManager.instance; + await _export(m, indexes: selected.toList()); + setState(() { + selected.clear(); + selectionMode = false; + }); + }, + icon: const Icon(Icons.share, size: 18), + label: Text(l10n.export, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: selected.isEmpty ? null : () => _removeSelected(), + icon: const Icon(Icons.delete, size: 18), + label: Text(l10n.delete, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: () { + setState(() { + selectionMode = false; + selected.clear(); + }); + }, + icon: const Icon(Icons.cancel, size: 18), + label: Text(l10n.cancel, style: const TextStyle(fontSize: 14))), + ])))) + ]); + } + + Future _addRule(RequestCryptoManager manager) async { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MobileCryptoRuleEditPage())).then((value) { + if (value != null && mounted) { + setState(() {}); + _refreshConfig(force: true); + } + }); + } + + Future _editRule(RequestCryptoManager manager, int index) async { + final rule = manager.rules[index]; + Navigator.of(context).push(MaterialPageRoute(builder: (_) => MobileCryptoRuleEditPage(rule: rule))).then((value) { + if (value != null && mounted) { + setState(() {}); + _refreshConfig(force: true); + } + }); + } + + void _showRuleActions(RequestCryptoManager manager, int index) { + final l10n = localizations; + setState(() { + selected.add(index); + }); + showModalBottomSheet( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + context: context, + enableDrag: true, + builder: (ctx) { + return Wrap(children: [ + BottomSheetItem( + text: l10n.multiple, + onPressed: () { + setState(() => selectionMode = true); + }), + const Divider(thickness: 0.5, height: 5), + ListTile( + leading: const Icon(Icons.edit_outlined), + title: Text(l10n.edit), + onTap: () { + Navigator.pop(ctx); + _editRule(manager, index); + }), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem(text: l10n.export, onPressed: () => _export(manager, indexes: [index])), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem( + text: manager.rules[index].enabled ? l10n.disabled : l10n.enable, + onPressed: () { + manager.rules[index].enabled = !manager.rules[index].enabled; + changed = true; + setState(() {}); + _refreshConfig(); + }), + const Divider(thickness: 0.5, height: 5), + BottomSheetItem( + text: l10n.delete, + onPressed: () { + Navigator.pop(ctx); + _removeRule(manager, index); + }), + Container(color: Theme.of(ctx).hoverColor, height: 8), + TextButton( + child: Container( + height: 45, + width: double.infinity, + padding: const EdgeInsets.only(top: 10), + child: Text(l10n.cancel, textAlign: TextAlign.center)), + onPressed: () { + Navigator.of(ctx).pop(); + }), + ]); + }).then((value) { + if (selectionMode) { + return; + } + setState(() { + selected.remove(index); + }); + }); + } + + Future _removeRule(RequestCryptoManager manager, int index) async { + await manager.removeRule(index); + if (!mounted) return; + changed = true; + setState(() {}); + _refreshConfig(force: true); + } + + Future _removeSelected() async { + final l10n = localizations; + if (selected.isEmpty) return; + showConfirmDialog(context, content: l10n.confirmContent, onConfirm: () async { + final manager = await RequestCryptoManager.instance; + final indexes = selected.toList()..sort((a, b) => b.compareTo(a)); + for (final idx in indexes) { + await manager.removeRule(idx); + } + if (!mounted) return; + changed = true; + setState(() { + selectionMode = false; + selected.clear(); + }); + _refreshConfig(force: true); + if (mounted) FlutterToastr.show(l10n.deleteSuccess, context); + }); + } + + Future _import(RequestCryptoManager manager) async { + try { + FilePickerResult? result = + await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['json']); + final path = result?.files.single.path; + if (path == null) return; + final content = await File(path).readAsString(); + final List list = jsonDecode(content); + for (final item in list) { + await manager.addRule(CryptoRule.fromJson(Map.from(item))); + } + if (!mounted) return; + changed = true; + setState(() {}); + _refreshConfig(force: true); + FlutterToastr.show(localizations.importSuccess, context); + } catch (e) { + logger.e('导入失败', error: e); + if (mounted) FlutterToastr.show('${localizations.importFailed} $e', context); + } + } + + Future _export(RequestCryptoManager manager, {List? indexes}) async { + try { + if (manager.rules.isEmpty) return; + final keys = (indexes == null || indexes.isEmpty) + ? List.generate(manager.rules.length, (i) => i) + : (indexes.toList()..sort()); + final data = keys.map((i) => manager.rules[i].toJson()).toList(); + final path = await FilePicker.platform.saveFile(fileName: 'request_crypto.json'); + if (path == null) return; + await File(path).writeAsString(jsonEncode(data)); + if (mounted) FlutterToastr.show(localizations.exportSuccess, context); + } catch (e) { + logger.e('导出失败', error: e); + if (mounted) FlutterToastr.show('Export failed: $e', context); + } + } +} + +/// Mobile editor page for a single crypto rule. +/// +/// This mirrors the mobile rewrite editor pattern: push to a page, edit, and save. +class MobileCryptoRuleEditPage extends StatefulWidget { + final CryptoRule? rule; + + const MobileCryptoRuleEditPage({super.key, this.rule}); + + @override + State createState() => _MobileCryptoRuleEditPageState(); +} + +class _MobileCryptoRuleEditPageState extends State { + AppLocalizations get l10n => AppLocalizations.of(context)!; + + final GlobalKey _formKey = GlobalKey(); + + late CryptoRule _rule; + + late TextEditingController nameController; + late TextEditingController patternController; + late TextEditingController fieldController; + + // key + iv + late TextEditingController keyController; + late TextEditingController ivController; + + bool enabled = true; + String mode = 'CBC'; + String padding = 'PKCS7'; + int length = 256; + + // formats & sources + String keyFormat = 'text'; // text | base64 + String ivSource = 'manual'; // manual | prefix + int ivPrefixLength = 16; + + @override + void initState() { + super.initState(); + + _rule = (widget.rule ?? CryptoRule.newRule()); + + nameController = TextEditingController(text: _rule.name); + patternController = TextEditingController(text: _rule.urlPattern); + fieldController = TextEditingController(text: _rule.field ?? ''); + + enabled = _rule.enabled; + mode = _rule.config.mode; + padding = _rule.config.padding; + length = _rule.config.keyLength; + + // key format handling (only text/base64) + final storedKey = _rule.config.key.trim(); + if (storedKey.startsWith('base64:')) { + keyFormat = 'base64'; + keyController = TextEditingController(text: storedKey.substring(7)); + } else { + keyFormat = 'text'; + keyController = TextEditingController(text: storedKey); + } + + // iv source and value + ivSource = _rule.config.ivSource; + ivPrefixLength = _rule.config.ivPrefixLength; + + final storedIv = _rule.config.iv.trim(); + if (storedIv.startsWith('base64:')) { + ivController = TextEditingController(text: storedIv.substring(7)); + } else { + ivController = TextEditingController(text: storedIv); + } + } + + @override + void dispose() { + nameController.dispose(); + patternController.dispose(); + fieldController.dispose(); + keyController.dispose(); + ivController.dispose(); + super.dispose(); + } + + InputDecoration _decorate(String label, {String? hint}) { + return InputDecoration( + labelText: label, + hintText: hint, + hintStyle: TextStyle(color: Colors.grey.withOpacity(0.8)), + isDense: true, + border: const OutlineInputBorder(), + ); + } + + @override + Widget build(BuildContext context) { + final isCN = Localizations.localeOf(context).languageCode == 'zh'; + + return Scaffold( + appBar: AppBar( + title: Text(widget.rule == null ? l10n.newBuilt : l10n.edit, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + actions: [ + TextButton( + onPressed: _save, + child: Text(l10n.save), + ), + const SizedBox(width: 6), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(12), + children: [ + Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withAlpha((0.5 * 255).round()), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withAlpha((0.2 * 255).round())), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.match, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 10), + TextFormField( + controller: nameController, + decoration: _decorate(l10n.name), + ), + const SizedBox(height: 10), + TextFormField( + controller: patternController, + decoration: _decorate('URL', hint: 'https://www.example.com/api/*'), + validator: (val) => (val == null || val.trim().isEmpty) ? l10n.cannotBeEmpty : null, + ), + const SizedBox(height: 10), + TextFormField( + controller: fieldController, + decoration: _decorate(l10n.cryptoRuleField, hint: isCN ? '为空=整个 body' : 'empty = whole body'), + ), + const SizedBox(height: 6), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(l10n.enable), + value: enabled, + onChanged: (v) => setState(() => enabled = v), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withAlpha((0.5 * 255).round()), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withAlpha((0.2 * 255).round())), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('AES', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 10), + Wrap( + spacing: 12, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _kvDropdown( + label: 'Mode', + child: DropdownButton( + value: mode, + items: const [ + DropdownMenuItem(value: 'ECB', child: Text('ECB')), + DropdownMenuItem(value: 'CBC', child: Text('CBC')), + ], + onChanged: (v) => setState(() => mode = v ?? 'CBC'), + ), + ), + _kvDropdown( + label: 'Padding', + child: DropdownButton( + value: padding, + items: const [ + DropdownMenuItem(value: 'PKCS7', child: Text('PKCS7')), + DropdownMenuItem(value: 'ZeroPadding', child: Text('ZeroPadding')), + ], + onChanged: (v) => setState(() => padding = v ?? 'PKCS7'), + ), + ), + _kvDropdown( + label: 'Key Length', + child: DropdownButton( + value: length, + items: const [ + DropdownMenuItem(value: 128, child: Text('128')), + DropdownMenuItem(value: 192, child: Text('192')), + DropdownMenuItem(value: 256, child: Text('256')), + ], + onChanged: (v) => setState(() => length = v ?? 256), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + _chipDropdown( + value: keyFormat, + items: const [ + DropdownMenuItem(value: 'text', child: Text('text')), + DropdownMenuItem(value: 'base64', child: Text('base64')), + ], + onChanged: (v) => setState(() => keyFormat = v ?? 'text'), + ), + const SizedBox(width: 10), + Expanded( + child: TextFormField( + controller: keyController, + decoration: _decorate('Key'), + validator: (val) => (val == null || val.trim().isEmpty) ? l10n.cannotBeEmpty : null, + ), + ), + ], + ), + const SizedBox(height: 10), + if (mode == 'CBC') ...[ + Row( + children: [ + _chipDropdown( + value: ivSource, + items: [ + DropdownMenuItem(value: 'manual', child: Text(l10n.manual)), + DropdownMenuItem(value: 'prefix', child: Text(l10n.cryptoIvPrefixLabel)), + ], + onChanged: (v) => setState(() => ivSource = v ?? 'manual'), + ), + const SizedBox(width: 10), + Expanded( + child: ivSource == 'manual' + ? TextFormField( + controller: ivController, + decoration: _decorate('IV'), + validator: (val) => (ivSource == 'manual' && (val == null || val.trim().isEmpty)) + ? l10n.cannotBeEmpty + : null, + ) + : _ivPrefixLengthEditor(), + ), + ], + ), + if (ivSource == 'prefix') + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + l10n.cryptoIvPrefixTooltip, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } + + Widget _kvDropdown({required String label, required Widget child}) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 8), + Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(8), + ), + child: DropdownButtonHideUnderline(child: child), + ), + ], + ); + } + + Widget _chipDropdown({ + required T value, + required List> items, + required ValueChanged onChanged, + }) { + return Container( + height: 40, + constraints: const BoxConstraints(minWidth: 95), + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(6)), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + items: items, + onChanged: onChanged, + ), + ), + ); + } + + Widget _ivPrefixLengthEditor() { + return Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + icon: const Icon(Icons.remove, size: 16), + onPressed: () => setState(() => ivPrefixLength = math.max(1, ivPrefixLength - 1)), + ), + Text(ivPrefixLength.toString()), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + icon: const Icon(Icons.add, size: 16), + onPressed: () => setState(() => ivPrefixLength = math.min(1024, ivPrefixLength + 1)), + ), + ], + ), + ); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) { + FlutterToastr.show(l10n.cannotBeEmpty, context, position: FlutterToastr.center); + return; + } + + var outKey = keyController.text.trim(); + if (!outKey.startsWith('base64:') && keyFormat == 'base64') { + outKey = 'base64:$outKey'; + } + + String outIv = ''; + if (ivSource == 'manual') { + outIv = ivController.text.trim(); + if (!outIv.startsWith('base64:') && keyFormat == 'base64') { + outIv = 'base64:$outIv'; + } + } + + final updated = _rule.copyWith( + name: nameController.text.trim(), + urlPattern: patternController.text.trim(), + field: fieldController.text.trim(), + enabled: enabled, + config: CryptoKeyConfig( + key: outKey, + iv: outIv, + ivSource: ivSource, + ivPrefixLength: ivPrefixLength, + mode: mode, + padding: padding, + keyLength: length, + ), + ); + + final manager = await RequestCryptoManager.instance; + final idx = manager.rules.indexOf(_rule); + + if (idx >= 0) { + await manager.updateRule(idx, updated); + } else { + await manager.addRule(updated); + } + await manager.flushConfig(); + + if (!mounted) return; + FlutterToastr.show(l10n.saveSuccess, context); + Navigator.of(context).pop(updated); + } +} diff --git a/lib/ui/mobile/setting/request_map.dart b/lib/ui/mobile/setting/request_map.dart index 6e171c5..63ca10b 100644 --- a/lib/ui/mobile/setting/request_map.dart +++ b/lib/ui/mobile/setting/request_map.dart @@ -162,10 +162,12 @@ class _RequestMapListState extends State { @override Widget build(BuildContext context) { return Scaffold( - persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + persistentFooterButtons: multiple ? [globalMenu()] : null, body: Container( padding: const EdgeInsets.only(top: 10), - decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withOpacity(0.2)), + ), child: Scrollbar( child: ListView(children: [ Row( diff --git a/lib/ui/mobile/setting/request_rewrite.dart b/lib/ui/mobile/setting/request_rewrite.dart index 49f0412..385f1ee 100644 --- a/lib/ui/mobile/setting/request_rewrite.dart +++ b/lib/ui/mobile/setting/request_rewrite.dart @@ -46,24 +46,11 @@ class MobileRequestRewrite extends StatefulWidget { } class _MobileRequestRewriteState extends State { - bool enabled = false; - AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { super.initState(); - enabled = widget.requestRewrites.enabled; - } - - @override - void dispose() { - if (enabled != widget.requestRewrites.enabled) { - widget.requestRewrites.enabled = enabled; - widget.requestRewrites.flushRequestRewriteConfig(); - } - - super.dispose(); } @override @@ -78,7 +65,13 @@ class _MobileRequestRewriteState extends State { Row( children: [ Text(localizations.requestRewriteEnable), - SwitchWidget(value: enabled, scale: 0.8, onChanged: (val) => enabled = val), + SwitchWidget( + value: widget.requestRewrites.enabled, + scale: 0.8, + onChanged: (val) { + widget.requestRewrites.enabled = val; + widget.requestRewrites.flushRequestRewriteConfig(); + }), ], ), Row(mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -172,7 +165,7 @@ class _RequestRuleListState extends State { @override Widget build(BuildContext context) { return Scaffold( - persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + persistentFooterButtons: multiple ? [globalMenu()] : null, body: Container( padding: const EdgeInsets.only(top: 10, bottom: 30), decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), @@ -568,6 +561,7 @@ class _RewriteRuleState extends State { height: 50, child: DropdownButtonFormField( onSaved: (val) => rule.type = val!, + value: ruleType, decoration: const InputDecoration( border: OutlineInputBorder(), errorStyle: TextStyle(height: 0, fontSize: 0), diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart index 148697d..f65fb79 100644 --- a/lib/ui/mobile/setting/script.dart +++ b/lib/ui/mobile/setting/script.dart @@ -20,6 +20,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:http/http.dart' as http; +import 'package:get/get.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:flutter_highlight/themes/monokai-sublime.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; @@ -170,6 +172,8 @@ class ScriptConsoleLog extends StatefulWidget { } class _ScriptConsoleLogState extends State { + int channelId = "ScriptConsoleLog".hashCode; + static final List logs = []; static FloatingWindowManager floatingWindowManager = FloatingWindowManager(); @@ -181,22 +185,19 @@ class _ScriptConsoleLogState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((d) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } }); - if (floatingWindowManager.isShow) { - return; - } - LogHandler logHandler = LogHandler( - channelId: hashCode, + channelId: channelId, handle: (log) { logs.add(log); if (!mounted && !floatingWindowManager.isShow) { logs.clear(); - //关闭日志监听 - ScriptManager.removeLogHandler(hashCode); + ScriptManager.removeLogHandler(channelId); return; } @@ -213,7 +214,7 @@ class _ScriptConsoleLogState extends State { super.dispose(); if (!floatingWindowManager.isShow) { logs.clear(); - ScriptManager.removeLogHandler(hashCode); + ScriptManager.removeLogHandler(channelId); } _scrollController.dispose(); } @@ -315,6 +316,7 @@ class _ScriptLogSmallWindowState extends State { @override void dispose() { _scrollController.dispose(); + logger.d("dispose small window log handler $hashCode"); ScriptManager.removeLogHandler(hashCode); super.dispose(); } @@ -358,6 +360,9 @@ class _ScriptLogSmallWindowState extends State { return Padding( padding: const EdgeInsets.only(bottom: 5, top: 18), child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + thickness: 2, child: ListView.builder( controller: _scrollController, itemCount: logs.length, @@ -377,10 +382,20 @@ class _ScriptLogSmallWindowState extends State { class ScriptEdit extends StatefulWidget { final ScriptItem? scriptItem; final String? script; + final String? url; final List? urls; final String? title; + final bool fromRemoteUrl; - const ScriptEdit({super.key, this.scriptItem, this.script, this.urls, this.title}); + const ScriptEdit({ + super.key, + this.scriptItem, + this.script, + this.url, + this.urls, + this.title, + this.fromRemoteUrl = false, + }); @override State createState() => _ScriptEditState(); @@ -390,18 +405,25 @@ class _ScriptEditState extends State { late CodeController script; late TextEditingController nameController; late List urlControllers; + late TextEditingController remoteUrlController; + late bool _useRemote; + final RxBool _fetchingRemoteScript = false.obs; AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { super.initState(); - final urls = - widget.scriptItem?.urls ?? (widget.urls != null && widget.urls!.isNotEmpty ? widget.urls! : []); + final urls = widget.scriptItem?.urls ?? + (widget.urls != null && widget.urls!.isNotEmpty + ? widget.urls! + : (widget.url != null && widget.url!.isNotEmpty ? [widget.url!] : [])); urlControllers = urls.isNotEmpty ? urls.map((u) => TextEditingController(text: u)).toList() : [TextEditingController()]; script = CodeController(language: javascript, text: widget.script ?? ScriptManager.template); - nameController = TextEditingController(text: widget.scriptItem?.name ?? widget.title); + nameController = TextEditingController(text: widget.scriptItem?.name ?? widget.title ?? ''); + remoteUrlController = TextEditingController(text: widget.scriptItem?.remoteUrl ?? ''); + _useRemote = widget.fromRemoteUrl || ((widget.scriptItem?.remoteUrl ?? '').trim().isNotEmpty); } @override @@ -411,9 +433,51 @@ class _ScriptEditState extends State { } script.dispose(); nameController.dispose(); + remoteUrlController.dispose(); + _fetchingRemoteScript.close(); super.dispose(); } + Future _fetchRemoteScript() async { + if (_fetchingRemoteScript.value) return; + final remoteUrl = remoteUrlController.text.trim(); + if (remoteUrl.isEmpty) { + FlutterToastr.show("${localizations.remoteUrl} ${localizations.cannotBeEmpty}", context, + position: FlutterToastr.top); + return; + } + + final uri = Uri.tryParse(remoteUrl); + if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https')) { + FlutterToastr.show("${localizations.remoteUrl} ${localizations.fail}", context, position: FlutterToastr.top); + return; + } + + try { + _fetchingRemoteScript.value = true; + final resp = await http.get(uri); + if (resp.statusCode < 200 || resp.statusCode >= 300) { + FlutterToastr.show("Fetch failed: HTTP ${resp.statusCode}", context, position: FlutterToastr.top); + return; + } + final content = utf8.decode(resp.bodyBytes); + script.text = content; + if (mounted) { + setState(() {}); + } + } catch (e) { + if (mounted) { + FlutterToastr.show("Fetch failed: $e", context, position: FlutterToastr.top); + } + } finally { + _fetchingRemoteScript.value = false; + } + } + + void _resetScript() { + script.text = ScriptManager.template; + } + @override Widget build(BuildContext context) { GlobalKey formKey = GlobalKey(); @@ -448,14 +512,26 @@ class _ScriptEditState extends State { FlutterToastr.show("URL ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top); return; } + + // Only persist remoteUrl when remote mode is enabled. + final remoteUrl = _useRemote ? remoteUrlController.text.trim() : ''; + final hasRemote = remoteUrl.isNotEmpty; + if (_useRemote && !hasRemote) { + FlutterToastr.show("Remote URL ${localizations.cannotBeEmpty}", context, + position: FlutterToastr.top); + return; + } + var scriptManager = await ScriptManager.instance; if (widget.scriptItem == null) { var scriptItem = ScriptItem(true, nameController.text, urls); + scriptItem.remoteUrl = _useRemote ? remoteUrl : null; await scriptManager.addScript(scriptItem, script.text); } else { widget.scriptItem?.name = nameController.text; widget.scriptItem?.urls = urls; widget.scriptItem?.urlRegs = null; + widget.scriptItem?.remoteUrl = _useRemote ? remoteUrl : null; await scriptManager.updateScript(widget.scriptItem!, script.text); } @@ -474,23 +550,24 @@ class _ScriptEditState extends State { children: [ // Name section Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), elevation: 0, shape: RoundedRectangleBorder( side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), borderRadius: BorderRadius.circular(8)), child: Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: textField("${localizations.name}:", nameController, localizations.pleaseEnter))), - const SizedBox(height: 10), // URLs section Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), elevation: 0, shape: RoundedRectangleBorder( side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), borderRadius: BorderRadius.circular(8)), child: Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 10), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ const Text("URL(s):"), @@ -535,16 +612,103 @@ class _ScriptEditState extends State { }), ]))) ]))), - const SizedBox(height: 10), - // Script section + // Source section Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), elevation: 0, shape: RoundedRectangleBorder( side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), borderRadius: BorderRadius.circular(8)), child: Padding( - padding: const EdgeInsets.all(10), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row(children: [ + SizedBox(width: 55, child: Text('${localizations.type}:')), + Expanded( + child: DropdownButtonFormField( + initialValue: _useRemote, + items: [ + DropdownMenuItem(value: false, child: Text(localizations.local)), + DropdownMenuItem(value: true, child: Text(localizations.remoteUrl)), + ], + onChanged: (val) { + if (val == null) return; + setState(() { + _useRemote = val; + }); + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(10), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder(), + ), + )) + ]))), + + // Remote URL section + if (_useRemote) + Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), + borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row(children: [ + SizedBox(width: 65, child: Text('${localizations.remoteUrl}:')), + Expanded( + child: SizedBox( + height: 34, + child: TextFormField( + controller: remoteUrlController, + keyboardType: TextInputType.url, + decoration: InputDecoration( + hintText: 'https://example.com/script.js', + hintStyle: const TextStyle(fontSize: 14, color: Colors.grey), + contentPadding: const EdgeInsets.all(10), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder(), + ), + onFieldSubmitted: (_) => _fetchRemoteScript(), + ), + ), + ), + const SizedBox(width: 3), + Obx(() { + // Keep the button visually aligned with the text field by fixing the height + // and using a compact FilledButton (with icon when idle and spinner when fetching). + return SizedBox( + height: 34, + child: Tooltip( + message: localizations.view, + child: FilledButton.tonal( + onPressed: _fetchRemoteScript, + style: FilledButton.styleFrom( + minimumSize: const Size(44, 34), + padding: const EdgeInsets.symmetric(horizontal: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6))), + child: _fetchingRemoteScript.value + ? const SizedBox( + width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.cloud_download, size: 18), + ), + ), + ); + }), + ]))), + + // Script section + Card( + color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.4)), + borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Text("${localizations.script}:", style: const TextStyle(fontWeight: FontWeight.w500)), @@ -558,27 +722,10 @@ class _ScriptEditState extends State { FlutterToastr.show(localizations.copied, context, position: FlutterToastr.top); })), Tooltip( - message: 'Paste', + message: 'Reset', child: IconButton( - icon: const Icon(Icons.content_paste_go_outlined, size: 20), - onPressed: () async { - final data = await Clipboard.getData('text/plain'); - final paste = data?.text; - if (paste == null || paste.isEmpty) return; - final sel = script.selection; - if (sel.isValid) { - final text = script.text; - final start = sel.start; - final end = sel.end; - final newText = text.replaceRange(start, end, paste); - script.value = script.value.copyWith( - text: newText, - selection: TextSelection.collapsed(offset: start + paste.length)); - } else { - script.text += paste; - } - setState(() {}); - })), + icon: const Icon(Icons.settings_backup_restore, size: 22), + onPressed: _resetScript)), Tooltip( message: localizations.clear, child: IconButton( @@ -588,7 +735,6 @@ class _ScriptEditState extends State { setState(() {}); })) ]), - const SizedBox(height: 6), CodeTheme( data: CodeThemeData(styles: monokaiSublimeTheme), child: ClipRRect( @@ -599,19 +745,20 @@ class _ScriptEditState extends State { border: Border.all(color: Colors.grey.withOpacity(0.2))), child: SingleChildScrollView( child: CodeField( - textStyle: const TextStyle(fontSize: 13, color: Colors.white), - enableSuggestions: true, - gutterStyle: const GutterStyle(width: 50, margin: 0), - onTapOutside: (event) => FocusScope.of(context).unfocus(), - controller: script))))), - ]))) + readOnly: _useRemote, + enableSuggestions: true, + textStyle: const TextStyle(fontSize: 13, color: Colors.white), + controller: script, + gutterStyle: const GutterStyle(width: 50, margin: 0), + ))))), + ]))), ], ))); } Widget textField(String label, TextEditingController controller, String hint, {TextInputType? keyboardType}) { return Row(children: [ - SizedBox(width: 50, child: Text(label)), + SizedBox(width: 65, child: Text(label)), Expanded( child: TextFormField( controller: controller, @@ -620,6 +767,7 @@ class _ScriptEditState extends State { decoration: InputDecoration( hintText: hint, contentPadding: const EdgeInsets.all(10), + hintStyle: const TextStyle(fontSize: 14, color: Colors.grey), errorStyle: const TextStyle(height: 0, fontSize: 0), focusedBorder: focusedBorder(), isDense: true, @@ -652,7 +800,7 @@ class _ScriptListState extends State { @override Widget build(BuildContext context) { return Scaffold( - persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + persistentFooterButtons: multiple ? [globalMenu()] : null, body: Container( padding: const EdgeInsets.only(top: 10, bottom: 30), decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), @@ -718,6 +866,9 @@ class _ScriptListState extends State { var primaryColor = Theme.of(context).colorScheme.primary; return List.generate(list.length, (index) { + final item = list[index]; + final isRemote = item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty; + return InkWell( splashColor: primaryColor.withOpacity(0.3), onTap: () async { @@ -744,8 +895,13 @@ class _ScriptListState extends State { children: [ SizedBox( width: 100, - child: Text(list[index].name!, - style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis)), + child: Row(children: [ + Expanded(child: Text(list[index].name ?? '', style: const TextStyle(fontSize: 13))), + if (isRemote) + const Padding( + padding: EdgeInsets.only(left: 6), + child: Text('R', style: TextStyle(fontSize: 11, color: Colors.blue))), + ])), SizedBox( width: 50, child: Transform.scale( @@ -825,11 +981,19 @@ class _ScriptListState extends State { }); } - showEdit([int? index]) async { - String? script = index == null ? null : await (await ScriptManager.instance).getScript(widget.scripts[index]); + Future showEdit([int? index]) async { + String? script; + if (index != null) { + var scriptManager = await ScriptManager.instance; + var scriptItem = widget.scripts[index]; + if (scriptItem.remoteUrl == null || scriptItem.remoteUrl?.isEmpty == true) { + script = await scriptManager.getScript(scriptItem); + } + } if (!mounted) { return; } + Navigator.of(context) .push(MaterialPageRoute( builder: (context) => ScriptEdit(scriptItem: index == null ? null : widget.scripts[index], script: script))) @@ -841,7 +1005,7 @@ class _ScriptListState extends State { } //导出js - export(BuildContext context, List indexes) async { + Future export(BuildContext context, List indexes) async { if (indexes.isEmpty) return; //文件名称 String fileName = 'proxypin-scripts.json'; @@ -851,7 +1015,9 @@ class _ScriptListState extends State { var item = widget.scripts[idx]; var map = item.toJson(); map.remove("scriptPath"); - map['script'] = await scriptManager.getScript(item); + if (item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty) { + map['script'] = await scriptManager.getScript(item); + } json.add(map); } diff --git a/lib/ui/mobile/setting/ssl.dart b/lib/ui/mobile/setting/ssl.dart index 97e9002..35cb4db 100644 --- a/lib/ui/mobile/setting/ssl.dart +++ b/lib/ui/mobile/setting/ssl.dart @@ -36,16 +36,14 @@ import 'package:url_launcher/url_launcher.dart'; class MobileSslWidget extends StatefulWidget { final ProxyServer proxyServer; - final Function(bool val)? onEnableChange; - const MobileSslWidget({super.key, required this.proxyServer, this.onEnableChange}); + const MobileSslWidget({super.key, required this.proxyServer}); @override State createState() => _MobileSslState(); } class _MobileSslState extends State { - bool changed = false; // iOS CA status bool _loading = false; @@ -84,9 +82,6 @@ class _MobileSslState extends State { @override void dispose() { - if (changed) { - widget.proxyServer.configuration.flushConfig(); - } super.dispose(); } @@ -121,10 +116,9 @@ class _MobileSslState extends State { value: widget.proxyServer.enableSsl, onChanged: (val) { widget.proxyServer.enableSsl = val; - widget.onEnableChange?.call(val); CertificateManager.cleanCache(); setState(() { - changed = true; + widget.proxyServer.configuration.flushConfig(); }); }), Divider(height: 0, thickness: 0.3, color: dividerColor), diff --git a/lib/ui/mobile/widgets/about.dart b/lib/ui/mobile/widgets/about.dart index ab97709..60876a0 100644 --- a/lib/ui/mobile/widgets/about.dart +++ b/lib/ui/mobile/widgets/about.dart @@ -52,7 +52,7 @@ class _AboutState extends State { padding: const EdgeInsets.symmetric(horizontal: 10), child: Text(localizations.proxyPinSoftware, textAlign: TextAlign.center))), const SizedBox(height: 8), - Center(child: Text("${localizations.version} ${AppConfiguration.version}")), + Center(child: Text("Version ${AppConfiguration.version}")), const SizedBox(height: 12), Card( color: Colors.transparent, @@ -94,7 +94,26 @@ class _AboutState extends State { final url = "$gitHub/releases"; _safeLaunch(Uri.parse(url)); }), - Divider(height: 0, thickness: 0.4, color: Theme.of(context).dividerColor.withOpacity(0.22)), + Divider(height: 0, thickness: 0.4, color: Theme.of(context).dividerColor.withValues(alpha: 0.22)), + ListTile( + title: Text(localizations.privacyPolicy), + trailing: const Icon(Icons.privacy_tip_outlined, size: 22), + onTap: () { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(localizations.privacyPolicy), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 385), + child: Text(localizations.privacyContent, style: const TextStyle(height: 1.35)))), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(localizations.close)) + ], + ), + ); + }), + Divider(height: 0, thickness: 0.4, color: Theme.of(context).dividerColor.withValues(alpha: 0.22)), // Sponsor / Donate entry ListTile( title: Text(localizations.sponsorDonate), diff --git a/lib/ui/toolbox/aes_page.dart b/lib/ui/toolbox/aes_page.dart index 290d231..a0a36b8 100644 --- a/lib/ui/toolbox/aes_page.dart +++ b/lib/ui/toolbox/aes_page.dart @@ -149,7 +149,7 @@ class _AesWidgetState extends State { height: 45, child: TextField( controller: keyController, - maxLength: 32, + maxLength: 64, onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), style: TextStyle(fontSize: 14), decoration: InputDecoration( diff --git a/lib/utils/aes.dart b/lib/utils/aes.dart index dbe5fe8..f5286ba 100644 --- a/lib/utils/aes.dart +++ b/lib/utils/aes.dart @@ -6,42 +6,143 @@ import 'package:pointycastle/export.dart'; class AesUtils { static Uint8List encrypt(Uint8List input, {required String key, required int keyLength, required String mode, required String padding, String? iv}) { - return process(input, true, key: key, keyLength: keyLength, mode: mode, padding: padding, iv: iv); + return _process(input, true, + key: key, keyLength: keyLength, mode: mode, padding: padding, iv: iv); } static Uint8List decrypt(Uint8List input, {required String key, required int keyLength, required String mode, required String padding, String? iv}) { - var data = process(input, false, key: key, keyLength: keyLength, mode: mode, padding: padding, iv: iv); - // 移除填充零字节 + var data = _process(input, false, + key: key, keyLength: keyLength, mode: mode, padding: padding, iv: iv); + // 移除填充零字节(仅 ZeroPadding 场景) if (padding == 'ZeroPadding') { int lastNonZeroIndex = data.lastIndexWhere((byte) => byte != 0); + if (lastNonZeroIndex < 0) return Uint8List(0); data = data.sublist(0, lastNonZeroIndex + 1); } return data; } - static Uint8List process(Uint8List input, bool isEncrypt, + // Refactored process method (renamed to _process and split into helpers) + static Uint8List _process(Uint8List input, bool isEncrypt, {required String key, required int keyLength, required String mode, required String padding, String? iv}) { - int keySize = keyLength ~/ 8; + final int keySize = keyLength ~/ 8; - final aesKey = Uint8List.fromList(utf8.encode(key.padRight(keySize, '0'))); - final aesIv = mode == 'CBC' ? Uint8List.fromList(utf8.encode(iv!.padRight(keySize, '0'))) : null; + // Build key bytes: support 'base64:' prefix or plain text + final keyBytes = _buildKeyBytes(key, keySize); - BlockCipher cipher = BlockCipher(mode == 'CBC' ? 'AES/CBC' : 'AES/ECB'); - CipherParameters params = - aesIv == null ? KeyParameter(aesKey) : ParametersWithIV(KeyParameter(aesKey), aesIv); + // If CBC mode, prepare IV bytes + Uint8List? ivBytes; + if (mode == 'CBC') { + if (iv == null) { + throw ArgumentError.value(iv, 'iv', 'IV is required for CBC mode'); + } + ivBytes = _buildIvBytes(iv); + // Ensure IV is block-size (16) length + final blockSize = 16; + if (ivBytes.length < blockSize) { + final tmp = Uint8List(blockSize); + tmp.setRange(0, ivBytes.length, ivBytes); + ivBytes = tmp; + } else if (ivBytes.length > blockSize) { + ivBytes = ivBytes.sublist(0, blockSize); + } + } + final aesEngine = AESEngine(); + + // When encrypting with ZeroPadding, pad input to block size + if (isEncrypt && padding == 'ZeroPadding') { + input = _padZeroForEncrypt(input, aesEngine.blockSize); + } + + // PKCS7 path if (padding == 'PKCS7') { - cipher = PaddedBlockCipherImpl(PKCS7Padding(), cipher); - params = PaddedBlockCipherParameters(params, null); + return _processWithPaddedCipher(input, isEncrypt, mode, keyBytes, ivBytes, aesEngine); } - // 检查输入长度是否为块大小的整数倍 - if (input.length % cipher.blockSize != 0 && padding == 'ZeroPadding') { - input = Uint8List.fromList(input + Uint8List(cipher.blockSize - (input.length % cipher.blockSize))); + // Raw block cipher / ZeroPadding path + return _processRawCipher(input, isEncrypt, mode, keyBytes, ivBytes, aesEngine); + } + + // Build key bytes with required keySize length (pad/truncate handled where used) + static Uint8List _buildKeyBytes(String key, int keySize) { + final src = _decodeKeyStringToBytes(key); + final keyBytes = Uint8List(keySize); + for (int i = 0; i < keySize && i < src.length; i++) { + keyBytes[i] = src[i]; } + return keyBytes; + } + + // Decode IV string to bytes (supports base64: prefix or plain text) + static Uint8List _buildIvBytes(String iv) { + return _decodeKeyStringToBytes(iv); + } + + // Zero-padding helper for encryption + static Uint8List _padZeroForEncrypt(Uint8List input, int blockSize) { + final rem = input.length % blockSize; + if (rem == 0) return input; + final padLen = blockSize - rem; + final tmp = Uint8List(input.length + padLen); + tmp.setRange(0, input.length, input); + // trailing zeros already default to 0 + return tmp; + } + + static Uint8List _processWithPaddedCipher(Uint8List input, bool isEncrypt, String mode, Uint8List keyBytes, + Uint8List? ivBytes, AESEngine aesEngine) { + final BlockCipher blockCipher = (mode == 'CBC') ? CBCBlockCipher(aesEngine) : aesEngine; + final paddedCipher = PaddedBlockCipherImpl(PKCS7Padding(), blockCipher); + + final params = (mode == 'CBC') + ? PaddedBlockCipherParameters, Null>( + ParametersWithIV(KeyParameter(keyBytes), ivBytes!), null) + : PaddedBlockCipherParameters(KeyParameter(keyBytes), null); + + paddedCipher.init(isEncrypt, params); + return paddedCipher.process(input); + } + + static Uint8List _processRawCipher(Uint8List input, bool isEncrypt, String mode, Uint8List keyBytes, + Uint8List? ivBytes, AESEngine aesEngine) { + final BlockCipher cipher = (mode == 'CBC') ? CBCBlockCipher(aesEngine) : aesEngine; + + final CipherParameters params = (mode == 'CBC') + ? ParametersWithIV(KeyParameter(keyBytes), ivBytes!) + : KeyParameter(keyBytes); cipher.init(isEncrypt, params); - return cipher.process(input); + + if (input.length % cipher.blockSize != 0) { + throw ArgumentError('Input length must be multiple of block size (${cipher.blockSize}) for raw AES processing'); + } + + final out = Uint8List(input.length); + var offset = 0; + while (offset < input.length) { + final processed = cipher.process(input.sublist(offset, offset + cipher.blockSize)); + out.setRange(offset, offset + processed.length, processed); + offset += cipher.blockSize; + } + return out; } -} + + // Decode key or iv string that may be prefixed with 'base64:' or be plain text + static Uint8List _decodeKeyStringToBytes(String s) { + if (s.startsWith('base64:')) { + final b64 = s.substring(7); + try { + return Uint8List.fromList(base64.decode(b64)); + } catch (_) { + // fallback to utf8 bytes of the full string + return Uint8List.fromList(utf8.encode(s)); + } + } + + // default: treat as plain text + return Uint8List.fromList(utf8.encode(s)); + } + +} \ No newline at end of file diff --git a/lib/utils/crypto_body_decoder.dart b/lib/utils/crypto_body_decoder.dart new file mode 100644 index 0000000..4c46f40 --- /dev/null +++ b/lib/utils/crypto_body_decoder.dart @@ -0,0 +1,223 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:proxypin/network/http/http.dart'; + +import '../network/components/manager/request_crypto_manager.dart'; +import '../network/util/logger.dart'; +import 'aes.dart'; + +class CryptoDecodedResult { + final Uint8List bytes; + final String? text; + final CryptoRule? rule; + + const CryptoDecodedResult({required this.bytes, this.text, this.rule}); + + bool get hasText => text != null && text!.trim().isNotEmpty; +} + +class CryptoBodyDecoder { + static Future maybeDecode(HttpMessage message) async { + final ruleStore = await RequestCryptoManager.instance; + + CryptoRule? match = ruleStore.getMatchingRule(message); + if (match == null) { + return null; + } + + return _tryDecode(message, match.config, rule: match); + } + + static CryptoDecodedResult? decode(HttpMessage message, CryptoKeyConfig config) { + return _tryDecode(message, config); + } + + static CryptoDecodedResult? decodeWithConfig(HttpMessage message, CryptoKeyConfig config) { + return _tryDecode(message, config); + } + + static CryptoDecodedResult? _tryDecode(HttpMessage message, CryptoKeyConfig config, {CryptoRule? rule}) { + final raw = message.body; + if (raw == null || raw.isEmpty || !config.isReady) { + return null; + } + + // If rule specifies a field, try to parse body as JSON and extract that field for decryption + final fieldPath = rule?.field?.trim(); + logger.d("CryptoBodyDecoder _tryDecode with config: $config and rule: $rule fieldPath: $fieldPath"); + if (fieldPath != null && fieldPath.isNotEmpty) { + // parse body as text + final content = _bytesToString(raw, message.charset); + if (content == null) return null; + dynamic jsonObj; + try { + jsonObj = jsonDecode(content); + } catch (_) { + return null; + } + + final extracted = _extractJsonField(jsonObj, fieldPath); + if (extracted == null) return null; + // Only attempt when extracted is a string or number (we stringify otherwise) + String fieldStr = extracted.toString(); + + // build candidates from the field string: raw bytes and base64-decoded (if looks like base64) + final candidates = []; + final base64Candidate = _tryDecodeBase64String(fieldStr); + if (base64Candidate != null) candidates.add(base64Candidate); + + for (final candidate in candidates) { + try { + final decrypted = _decryptCandidate(candidate, config); + // print("CryptoBodyDecoder _tryDecode decrypted bytes: $decrypted"); + if (decrypted != null) { + return CryptoDecodedResult(bytes: decrypted, text: _bytesToString(decrypted, message.charset), rule: rule); + } + } catch (e) { + logger.d("CryptoBodyDecoder _tryDecode decryption error: $e"); + continue; + } + } + return null; + } + + // whole-body: try raw bytes and base64-decoded text + final candidates = []; + // candidates.add(Uint8List.fromList(raw)); + final base64Candidate = _fromBase64(raw); + if (base64Candidate != null) { + candidates.add(base64Candidate); + } + // logger.d("CryptoBodyDecoder _tryDecode total candidates: ${candidates.length}"); + for (final candidate in candidates) { + try { + final decrypted = _decryptCandidate(candidate, config); + // logger.d("CryptoBodyDecoder _tryDecode decrypted bytes: $decrypted"); + if (decrypted != null) { + return CryptoDecodedResult(bytes: decrypted, text: _bytesToString(decrypted, message.charset), rule: rule); + } + } catch (e) { + logger.d("CryptoBodyDecoder _tryDecode decryption error: $e"); + continue; + } + } + return null; + } + + // Attempt to decrypt a single candidate, handling ivSource == 'prefix' by extracting IV bytes. + static Uint8List? _decryptCandidate(Uint8List candidate, CryptoKeyConfig config) { + const int aesBlockSize = 16; + // If using prefix-mode, split IV and cipher bytes and ensure cipher bytes length is valid for non-PKCS7 paddings + if (config.mode == 'CBC' && config.ivSource == 'prefix') { + final n = config.ivPrefixLength; + if (candidate.length <= n) return null; + final ivBytes = candidate.sublist(0, n); + final cipherBytes = candidate.sublist(n); + // For non-PKCS7 paddings (e.g., ZeroPadding/raw) the cipher bytes length must be multiple of block size + if (config.padding != 'PKCS7' && (cipherBytes.length % aesBlockSize != 0)) return null; + final ivStr = 'base64:' + base64.encode(ivBytes); + try { + return AesUtils.decrypt(cipherBytes, + key: config.key, keyLength: config.keyLength, mode: config.mode, padding: config.padding, iv: ivStr); + } catch (e) { + logger.d('CryptoBodyDecoder _decryptCandidate error (prefix): $e'); + return null; + } + } else { + // iv provided in config.iv (may include base64: prefix or be plain text) + // For non-PKCS7 paddings ensure candidate length is block-aligned before attempting raw decrypt + if (config.padding != 'PKCS7' && (candidate.length % aesBlockSize != 0)) return null; + final ivParam = (config.mode == 'CBC') ? config.iv : null; + try { + return AesUtils.decrypt(candidate, + key: config.key, keyLength: config.keyLength, mode: config.mode, padding: config.padding, iv: ivParam); + } catch (e) { + logger.d('CryptoBodyDecoder _decryptCandidate error: $e'); + return null; + } + } + } + + // Try to decode a base64 string; return bytes or null + static Uint8List? _tryDecodeBase64String(String s) { + final trimmed = s.trim(); + if (trimmed.isEmpty) return null; + if (!_maybeBase64(trimmed)) return null; + try { + return Uint8List.fromList(base64.decode(trimmed)); + } catch (_) { + return null; + } + } + + // Extract a nested JSON field by a dot-separated path. Supports array indexes like items[0].value + static dynamic _extractJsonField(dynamic jsonObj, String path) { + final parts = path.split('.'); + dynamic current = jsonObj; + for (final part in parts) { + if (current == null) return null; + // check for array index like key[index] + final arrayMatch = RegExp(r"^([a-zA-Z0-9_\-]+)\[(\d+)\]").firstMatch(part); + if (arrayMatch != null) { + final key = arrayMatch.group(1)!; + final idx = int.parse(arrayMatch.group(2)!); + if (current is Map && current.containsKey(key)) { + final list = current[key]; + if (list is List && idx >= 0 && idx < list.length) { + current = list[idx]; + continue; + } + return null; + } + return null; + } + + // normal key or numeric index for lists + if (current is Map) { + if (!current.containsKey(part)) return null; + current = current[part]; + } else if (current is List) { + final idx = int.tryParse(part); + if (idx == null || idx < 0 || idx >= current.length) return null; + current = current[idx]; + } else { + return null; + } + } + return current; + } + + static Uint8List? _fromBase64(List raw) { + try { + final content = utf8.decode(raw).trim(); + if (content.isEmpty || !_maybeBase64(content)) { + return null; + } + return Uint8List.fromList(base64.decode(content)); + } catch (_) { + return null; + } + } + + static bool _maybeBase64(String value) { + if (value.length % 4 != 0) return false; + if (value.contains(RegExp(r'[^A-Za-z0-9+/=\r\n]'))) return false; + return true; + } + + static String? _bytesToString(List bytes, String? charset) { + try { + if (charset == null || charset.toLowerCase().contains('utf')) { + return utf8.decode(bytes); + } + return const Latin1Codec().decode(bytes); + } catch (_) { + try { + return utf8.decode(bytes); + } catch (_) { + return null; + } + } + } +} diff --git a/lib/utils/har.dart b/lib/utils/har.dart index c98f1b5..67bd9b5 100644 --- a/lib/utils/har.dart +++ b/lib/utils/har.dart @@ -33,7 +33,7 @@ class Har { static Map toHar(HttpRequest request) { Map har = { "startedDateTime": request.requestTime.toUtc().toIso8601String(), // 请求发出的时间(ISO 8601) - "time": request.response?.responseTime.difference(request.requestTime).inMilliseconds, + "time": request.response?.responseTime.difference(request.requestTime).inMilliseconds ?? -1, // 请求耗时,单位毫秒 "pageref": "ProxyPin", // 页面标识 "_id": request.requestId, // 页面标识 '_app': request.processInfo?.toJson(), @@ -51,7 +51,7 @@ class Har { "cache": {}, 'timings': { 'send': 0, - 'wait': request.response?.responseTime.difference(request.requestTime).inMilliseconds, + 'wait': request.response?.responseTime.difference(request.requestTime).inMilliseconds ?? -1, 'receive': 0, }, 'serverIPAddress': request.response?.remoteHost ?? '', // 服务器IP地址 @@ -66,7 +66,7 @@ class Har { "content": { "size": request.response?.body?.length ?? -1, // 响应体大小 "mimeType": _getContentType(request.response?.headers.contentType), // 响应体类型 - "text": request.response?.bodyAsString, // 响应体内容 + "text": request.response?.bodyAsString ?? '', // 响应体内容 }, "redirectURL": '', // 重定向地址 "headersSize": -1, // 响应头大小 @@ -185,13 +185,13 @@ class Har { if (request.contentType == ContentType.formData || request.contentType == ContentType.formUrl) { return { "mimeType": request.headers.contentType, // 请求体类型 - "text": request.body == null ? null : String.fromCharCodes(request.body!), // 请求体内容 + if (request.body != null) "text": String.fromCharCodes(request.body!), // 请求体内容 "params": [], // 请求体内容 }; } return { "mimeType": request.headers.contentType, // 请求体类型 - "text": request.body == null ? null : String.fromCharCodes(request.body!), // 请求体内容 + if (request.body != null) "text": String.fromCharCodes(request.body!), // 请求体内容 }; } diff --git a/lib/utils/task.dart b/lib/utils/task.dart new file mode 100644 index 0000000..85e976c --- /dev/null +++ b/lib/utils/task.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +/// 延时任务工具类 +class DelayedTask { + // 私有构造函数,实现单例 + DelayedTask._internal(); + + static final DelayedTask _instance = DelayedTask._internal(); + + factory DelayedTask() => _instance; + + // 维护一个任务池,支持同时管理多个不同的延时任务 + final Map _taskPool = {}; + + /// 执行防抖任务 (Debounce) + /// 如果在 [duration] 时间内再次调用相同 [tag] 的任务,前一个任务会被自动取消 + void debounce( + String tag, + Duration duration, + void Function() action, + ) { + // 1. 如果旧任务还在运行,直接取消 + _taskPool[tag]?.cancel(); + + // 2. 开启新任务 + _taskPool[tag] = Timer(duration, () { + action(); + _taskPool.remove(tag); // 执行完毕后移除 + }); + } + + /// 延迟 [duration] 后执行一次,返回可手动取消的 Timer + /// 适用于不需要防抖,但需要精准手动控制取消的场景 + Timer delay(Duration duration, void Function() action) { + return Timer(duration, action); + } + + /// 取消特定标签的任务 + void cancel(String tag) { + if (_taskPool.containsKey(tag)) { + _taskPool[tag]?.cancel(); + _taskPool.remove(tag); + } + } + + /// 取消所有正在运行的任务 (通常在 dispose 时调用) + void cancelAll() { + _taskPool.forEach((tag, timer) => timer.cancel()); + _taskPool.clear(); + } +} diff --git a/linux/build.sh b/linux/build.sh index 243c8aa..f29a670 100644 --- a/linux/build.sh +++ b/linux/build.sh @@ -1,12 +1,11 @@ #!/bin/bash - pwd cd ../build/linux/x64/release rm -rf package mkdir -p package/DEBIAN echo "Package: ProxyPin" >> package/DEBIAN/control -echo "Version: 1.2.3" >> package/DEBIAN/control +echo "Version: 1.2.4" >> package/DEBIAN/control echo "Priority: optional" >> package/DEBIAN/control echo "Architecture: amd64" >> package/DEBIAN/control echo "Depends: ca-certificates" >> package/DEBIAN/control diff --git a/pubspec.yaml b/pubspec.yaml index aa5df93..2758828 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: proxypin description: ProxyPin publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.2.3+26 +version: 1.2.4+27 environment: sdk: '>=3.0.2 <4.0.0' @@ -33,6 +33,7 @@ dependencies: git: url: https://github.com/wanghongenpin/flutter-code-editor.git ref: secure-keyboard + flutter_highlight: ^0.7.0 flutter_desktop_context_menu: ^0.2.0 device_info_plus: ^10.1.2 shared_preferences: ^2.2.3 diff --git a/test/pk12_test.dart b/test/pk12_test.dart index 405683b..10282ba 100644 --- a/test/pk12_test.dart +++ b/test/pk12_test.dart @@ -3,6 +3,12 @@ import 'dart:io'; import 'package:proxypin/network/util/cert/pkcs12.dart'; void main() { + const testPath = r"C:\Users\wanghongen\Downloads\new_key.p12"; + if (!File(testPath).existsSync()) { + print('pk12_test local file missing - skipped'); + return; + } + File file = File('C:\\Users\\wanghongen\\Downloads\\new_key.p12'); parsePKCS12([file], '01');