From 9c7a9da67bcf0c538033b52b0f2b24a0fd5b5876 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sat, 27 Dec 2025 01:34:27 +0800 Subject: [PATCH 01/20] Add export/import functionality for favorites (#655) --- lib/network/http/http.dart | 4 +- lib/storage/favorites.dart | 60 ++++++++++++++- lib/ui/component/history_cache_time.dart | 1 + lib/ui/content/panel.dart | 1 + lib/ui/desktop/left_menus/favorite.dart | 96 +++++++++++++++++++++++- lib/ui/desktop/left_menus/history.dart | 20 ++++- lib/ui/mobile/request/favorite.dart | 61 +++++++++++++-- 7 files changed, 228 insertions(+), 15 deletions(-) diff --git a/lib/network/http/http.dart b/lib/network/http/http.dart index 2575058..9993376 100644 --- a/lib/network/http/http.dart +++ b/lib/network/http/http.dart @@ -260,6 +260,7 @@ class HttpRequest extends HttpMessage { Map toJson() { return { '_class': 'HttpRequest', + '_id': requestId, 'uri': requestUrl, 'method': method.name, 'protocolVersion': protocolVersion, @@ -273,7 +274,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) { 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/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/content/panel.dart b/lib/ui/content/panel.dart index be04296..3d9779c 100644 --- a/lib/ui/content/panel.dart +++ b/lib/ui/content/panel.dart @@ -156,6 +156,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()), 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 f5cc5ee..f8a49a7 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/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, From d9a3a1b934053d20e47d34f6f36e17dbaf39ea6d Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sat, 27 Dec 2025 19:47:08 +0800 Subject: [PATCH 02/20] android: set minSdkVersion to 21 (explicit) --- android/app/build.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 2875577..7aa27b9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -60,7 +60,9 @@ android { applicationId "com.network.proxy" ndk { abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' } // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion flutter.minSdkVersion +// minSdkVersion flutter.minSdkVersion + // Minimum supported Android version: 21 + minSdkVersion 21 targetSdkVersion flutter.targetSdkVersion multiDexEnabled true versionCode flutterVersionCode.toInteger() From e98229c8770dbcad6a9c04055e0433f70b98b62f Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sat, 27 Dec 2025 21:45:11 +0800 Subject: [PATCH 03/20] UI components for improved consistency and styling --- android/app/build.gradle | 4 +--- lib/ui/desktop/left_menus/navigation.dart | 2 ++ lib/ui/desktop/preference.dart | 11 +++++----- lib/ui/desktop/request/report_servers.dart | 4 +++- lib/ui/desktop/setting/about.dart | 23 +++++++-------------- lib/ui/desktop/setting/request_rewrite.dart | 2 +- lib/ui/mobile/setting/report_servers.dart | 2 +- lib/ui/mobile/setting/request_rewrite.dart | 2 +- lib/ui/mobile/widgets/about.dart | 16 +++++++------- linux/build.sh | 2 +- 10 files changed, 33 insertions(+), 35 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 7aa27b9..2875577 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -60,9 +60,7 @@ android { applicationId "com.network.proxy" ndk { abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' } // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. -// minSdkVersion flutter.minSdkVersion - // Minimum supported Android version: 21 - minSdkVersion 21 + minSdkVersion flutter.minSdkVersion targetSdkVersion flutter.targetSdkVersion multiDexEnabled true versionCode flutterVersionCode.toInteger() 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 93cd7be..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,7 @@ class _ReportServersPageState extends State { SizedBox( width: 100, child: DropdownButtonFormField( - initialValue: compression, + value: compression, decoration: dec(), isDense: true, items: [ diff --git a/lib/ui/desktop/setting/about.dart b/lib/ui/desktop/setting/about.dart index 69404ac..e553520 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.surfaceContainerHighest.withValues(alpha: 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), @@ -96,10 +88,11 @@ class _AppUpdateStateChecking extends State { showDialog( context: context, builder: (ctx) => AlertDialog( - constraints: const BoxConstraints(maxWidth: 385), title: Text(localizations.privacyPolicy), content: SingleChildScrollView( - child: Text(localizations.privacyContent, style: const TextStyle(height: 1.35))), + 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)) ], diff --git a/lib/ui/desktop/setting/request_rewrite.dart b/lib/ui/desktop/setting/request_rewrite.dart index 262d335..a47bf59 100644 --- a/lib/ui/desktop/setting/request_rewrite.dart +++ b/lib/ui/desktop/setting/request_rewrite.dart @@ -604,7 +604,7 @@ class _RewriteRuleEditState extends State { height: 36, child: DropdownButtonFormField( onSaved: (val) => rule.type = val!, - initialValue: ruleType, + value: ruleType, decoration: InputDecoration( errorStyle: const TextStyle(height: 0, fontSize: 0), contentPadding: const EdgeInsets.only(left: 7, right: 7), diff --git a/lib/ui/mobile/setting/report_servers.dart b/lib/ui/mobile/setting/report_servers.dart index 0a02571..3ffadcd 100644 --- a/lib/ui/mobile/setting/report_servers.dart +++ b/lib/ui/mobile/setting/report_servers.dart @@ -268,7 +268,7 @@ class _ReportServerEditPageMobileState extends State SizedBox( width: 120, child: DropdownButtonFormField( - initialValue: _compression, + value: _compression, decoration: dec(), items: [ DropdownMenuItem(value: 'none', child: Text(localizations.compressionNone)), diff --git a/lib/ui/mobile/setting/request_rewrite.dart b/lib/ui/mobile/setting/request_rewrite.dart index 5dc69e7..46d44bf 100644 --- a/lib/ui/mobile/setting/request_rewrite.dart +++ b/lib/ui/mobile/setting/request_rewrite.dart @@ -569,7 +569,7 @@ class _RewriteRuleState extends State { height: 50, child: DropdownButtonFormField( onSaved: (val) => rule.type = val!, - initialValue: ruleType, + value: ruleType, decoration: const InputDecoration( border: OutlineInputBorder(), errorStyle: TextStyle(height: 0, fontSize: 0), diff --git a/lib/ui/mobile/widgets/about.dart b/lib/ui/mobile/widgets/about.dart index b02639d..f0b6387 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, @@ -101,13 +101,15 @@ class _AboutState extends State { onTap: () { showDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => ConstrainedBox( constraints: const BoxConstraints(maxWidth: 385), - title: Text(localizations.privacyPolicy), - content: SingleChildScrollView(child: Text(localizations.privacyContent)), - actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(localizations.close)) - ], + child: AlertDialog( + title: Text(localizations.privacyPolicy), + content: SingleChildScrollView(child: Text(localizations.privacyContent)), + 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)), diff --git a/linux/build.sh b/linux/build.sh index 601d157..d30e1ed 100644 --- a/linux/build.sh +++ b/linux/build.sh @@ -5,7 +5,7 @@ cd ../build/linux/x64/release rm -rf package mkdir -p package/DEBIAN echo "Package: ProxyPin" >> package/DEBIAN/control -echo "Version: 1.2.2" >> package/DEBIAN/control +echo "Version: 1.2.3" >> package/DEBIAN/control echo "Priority: optional" >> package/DEBIAN/control echo "Architecture: amd64" >> package/DEBIAN/control echo "Depends: ca-certificates" >> package/DEBIAN/control From ce6ee05de496f78d0a6f00d7a191cf3afc5af088 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Tue, 30 Dec 2025 22:02:52 +0800 Subject: [PATCH 04/20] Add text and list display to HTTP headers --- lib/ui/component/json/json_text.dart | 2 +- lib/ui/configuration.dart | 5 + lib/ui/content/headers.dart | 187 +++++++++++++++++++++++++++ lib/ui/content/panel.dart | 54 +++----- lib/ui/desktop/setting/setting.dart | 2 +- lib/ui/mobile/widgets/about.dart | 23 ++-- pubspec.yaml | 5 +- 7 files changed, 225 insertions(+), 53 deletions(-) create mode 100644 lib/ui/content/headers.dart 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/configuration.dart b/lib/ui/configuration.dart index 3ca3a5c..bc5ba7a 100644 --- a/lib/ui/configuration.dart +++ b/lib/ui/configuration.dart @@ -82,6 +82,9 @@ class AppConfiguration { /// header默认展示 bool headerExpanded = true; + /// Headers展示模式: table(逐行) / text(原始文本) + String headerViewMode = "table"; + /// 底部导航栏 bool bottomNavigation = true; @@ -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; @@ -251,6 +255,7 @@ class AppConfiguration { "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/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 3d9779c..bae2eff 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'; ///网络请求详情页 @@ -219,35 +219,13 @@ 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]; } } @@ -282,32 +260,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 +306,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 +316,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/setting/setting.dart b/lib/ui/desktop/setting/setting.dart index 5568885..60356dd 100644 --- a/lib/ui/desktop/setting/setting.dart +++ b/lib/ui/desktop/setting/setting.dart @@ -76,7 +76,7 @@ class _SettingState extends State { item(localizations.requestRewrite, onPressed: requestRewrite), item(localizations.requestMap, onPressed: requestMap), item(localizations.script, - onPressed: () => MultiWindow.openWindow(localizations.script, 'ScriptWidget', size: const Size(800, 700))), + onPressed: () => MultiWindow.openWindow(localizations.script, 'ScriptWidget', size: const Size(800, 730))), item(localizations.externalProxy, onPressed: setExternalProxy), item(localizations.about, onPressed: showAbout), ], diff --git a/lib/ui/mobile/widgets/about.dart b/lib/ui/mobile/widgets/about.dart index f0b6387..2561486 100644 --- a/lib/ui/mobile/widgets/about.dart +++ b/lib/ui/mobile/widgets/about.dart @@ -100,17 +100,18 @@ class _AboutState extends State { trailing: const Icon(Icons.privacy_tip_outlined, size: 22), onTap: () { showDialog( - context: context, - builder: (ctx) => ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 385), - child: AlertDialog( - title: Text(localizations.privacyPolicy), - content: SingleChildScrollView(child: Text(localizations.privacyContent)), - actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(localizations.close)) - ], - ), - )); + 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 diff --git a/pubspec.yaml b/pubspec.yaml index fc4d9cb..b1a4eb5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: url: https://gitee.com/wanghongenpin/flutter-plugins.git path: packages/desktop_multi_window path_provider: ^2.1.5 - file_picker: ^10.3.7 + file_picker: ^10.3.8 proxy_manager: ^0.0.3 permission_handler: ^12.0.1 flutter_toastr: ^1.0.3 @@ -33,9 +33,10 @@ 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: ^11.5.0 - shared_preferences: ^2.5.3 + shared_preferences: ^2.5.4 url_launcher: ^6.3.2 toastification: ^3.0.2 get: ^4.7.3 From dee8f45d917ba897f7923022e4a8688c7392c71c Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Fri, 2 Jan 2026 20:35:06 +0800 Subject: [PATCH 05/20] Add request crypto functionality (#500)(#335)(#472) --- lib/l10n/app_en.arb | 8 +- lib/l10n/app_localizations.dart | 30 + lib/l10n/app_localizations_en.dart | 15 + lib/l10n/app_localizations_zh.dart | 15 + lib/l10n/app_zh.arb | 8 +- .../manager/request_crypto_manager.dart | 268 +++++++ lib/ui/component/multi_window.dart | 15 + lib/ui/component/text_field.dart | 3 +- lib/ui/content/body.dart | 64 +- lib/ui/desktop/setting/request_crypto.dart | 706 ++++++++++++++++++ lib/ui/desktop/setting/setting.dart | 5 + lib/ui/toolbox/aes_page.dart | 2 +- lib/utils/aes.dart | 135 +++- lib/utils/crypto_body_decoder.dart | 227 ++++++ 14 files changed, 1478 insertions(+), 23 deletions(-) create mode 100644 lib/network/components/manager/request_crypto_manager.dart create mode 100644 lib/ui/desktop/setting/request_crypto.dart create mode 100644 lib/utils/crypto_body_decoder.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 43c52aa..00df9fd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -357,5 +357,11 @@ "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" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 00fb023..c5f01a6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2093,6 +2093,36 @@ 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; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c14f4a0..5b7d4b5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1037,4 +1037,19 @@ 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'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index fb58a72..610a66b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1024,6 +1024,21 @@ 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 => '字段'; } /// The translations for Chinese, using the Han script (`zh_Hant`). diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7fbe9da..fb3412d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -357,5 +357,11 @@ "sponsorBuyMeCoffee": "Buy Me a Coffee", "privacyPolicy": "隐私协议", - "privacyContent": "本项目为开源抓包工具,所有功能均在本地设备上运行;无任何后端服务器,不会收集、存储或上传任何用户信息。抓取的网络数据仅在本地处理,除非您主动使用远程转发功能。所需权限(如网络、存储、相机用于扫码)仅用于实现相应功能。您可在公开的源代码中审计其行为。" + "privacyContent": "本项目为开源抓包工具,所有功能均在本地设备上运行;无任何后端服务器,不会收集、存储或上传任何用户信息。抓取的网络数据仅在本地处理,除非您主动使用远程转发功能。所需权限(如网络、存储、相机用于扫码)仅用于实现相应功能。您可在公开的源代码中审计其行为。", + + "requestCrypto": "请求解密", + "cryptoDecoded": "已解密", + "cryptoDecodeToggle": "解密", + "optional": "可选", + "cryptoRuleField": "字段" } \ No newline at end of file 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..1dfff70 --- /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/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(String url) { + 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 + final bool enabled; + final CryptoKeyConfig config; + + const CryptoRule({ + required this.name, + required this.urlPattern, + this.field, + required this.enabled, + required this.config, + }); + + bool matches(String url) { + if (urlPattern.isEmpty) { + return true; + } + 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/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/content/body.dart b/lib/ui/content/body.dart index 97d81d4..c28907b 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,17 @@ 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 +110,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); @@ -273,6 +294,19 @@ class HttpBodyState extends State { onPressed: () => openNew())); } + if (decoded != null) { + list.add(Row(children: [ + TextButton.icon( + onPressed: () { + setState(() { + showDecoded = !showDecoded; + }); + }, + icon: Icon(showDecoded ? Icons.lock_open : Icons.lock), + label: Text(showDecoded ? localizations.cryptoDecoded : localizations.cryptoDecodeToggle)), + ])); + } + return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row(children: list), @@ -419,6 +453,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,6 +485,11 @@ class _BodyState extends State<_Body> { } Widget _getBody(ViewType type) { + 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( @@ -479,7 +523,9 @@ class _BodyState extends State<_Body> { } 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")); @@ -495,7 +541,8 @@ class _BodyState extends State<_Body> { 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 +690,16 @@ 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(); +} diff --git a/lib/ui/desktop/setting/request_crypto.dart b/lib/ui/desktop/setting/request_crypto.dart new file mode 100644 index 0000000..9c523dd --- /dev/null +++ b/lib/ui/desktop/setting/request_crypto.dart @@ -0,0 +1,706 @@ +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 { + final Map selected = {}; + + RequestCryptoManager get manager => widget.manager; + bool isPressed = false; + Offset? lastPressPosition; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @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.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) { + 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: 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), + _buildRuleList() + ])))); + } + + Widget _buildRuleList() { + final theme = Theme.of(context); + return GestureDetector( + onSecondaryTapDown: (details) => _showGlobalMenu(details.globalPosition), + child: Listener( + onPointerUp: (_) => isPressed = false, + onPointerDown: (event) { + lastPressPosition = event.localPosition; + if (event.buttons == kPrimaryButton) { + isPressed = true; + } + }, + child: Container( + padding: const EdgeInsets.only(top: 10), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withAlpha((0.2 * 255).round()))), + child: Column(children: [ + Padding( + padding: 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')), + SizedBox(width: 220, child: Text(localizations.cryptoRuleField, textAlign: TextAlign.center)), + SizedBox(width: 120, child: Text(localizations.action, textAlign: TextAlign.center)) + ])), + const Divider(thickness: 0.5, height: 5), + ...List.generate(manager.rules.length, (index) { + final rule = manager.rules[index]; + final selectedState = selected[index] == true; + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + hoverColor: theme.colorScheme.primary.withValues(alpha: 0.1), + onSecondaryTapDown: (details) => _showRowMenu(details.globalPosition, index), + onDoubleTap: () => _editRule(index), + onHover: (hover) { + if (isPressed && !selectedState) { + setState(() => selected[index] = true); + } + }, + onTap: () { + if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { + setState(() => selected[index] = !(selected[index] ?? false)); + return; + } + if (selected.isEmpty) { + return; + } + setState(() => selected.clear()); + }, + child: Container( + color: selectedState + ? theme.colorScheme.primary.withValues(alpha: 0.2) + : index.isEven + ? Colors.grey.withAlpha((0.06 * 255).round()) + : null, + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 16), + 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) => _toggleRule(index, val))), + const SizedBox(width: 8), + Expanded( + child: Text(rule.urlPattern.isEmpty ? localizations.emptyMatchAll : rule.urlPattern, + overflow: TextOverflow.ellipsis)), + SizedBox( + width: 220, + child: Text(rule.field ?? '', + overflow: TextOverflow.ellipsis, textAlign: TextAlign.center)), + SizedBox( + width: 120, + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + IconButton( + icon: const Icon(Icons.edit, size: 18), + tooltip: localizations.edit, + onPressed: () => _editRule(index)), + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + tooltip: localizations.delete, + onPressed: () => _removeRules([index])) + ])) + ]))); + }) + ])))); + } + + 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 _editRule(int index) async { + final rule = manager.rules[index]; + final updated = await showDialog(context: context, builder: (_) => CryptoRuleDialog(rule: rule)); + if (updated == null) return; + await manager.updateRule(index, updated); + _refreshConfig(force: true); + setState(() {}); + } + + Future _toggleRule(int index, bool value) async { + await manager.updateRule(index, manager.rules[index].copyWith(enabled: value)); + _refreshConfig(force: true); + setState(() {}); + } + + void _showRowMenu(Offset position, int index) { + showContextMenu(context, position, items: [ + PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => _editRule(index)), + PopupMenuItem(height: 35, child: Text(localizations.delete), onTap: () => _removeRules([index])) + ]); + } + + void _showGlobalMenu(Offset offset) { + showContextMenu(context, offset, items: [ + PopupMenuItem(height: 35, onTap: _addRule, child: Text(localizations.newBuilt)), + PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => _export(selected.keys.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.keys.toList())) + ]); + } + + Future _removeRules(List indexes) async { + if (indexes.isEmpty) return; + indexes.sort((a, b) => b.compareTo(a)); + for (final index in indexes) { + await manager.removeRule(index); + } + selected.clear(); + _refreshConfig(force: true); + } + + Future _enableStatus(bool enable) async { + if (selected.isEmpty) return; + for (final entry in selected.entries) { + if (entry.value) { + await manager.updateRule(entry.key, manager.rules[entry.key].copyWith(enabled: enable)); + } + } + selected.clear(); + _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); + } + } + + 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)); + FlutterToastr.show(localizations.exportSuccess, context); + } +} + +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), + // Key input and format selector in a single row for nicer UI + Row(children: [ + 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(width: 8), + Container( + height: 44, + 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: 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, + itemHeight: 48, + ), + ), + ) + ]), + const SizedBox(height: 12), + // Compact single-line IV controls for CBC + if (mode == 'CBC') + Row(children: [ + // small segmented control + SegmentedButton( + segments: const [ + ButtonSegment(value: 'manual', label: Text('输入')), + ButtonSegment(value: 'prefix', label: Text('从密文取')), + ], + selected: {ivSource}, + onSelectionChanged: (selection) => setState(() => ivSource = selection.first), + ), + const SizedBox(width: 8), + // narrow IV input when manual (fixed width for compactness) + if (ivSource == 'manual') + SizedBox( + width: 220, + height: 40, + child: TextFormField( + controller: ivController, + decoration: decorate(context, 'IV', hint: l10n.optional).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: '从密文的前 N 字节提取 IV(通常为 16)', + 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), + ), + ]), + ), + ]), + const SizedBox(height: 12), + // Compact row: Mode | Padding | Key Length + Row(children: [ + Text("Mode", style: theme.textTheme.labelMedium), + const SizedBox(width: 8), + Container( + height: 36, + 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: 36, + 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: 36, + 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, + ), + ), + ), + ]), + ]), + ), + ), + ], + ), + ), + ), + ), + 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/setting.dart b/lib/ui/desktop/setting/setting.dart index 60356dd..377e1e5 100644 --- a/lib/ui/desktop/setting/setting.dart +++ b/lib/ui/desktop/setting/setting.dart @@ -75,6 +75,7 @@ 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, 730))), item(localizations.externalProxy, onPressed: setExternalProxy), @@ -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/toolbox/aes_page.dart b/lib/ui/toolbox/aes_page.dart index 87d3094..aabf2d6 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..f2b82bf --- /dev/null +++ b/lib/utils/crypto_body_decoder.dart @@ -0,0 +1,227 @@ +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 url = message is HttpRequest + ? message.requestUrl + : message is HttpResponse + ? message.request?.requestUrl + : null; + if (url == null) return null; + final ruleStore = await RequestCryptoManager.instance; + + CryptoRule? match = ruleStore.getMatchingRule(url); + if (match != null) { + return _tryDecode(message, match.config, rule: match); + } + + return null; + } + + 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; + } + } + } +} From cc503dc42ae570a32fd5a4e2b592417777b7d30e Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Tue, 6 Jan 2026 00:18:15 +0800 Subject: [PATCH 06/20] Refactor request crypto UI and enhance AES key handling (#500)(#335)(#472) --- lib/l10n/app_en.arb | 5 +- lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_zh.dart | 29 +- lib/l10n/app_zh.arb | 5 +- lib/l10n/app_zh_Hant.arb | 7 + .../manager/request_crypto_manager.dart | 12 +- lib/network/http/http.dart | 9 + lib/ui/content/body.dart | 14 +- lib/ui/desktop/setting/request_crypto.dart | 594 ++++++++++-------- lib/utils/crypto_body_decoder.dart | 20 +- pubspec.yaml | 2 +- 12 files changed, 434 insertions(+), 281 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 00df9fd..29bd6c5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -363,5 +363,8 @@ "cryptoDecoded": "Decoded", "cryptoDecodeToggle": "Decrypt", "optional": "Optional", - "cryptoRuleField": "Field Name" + "cryptoRuleField": "Field Name", + + "cryptoIvPrefixLabel": "IV Prefix", + "cryptoIvPrefixTooltip": "Use the first N bytes of the response body as IV" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c5f01a6..c5bc9b5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2123,6 +2123,18 @@ abstract class AppLocalizations { /// 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; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5b7d4b5..6d46b2e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1052,4 +1052,10 @@ class AppLocalizationsEn extends AppLocalizations { @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'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 610a66b..2a3707b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1038,7 +1038,13 @@ class AppLocalizationsZh extends AppLocalizations { String get optional => '可选'; @override - String get cryptoRuleField => '字段'; + String get cryptoRuleField => '字段名称'; + + @override + String get cryptoIvPrefixLabel => 'IV 前缀'; + + @override + String get cryptoIvPrefixTooltip => '使用响应体前 N 个字节作为 IV'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -2062,4 +2068,25 @@ 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'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fb3412d..1d952c6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -363,5 +363,8 @@ "cryptoDecoded": "已解密", "cryptoDecodeToggle": "解密", "optional": "可选", - "cryptoRuleField": "字段" + "cryptoRuleField": "字段名称", + + "cryptoIvPrefixLabel": "IV 前缀", + "cryptoIvPrefixTooltip": "使用响应体前 N 个字节作为 IV" } \ No newline at end of file diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 733f4d7..c87555d 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": "有可用更新", diff --git a/lib/network/components/manager/request_crypto_manager.dart b/lib/network/components/manager/request_crypto_manager.dart index 1dfff70..739ff67 100644 --- a/lib/network/components/manager/request_crypto_manager.dart +++ b/lib/network/components/manager/request_crypto_manager.dart @@ -1,6 +1,7 @@ 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'; @@ -73,7 +74,9 @@ class RequestCryptoManager { } /// Get the first matching rule for the given URL and optional field name - CryptoRule? getMatchingRule(String url) { + 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; @@ -119,10 +122,10 @@ class CryptoRule { final String name; final String urlPattern; final String? field; // single field supported - final bool enabled; + bool enabled; final CryptoKeyConfig config; - const CryptoRule({ + CryptoRule({ required this.name, required this.urlPattern, this.field, @@ -131,9 +134,6 @@ class CryptoRule { }); bool matches(String url) { - if (urlPattern.isEmpty) { - return true; - } try { return RegExp(urlPattern).hasMatch(url); } catch (_) { diff --git a/lib/network/http/http.dart b/lib/network/http/http.dart index 9993376..26654fd 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; @@ -296,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); @@ -320,6 +327,7 @@ class HttpResponse extends HttpMessage { httpResponse.responseTime = DateTime.fromMillisecondsSinceEpoch(json['responseTime']); } httpResponse.packageSize = json['packageSize']; + httpResponse._requestUrl = json['requestUrl']; return httpResponse; } @@ -327,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/ui/content/body.dart b/lib/ui/content/body.dart index c28907b..c6d0ce3 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -85,6 +85,7 @@ class HttpBodyState extends State { if (widget.windowController != null) { HardwareKeyboard.instance.addHandler(onKeyEvent); } + _loadDecoded(); } @@ -490,7 +491,8 @@ class _BodyState extends State<_Body> { ? _DecodedHttpMessage(widget.message!, parent!.decoded!) : widget.message; - if (message?.isWebSocket == true || (message?.contentType == ContentType.sse && message?.messages.isNotEmpty == true)) { + 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), @@ -523,9 +525,7 @@ class _BodyState extends State<_Body> { } 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")); @@ -541,8 +541,7 @@ class _BodyState extends State<_Body> { 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); @@ -702,4 +701,7 @@ class _DecodedHttpMessage extends HttpMessage { @override Map toJson() => original.toJson(); + + @override + String? get requestUrl => original.requestUrl; } diff --git a/lib/ui/desktop/setting/request_crypto.dart b/lib/ui/desktop/setting/request_crypto.dart index 9c523dd..9df2293 100644 --- a/lib/ui/desktop/setting/request_crypto.dart +++ b/lib/ui/desktop/setting/request_crypto.dart @@ -47,13 +47,9 @@ class RequestCryptoPage extends StatefulWidget { } class _RequestCryptoPageState extends State { - final Map selected = {}; + AppLocalizations get localizations => AppLocalizations.of(context)!; RequestCryptoManager get manager => widget.manager; - bool isPressed = false; - Offset? lastPressPosition; - - AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { @@ -68,6 +64,11 @@ class _RequestCryptoPageState extends State { } 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)) { @@ -82,6 +83,7 @@ class _RequestCryptoPageState extends State { @override Widget build(BuildContext context) { + bool isEN = Localizations.localeOf(context).languageCode == 'en'; return Scaffold( backgroundColor: Theme.of(context).dialogTheme.backgroundColor, appBar: AppBar( @@ -94,7 +96,7 @@ class _RequestCryptoPageState extends State { child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ SizedBox( - width: 225, + width: isEN ? 310 : 225, child: ListTile( title: Text("${localizations.enable} ${localizations.requestCrypto}"), trailing: SwitchWidget( @@ -118,101 +120,7 @@ class _RequestCryptoPageState extends State { const SizedBox(width: 15) ]), const SizedBox(height: 16), - _buildRuleList() - ])))); - } - - Widget _buildRuleList() { - final theme = Theme.of(context); - return GestureDetector( - onSecondaryTapDown: (details) => _showGlobalMenu(details.globalPosition), - child: Listener( - onPointerUp: (_) => isPressed = false, - onPointerDown: (event) { - lastPressPosition = event.localPosition; - if (event.buttons == kPrimaryButton) { - isPressed = true; - } - }, - child: Container( - padding: const EdgeInsets.only(top: 10), - decoration: BoxDecoration(border: Border.all(color: Colors.grey.withAlpha((0.2 * 255).round()))), - child: Column(children: [ - Padding( - padding: 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')), - SizedBox(width: 220, child: Text(localizations.cryptoRuleField, textAlign: TextAlign.center)), - SizedBox(width: 120, child: Text(localizations.action, textAlign: TextAlign.center)) - ])), - const Divider(thickness: 0.5, height: 5), - ...List.generate(manager.rules.length, (index) { - final rule = manager.rules[index]; - final selectedState = selected[index] == true; - return InkWell( - highlightColor: Colors.transparent, - splashColor: Colors.transparent, - hoverColor: theme.colorScheme.primary.withValues(alpha: 0.1), - onSecondaryTapDown: (details) => _showRowMenu(details.globalPosition, index), - onDoubleTap: () => _editRule(index), - onHover: (hover) { - if (isPressed && !selectedState) { - setState(() => selected[index] = true); - } - }, - onTap: () { - if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { - setState(() => selected[index] = !(selected[index] ?? false)); - return; - } - if (selected.isEmpty) { - return; - } - setState(() => selected.clear()); - }, - child: Container( - color: selectedState - ? theme.colorScheme.primary.withValues(alpha: 0.2) - : index.isEven - ? Colors.grey.withAlpha((0.06 * 255).round()) - : null, - height: 42, - padding: const EdgeInsets.symmetric(horizontal: 16), - 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) => _toggleRule(index, val))), - const SizedBox(width: 8), - Expanded( - child: Text(rule.urlPattern.isEmpty ? localizations.emptyMatchAll : rule.urlPattern, - overflow: TextOverflow.ellipsis)), - SizedBox( - width: 220, - child: Text(rule.field ?? '', - overflow: TextOverflow.ellipsis, textAlign: TextAlign.center)), - SizedBox( - width: 120, - child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - IconButton( - icon: const Icon(Icons.edit, size: 18), - tooltip: localizations.edit, - onPressed: () => _editRule(index)), - IconButton( - icon: const Icon(Icons.delete_outline, size: 18), - tooltip: localizations.delete, - onPressed: () => _removeRules([index])) - ])) - ]))); - }) + CryptoRuleList(manager: manager, windowId: widget.windowId), ])))); } @@ -225,62 +133,6 @@ class _RequestCryptoPageState extends State { _refreshConfig(force: true); } - Future _editRule(int index) async { - final rule = manager.rules[index]; - final updated = await showDialog(context: context, builder: (_) => CryptoRuleDialog(rule: rule)); - if (updated == null) return; - await manager.updateRule(index, updated); - _refreshConfig(force: true); - setState(() {}); - } - - Future _toggleRule(int index, bool value) async { - await manager.updateRule(index, manager.rules[index].copyWith(enabled: value)); - _refreshConfig(force: true); - setState(() {}); - } - - void _showRowMenu(Offset position, int index) { - showContextMenu(context, position, items: [ - PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => _editRule(index)), - PopupMenuItem(height: 35, child: Text(localizations.delete), onTap: () => _removeRules([index])) - ]); - } - - void _showGlobalMenu(Offset offset) { - showContextMenu(context, offset, items: [ - PopupMenuItem(height: 35, onTap: _addRule, child: Text(localizations.newBuilt)), - PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => _export(selected.keys.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.keys.toList())) - ]); - } - - Future _removeRules(List indexes) async { - if (indexes.isEmpty) return; - indexes.sort((a, b) => b.compareTo(a)); - for (final index in indexes) { - await manager.removeRule(index); - } - selected.clear(); - _refreshConfig(force: true); - } - - Future _enableStatus(bool enable) async { - if (selected.isEmpty) return; - for (final entry in selected.entries) { - if (entry.value) { - await manager.updateRule(entry.key, manager.rules[entry.key].copyWith(enabled: enable)); - } - } - selected.clear(); - _refreshConfig(force: true); - } - Future _import() async { String? path; if (Platform.isMacOS) { @@ -304,11 +156,225 @@ class _RequestCryptoPageState extends State { if (mounted) FlutterToastr.show(localizations.importSuccess, context); } catch (e) { logger.e('导入失败 $path', error: e); - if (mounted) FlutterToastr.show("${localizations.importFailed} $e", context); + if (mounted) FlutterToastr.show('${localizations.importFailed} $e', context); } } +} - Future _export(List indexes) async { +// 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(); @@ -321,7 +387,18 @@ class _RequestCryptoPageState extends State { } if (path == null) return; await File(path).writeAsString(jsonEncode(data)); - FlutterToastr.show(localizations.exportSuccess, context); + 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; } } @@ -478,26 +555,84 @@ class _CryptoRuleDialogState extends State { child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("AES", style: theme.textTheme.titleSmall), const SizedBox(height: 12), - // Key input and format selector in a single row for nicer UI Row(children: [ - 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, + 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: 44, + 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, + 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, @@ -508,33 +643,56 @@ class _CryptoRuleDialogState extends State { onChanged: (v) => setState(() => keyFormat = v ?? 'text'), style: Theme.of(context).textTheme.bodyMedium, iconEnabledColor: Theme.of(context).colorScheme.primary, - itemHeight: 48, ), ), - ) + ), + 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: [ - // small segmented control - SegmentedButton( - segments: const [ - ButtonSegment(value: 'manual', label: Text('输入')), - ButtonSegment(value: 'prefix', label: Text('从密文取')), - ], - selected: {ivSource}, - onSelectionChanged: (selection) => setState(() => ivSource = selection.first), + Container( + height: 42, + 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: 220, - height: 40, + width: 260, + height: 42, child: TextFormField( controller: ivController, - decoration: decorate(context, 'IV', hint: l10n.optional).copyWith( + decoration: decorate(context, 'IV').copyWith( isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10)), validator: (val) => (ivSource == 'manual' && (val == null || val.trim().isEmpty)) @@ -545,7 +703,7 @@ class _CryptoRuleDialogState extends State { if (ivSource == 'manual') const SizedBox(width: 8), if (ivSource == 'prefix') Tooltip( - message: '从密文的前 N 字节提取 IV(通常为 16)', + 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) @@ -578,76 +736,6 @@ class _CryptoRuleDialogState extends State { ]), ), ]), - const SizedBox(height: 12), - // Compact row: Mode | Padding | Key Length - Row(children: [ - Text("Mode", style: theme.textTheme.labelMedium), - const SizedBox(width: 8), - Container( - height: 36, - 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: 36, - 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: 36, - 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, - ), - ), - ), - ]), ]), ), ), diff --git a/lib/utils/crypto_body_decoder.dart b/lib/utils/crypto_body_decoder.dart index f2b82bf..4c46f40 100644 --- a/lib/utils/crypto_body_decoder.dart +++ b/lib/utils/crypto_body_decoder.dart @@ -19,20 +19,14 @@ class CryptoDecodedResult { class CryptoBodyDecoder { static Future maybeDecode(HttpMessage message) async { - final url = message is HttpRequest - ? message.requestUrl - : message is HttpResponse - ? message.request?.requestUrl - : null; - if (url == null) return null; final ruleStore = await RequestCryptoManager.instance; - CryptoRule? match = ruleStore.getMatchingRule(url); - if (match != null) { - return _tryDecode(message, match.config, rule: match); + CryptoRule? match = ruleStore.getMatchingRule(message); + if (match == null) { + return null; } - return null; + return _tryDecode(message, match.config, rule: match); } static CryptoDecodedResult? decode(HttpMessage message, CryptoKeyConfig config) { @@ -124,7 +118,8 @@ class CryptoBodyDecoder { 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); + 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; @@ -135,7 +130,8 @@ class CryptoBodyDecoder { 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); + 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; diff --git a/pubspec.yaml b/pubspec.yaml index b1a4eb5..4753534 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,7 +45,7 @@ dependencies: flutter_qr_reader_plus: ^1.0.6 brotli: ^0.6.0 # macos_window_utils: 1.6.1 - win32audio: ^1.3.1 + win32audio: ^1.5.0 vclibs: ^0.1.3 scrollable_positioned_list_nic: ^0.0.2 From 5bac0e500b01356024cea5f235f88ea1b2313922 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Wed, 7 Jan 2026 20:56:31 +0800 Subject: [PATCH 07/20] Add RequestParams widget to display query parameters in request (#635) --- lib/ui/content/panel.dart | 46 +++++++++++++++++++++++++++++++++++---- pubspec.yaml | 2 +- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/lib/ui/content/panel.dart b/lib/ui/content/panel.dart index bae2eff..299cb59 100644 --- a/lib/ui/content/panel.dart +++ b/lib/ui/content/panel.dart @@ -200,8 +200,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() { @@ -229,16 +232,51 @@ class NetworkTabState extends State with SingleTickerProvi } } -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; diff --git a/pubspec.yaml b/pubspec.yaml index 4753534..d431299 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+26 environment: sdk: '>=3.0.2 <4.0.0' From ae7d821185a2f3cc780456f5210b71554628c6dd Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Fri, 9 Jan 2026 04:19:46 +0800 Subject: [PATCH 08/20] Refactor localization strings and improve request crypto UI (#500)(#335)(#472) --- lib/l10n/app_localizations_zh.dart | 4 +- lib/l10n/app_zh.arb | 4 +- lib/ui/content/body.dart | 200 ++++-- lib/ui/desktop/desktop.dart | 11 +- lib/ui/mobile/menu/bottom_navigation.dart | 7 + lib/ui/mobile/menu/drawer.dart | 5 + lib/ui/mobile/setting/filter.dart | 2 +- lib/ui/mobile/setting/request_crypto.dart | 786 +++++++++++++++++++++ lib/ui/mobile/setting/request_map.dart | 6 +- lib/ui/mobile/setting/request_rewrite.dart | 2 +- lib/ui/mobile/setting/script.dart | 2 +- 11 files changed, 941 insertions(+), 88 deletions(-) create mode 100644 lib/ui/mobile/setting/request_crypto.dart diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 2a3707b..734ce69 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -992,10 +992,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 => '证书未安装'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1d952c6..88855fa 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": "新窗口打开", diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index c6d0ce3..b2263bd 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -230,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), @@ -285,33 +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) { - list.add(Row(children: [ - TextButton.icon( - onPressed: () { - setState(() { - showDecoded = !showDecoded; - }); - }, - icon: Icon(showDecoded ? Icons.lock_open : Icons.lock), - label: Text(showDecoded ? localizations.cryptoDecoded : localizations.cryptoDecodeToggle)), - ])); + 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)); } ///下载图片 @@ -520,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); diff --git a/lib/ui/desktop/desktop.dart b/lib/ui/desktop/desktop.dart index 2c6fdd3..0eaedf6 100644 --- a/lib/ui/desktop/desktop.dart +++ b/lib/ui/desktop/desktop.dart @@ -175,13 +175,10 @@ 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' + '1. 增加收藏导出和导入;\n' + '2. 增加请求解密,可配置AES自动解密消息体;\n' + '3. HTTP Header 展示增加文本和表格切换;\n' + '4. 增加 Request Param 列表展示;\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' diff --git a/lib/ui/mobile/menu/bottom_navigation.dart b/lib/ui/mobile/menu/bottom_navigation.dart index 2ceee74..c2151ed 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.withValues(alpha: 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 cc4098b..f88d3fe 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/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/request_crypto.dart b/lib/ui/mobile/setting/request_crypto.dart new file mode 100644 index 0000000..dfa52a5 --- /dev/null +++ b/lib/ui/mobile/setting/request_crypto.dart @@ -0,0 +1,786 @@ +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( + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(10), + ), + 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( + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(10), + ), + 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, + 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: 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 e175e57..f9c37dd 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 46d44bf..0b7bb26 100644 --- a/lib/ui/mobile/setting/request_rewrite.dart +++ b/lib/ui/mobile/setting/request_rewrite.dart @@ -172,7 +172,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))), diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart index 148697d..82dc31d 100644 --- a/lib/ui/mobile/setting/script.dart +++ b/lib/ui/mobile/setting/script.dart @@ -652,7 +652,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))), From 7261b46559477460c46e3f4939fa5da99478fe39 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sat, 10 Jan 2026 08:09:15 +0800 Subject: [PATCH 09/20] Update dark theme colors for improved visibility and aesthetics (#656) --- lib/ui/component/json/json_viewer.dart | 2 +- lib/ui/component/json/theme.dart | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) 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, ); From 4beef9a3605f6a1564e02a45cff20b7064489f4f Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Mon, 12 Jan 2026 08:58:43 +0800 Subject: [PATCH 10/20] Add option to include system apps in installed apps retrieval (#549) --- .../proxy/plugin/InstalledAppsPlugin.kt | 36 ++++-- lib/native/installed_apps.dart | 14 ++- lib/network/bin/configuration.dart | 2 +- lib/ui/desktop/desktop.dart | 2 + lib/ui/mobile/setting/app_filter.dart | 116 ++++++++++++------ lib/utils/task.dart | 51 ++++++++ 6 files changed, 168 insertions(+), 53 deletions(-) create mode 100644 lib/utils/task.dart 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/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/ui/desktop/desktop.dart b/lib/ui/desktop/desktop.dart index 0eaedf6..b2ed50c 100644 --- a/lib/ui/desktop/desktop.dart +++ b/lib/ui/desktop/desktop.dart @@ -179,6 +179,8 @@ class _DesktopHomePagePageState extends State implements EventL '2. 增加请求解密,可配置AES自动解密消息体;\n' '3. HTTP Header 展示增加文本和表格切换;\n' '4. 增加 Request Param 列表展示;\n' + '5. 应用过滤列表增加是否显示系统应用;\n' + '6. 更新JSON深色主题色,以提高可见度和美观度;\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' 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/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(); + } +} From b1220e18ef30e8dc0cb096e0c34426d82aabc98a Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Wed, 14 Jan 2026 23:49:07 +0800 Subject: [PATCH 11/20] macos set system proxy with osascript privileges --- lib/network/util/system_proxy.dart | 74 ++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 23 deletions(-) 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(' && '); } } From 570912ab246a351fb5b1d67ace36a9c752d0948e Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Fri, 16 Jan 2026 09:30:21 +0800 Subject: [PATCH 12/20] Add support for remote scripts --- .../components/manager/script_manager.dart | 98 ++++++-- lib/ui/desktop/request/request.dart | 6 + lib/ui/desktop/setting/script.dart | 217 ++++++++++++++---- lib/ui/desktop/setting/setting.dart | 2 +- lib/ui/mobile/request/request.dart | 8 +- test/pk12_test.dart | 6 + 6 files changed, 277 insertions(+), 60 deletions(-) diff --git a/lib/network/components/manager/script_manager.dart b/lib/network/components/manager/script_manager.dart index bbece23..fef6368 100644 --- a/lib/network/components/manager/script_manager.dart +++ b/lib/network/components/manager/script_manager.dart @@ -24,6 +24,7 @@ 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 +33,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 +46,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); @@ -164,18 +162,57 @@ 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 { + // Remote script: script is treated as initial cache (optional) + if (item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty) { + list.add(item); + return; + } + final path = await homePath(); String scriptPath = "${separator}scripts$separator${RandomUtil.randomString(16)}.js"; var file = File(path + scriptPath); @@ -188,9 +225,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 +243,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 +285,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 +317,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 +373,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 +399,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 +414,14 @@ class ScriptItem { 'enabled': enabled, 'name': name, 'url': urls.length == 1 ? urls[0] : urls, - 'scriptPath': scriptPath + 'scriptPath': scriptPath, + // remote + 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/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/script.dart b/lib/ui/desktop/setting/script.dart index 06bec55..e49afb2 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("Remote URL ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top); + return; + } + + final uri = Uri.tryParse(remoteUrl); + if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https')) { + FlutterToastr.show("Remote URL invalid", 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) { + FlutterToastr.show("Fetched", context, position: FlutterToastr.top); + 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 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(); } @@ -363,11 +429,12 @@ class _ScriptEditState extends State { Widget build(BuildContext context) { GlobalKey formKey = GlobalKey(); bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh'); + final showRemoteUrl = _useRemote; 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 +450,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 +466,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("Remote URL ${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 +508,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 +517,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 +566,6 @@ class _ScriptEditState extends State { }), ]))) ]))), - const SizedBox(height: 10), // Script section Card( @@ -498,10 +575,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: const [ + DropdownMenuItem(value: false, child: Text('Local')), + DropdownMenuItem(value: true, child: Text('Remote URL')), + ], + 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 (showRemoteUrl) ...[ + 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), + ) + : const Text('Fetch'), + )), + ], + const Spacer(), Tooltip( message: localizations.copy, @@ -512,36 +651,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 +671,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 +755,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 +774,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 +817,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))), ], ))); }); @@ -805,7 +931,10 @@ class _ScriptListState extends State { var item = widget.scripts[idx]; var map = item.toJson(); map.remove("scriptPath"); - map['script'] = await scriptManager.getScript(item); + // For remote scripts, embed cached content too. + map['script'] = await scriptManager.getScript(item).onError((e, t) { + return ''; + }); json.add(map); } diff --git a/lib/ui/desktop/setting/setting.dart b/lib/ui/desktop/setting/setting.dart index 377e1e5..93dddcc 100644 --- a/lib/ui/desktop/setting/setting.dart +++ b/lib/ui/desktop/setting/setting.dart @@ -77,7 +77,7 @@ class _SettingState extends State { item(localizations.requestMap, onPressed: requestMap), item(localizations.requestCrypto, onPressed: showRequestCrypto), item(localizations.script, - onPressed: () => MultiWindow.openWindow(localizations.script, 'ScriptWidget', size: const Size(800, 730))), + onPressed: () => MultiWindow.openWindow(localizations.script, 'ScriptWidget', size: const Size(800, 780))), item(localizations.externalProxy, onPressed: setExternalProxy), item(localizations.about, onPressed: showAbout), ], 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/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'); From 0012116fa06a1bfe808a0143bd383115417051d7 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sat, 17 Jan 2026 07:07:38 +0800 Subject: [PATCH 13/20] Add localization support for remote script management --- lib/l10n/app_en.arb | 6 +++++- lib/l10n/app_localizations.dart | 18 ++++++++++++++++++ lib/l10n/app_localizations_en.dart | 9 +++++++++ lib/l10n/app_localizations_zh.dart | 18 ++++++++++++++++++ lib/l10n/app_zh.arb | 6 +++++- lib/l10n/app_zh_Hant.arb | 6 +++++- .../components/manager/script_manager.dart | 3 ++- lib/network/util/cache.dart | 4 ++++ lib/ui/desktop/setting/script.dart | 16 ++++++++-------- 9 files changed, 74 insertions(+), 12 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 29bd6c5..1b842bc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -366,5 +366,9 @@ "cryptoRuleField": "Field Name", "cryptoIvPrefixLabel": "IV Prefix", - "cryptoIvPrefixTooltip": "Use the first N bytes of the response body as IV" + "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 c5bc9b5..34f917e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2135,6 +2135,24 @@ abstract class AppLocalizations { /// 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 6d46b2e..208de56 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1058,4 +1058,13 @@ class AppLocalizationsEn extends AppLocalizations { @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 734ce69..5e07ef9 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1045,6 +1045,15 @@ class AppLocalizationsZh extends AppLocalizations { @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`). @@ -2089,4 +2098,13 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @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 88855fa..ade3608 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -366,5 +366,9 @@ "cryptoRuleField": "字段名称", "cryptoIvPrefixLabel": "IV 前缀", - "cryptoIvPrefixTooltip": "使用响应体前 N 个字节作为 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 c87555d..eb059a4 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -343,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/network/components/manager/script_manager.dart b/lib/network/components/manager/script_manager.dart index fef6368..853df7d 100644 --- a/lib/network/components/manager/script_manager.dart +++ b/lib/network/components/manager/script_manager.dart @@ -20,6 +20,7 @@ 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'; @@ -60,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; 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/ui/desktop/setting/script.dart b/lib/ui/desktop/setting/script.dart index e49afb2..f8d7a76 100644 --- a/lib/ui/desktop/setting/script.dart +++ b/lib/ui/desktop/setting/script.dart @@ -362,13 +362,13 @@ class _ScriptEditState extends State { if (_fetchingRemoteScript.value) return; final remoteUrl = remoteUrlController.text.trim(); if (remoteUrl.isEmpty) { - FlutterToastr.show("Remote URL ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top); + 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("Remote URL invalid", context, position: FlutterToastr.top); + FlutterToastr.show("${localizations.remoteUrl} ${localizations.fail}", context, position: FlutterToastr.top); return; } @@ -381,7 +381,6 @@ class _ScriptEditState extends State { } script.text = resp.body; if (mounted) { - FlutterToastr.show("Fetched", context, position: FlutterToastr.top); setState(() {}); } } catch (e) { @@ -395,6 +394,7 @@ class _ScriptEditState extends State { void _resetScript() { script.text = ScriptManager.template; + script.text = ScriptManager.template; } @override @@ -471,7 +471,7 @@ class _ScriptEditState extends State { final remoteUrl = _useRemote ? remoteUrlController.text.trim() : ''; final hasRemote = remoteUrl.isNotEmpty; if (_useRemote && !hasRemote) { - FlutterToastr.show("Remote URL ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top); + FlutterToastr.show("${localizations.remoteUrl} ${localizations.cannotBeEmpty}", context, position: FlutterToastr.top); return; } @@ -585,9 +585,9 @@ class _ScriptEditState extends State { height: 34, child: DropdownButtonFormField( initialValue: _useRemote, - items: const [ - DropdownMenuItem(value: false, child: Text('Local')), - DropdownMenuItem(value: true, child: Text('Remote URL')), + items: [ + DropdownMenuItem(value: false, child: Text(localizations.local)), + DropdownMenuItem(value: true, child: Text(localizations.remoteUrl)), ], onChanged: (val) { if (val == null) return; @@ -637,7 +637,7 @@ class _ScriptEditState extends State { height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Fetch'), + : Text(localizations.view), )), ], From c181767878dd2c3769a03a93d622ddac9a6ebd11 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Mon, 19 Jan 2026 09:19:03 +0800 Subject: [PATCH 14/20] Enhance script management with remote URL support and improved logging (#490) --- .../components/manager/script_manager.dart | 9 +- lib/ui/desktop/setting/script.dart | 21 +- lib/ui/mobile/setting/request_crypto.dart | 10 +- lib/ui/mobile/setting/script.dart | 272 ++++++++++++++---- pubspec.yaml | 2 +- 5 files changed, 246 insertions(+), 68 deletions(-) diff --git a/lib/network/components/manager/script_manager.dart b/lib/network/components/manager/script_manager.dart index 853df7d..9622ef8 100644 --- a/lib/network/components/manager/script_manager.dart +++ b/lib/network/components/manager/script_manager.dart @@ -97,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) { @@ -207,13 +210,14 @@ async function onResponse(context, request, response) { } ///添加脚本 - 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); @@ -416,7 +420,6 @@ class ScriptItem { 'name': name, 'url': urls.length == 1 ? urls[0] : urls, 'scriptPath': scriptPath, - // remote if (remoteUrl != null) 'remoteUrl': remoteUrl, }; } diff --git a/lib/ui/desktop/setting/script.dart b/lib/ui/desktop/setting/script.dart index f8d7a76..b90b098 100644 --- a/lib/ui/desktop/setting/script.dart +++ b/lib/ui/desktop/setting/script.dart @@ -429,7 +429,6 @@ class _ScriptEditState extends State { Widget build(BuildContext context) { GlobalKey formKey = GlobalKey(); bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh'); - final showRemoteUrl = _useRemote; return AlertDialog( scrollable: true, @@ -605,7 +604,7 @@ class _ScriptEditState extends State { ), // Put Remote URL right after type selector. - if (showRemoteUrl) ...[ + if (_useRemote) ...[ const SizedBox(width: 10), Expanded( flex: 6, @@ -894,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; } @@ -931,10 +937,11 @@ class _ScriptListState extends State { var item = widget.scripts[idx]; var map = item.toJson(); map.remove("scriptPath"); - // For remote scripts, embed cached content too. - map['script'] = await scriptManager.getScript(item).onError((e, t) { - return ''; - }); + + if (item.remoteUrl != null && item.remoteUrl!.trim().isNotEmpty) { + map['script'] = await scriptManager.getScript(item); + } + json.add(map); } diff --git a/lib/ui/mobile/setting/request_crypto.dart b/lib/ui/mobile/setting/request_crypto.dart index dfa52a5..1833f78 100644 --- a/lib/ui/mobile/setting/request_crypto.dart +++ b/lib/ui/mobile/setting/request_crypto.dart @@ -502,10 +502,11 @@ class _MobileCryptoRuleEditPageState extends State { 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.withValues(alpha: 0.25)), - borderRadius: BorderRadius.circular(10), + side: BorderSide(color: Theme.of(context).dividerColor.withAlpha((0.2 * 255).round())), + borderRadius: BorderRadius.circular(8), ), child: Padding( padding: const EdgeInsets.all(12), @@ -543,10 +544,11 @@ class _MobileCryptoRuleEditPageState extends State { ), 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.withValues(alpha: 0.25)), - borderRadius: BorderRadius.circular(10), + side: BorderSide(color: Theme.of(context).dividerColor.withAlpha((0.2 * 255).round())), + borderRadius: BorderRadius.circular(8), ), child: Padding( padding: const EdgeInsets.all(12), diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart index 82dc31d..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, @@ -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/pubspec.yaml b/pubspec.yaml index d431299..3f7feb9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: ref: secure-keyboard flutter_highlight: ^0.7.0 flutter_desktop_context_menu: ^0.2.0 - device_info_plus: ^11.5.0 + device_info_plus: ^12.3.0 shared_preferences: ^2.5.4 url_launcher: ^6.3.2 toastification: ^3.0.2 From ed6942eabd6b21dda1579d4c214f5aedd7c1a91a Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Wed, 21 Jan 2026 16:14:56 +0800 Subject: [PATCH 15/20] 1.2.3-beta --- lib/ui/configuration.dart | 8 ++++---- lib/ui/desktop/desktop.dart | 35 ++++++++++++++++++----------------- lib/ui/mobile/mobile.dart | 34 ++++++++++++++++------------------ linux/build.sh | 2 +- pubspec.yaml | 4 ++-- 5 files changed, 41 insertions(+), 42 deletions(-) diff --git a/lib/ui/configuration.dart b/lib/ui/configuration.dart index bc5ba7a..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); @@ -199,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( @@ -251,7 +251,7 @@ class AppConfiguration { 'mode': _theme.mode.name, 'themeColor': _theme.color, 'useMaterial3': _theme.useMaterial3, - 'upgradeNoticeV23': upgradeNoticeV23, + 'upgradeNoticeV24': upgradeNoticeV24, "language": _language?.languageCode, "languageScript": _language?.scriptCode, "headerExpanded": headerExpanded, diff --git a/lib/ui/desktop/desktop.dart b/lib/ui/desktop/desktop.dart index b2ed50c..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); }, @@ -177,20 +178,20 @@ class _DesktopHomePagePageState extends State implements EventL '点击HTTPS抓包(加锁图标),选择安装根证书,按照提示操作即可。\n\n' '1. 增加收藏导出和导入;\n' '2. 增加请求解密,可配置AES自动解密消息体;\n' - '3. HTTP Header 展示增加文本和表格切换;\n' - '4. 增加 Request Param 列表展示;\n' - '5. 应用过滤列表增加是否显示系统应用;\n' - '6. 更新JSON深色主题色,以提高可见度和美观度;\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', + '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/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index fd693e5..9eedf27 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/linux/build.sh b/linux/build.sh index d30e1ed..f29a670 100644 --- a/linux/build.sh +++ b/linux/build.sh @@ -5,7 +5,7 @@ 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 3f7feb9..816ddc4 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.4+26 +version: 1.2.4+27 environment: sdk: '>=3.0.2 <4.0.0' @@ -45,7 +45,7 @@ dependencies: flutter_qr_reader_plus: ^1.0.6 brotli: ^0.6.0 # macos_window_utils: 1.6.1 - win32audio: ^1.5.0 + win32audio: ^1.3.1 vclibs: ^0.1.3 scrollable_positioned_list_nic: ^0.0.2 From 7008072a70de893aec46131afaea8061f1f4f3c1 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Wed, 21 Jan 2026 17:27:16 +0800 Subject: [PATCH 16/20] Refactor UI components for improved layout and consistency --- lib/ui/desktop/setting/request_crypto.dart | 4 +- lib/ui/mobile/setting/request_crypto.dart | 43 +++++++++------------- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/lib/ui/desktop/setting/request_crypto.dart b/lib/ui/desktop/setting/request_crypto.dart index 9df2293..fa21a11 100644 --- a/lib/ui/desktop/setting/request_crypto.dart +++ b/lib/ui/desktop/setting/request_crypto.dart @@ -628,6 +628,7 @@ class _CryptoRuleDialogState extends State { 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())), @@ -647,7 +648,6 @@ class _CryptoRuleDialogState extends State { ), ), const SizedBox(width: 12), - Expanded( child: SizedBox( child: TextFormField( @@ -658,7 +658,6 @@ class _CryptoRuleDialogState extends State { ), ), ), - ]), const SizedBox(height: 12), // Compact single-line IV controls for CBC @@ -666,6 +665,7 @@ class _CryptoRuleDialogState extends State { 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())), diff --git a/lib/ui/mobile/setting/request_crypto.dart b/lib/ui/mobile/setting/request_crypto.dart index 1833f78..f65148b 100644 --- a/lib/ui/mobile/setting/request_crypto.dart +++ b/lib/ui/mobile/setting/request_crypto.dart @@ -172,10 +172,8 @@ class _MobileRequestCryptoPageState extends State { })), const SizedBox(width: 20), Expanded( - child: Text( - rule.urlPattern.isEmpty ? l10n.emptyMatchAll : rule.urlPattern, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 13))), + child: Text(rule.urlPattern.isEmpty ? l10n.emptyMatchAll : rule.urlPattern, + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13))), ]))); })) ])), @@ -214,9 +212,7 @@ class _MobileRequestCryptoPageState extends State { icon: const Icon(Icons.share, size: 18), label: Text(l10n.export, style: const TextStyle(fontSize: 14))), TextButton.icon( - onPressed: selected.isEmpty - ? null - : () => _removeSelected(), + onPressed: selected.isEmpty ? null : () => _removeSelected(), icon: const Icon(Icons.delete, size: 18), label: Text(l10n.delete, style: const TextStyle(fontSize: 14))), TextButton.icon( @@ -233,9 +229,7 @@ class _MobileRequestCryptoPageState extends State { } Future _addRule(RequestCryptoManager manager) async { - Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const MobileCryptoRuleEditPage())) - .then((value) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const MobileCryptoRuleEditPage())).then((value) { if (value != null && mounted) { setState(() {}); _refreshConfig(force: true); @@ -245,9 +239,7 @@ class _MobileRequestCryptoPageState extends State { Future _editRule(RequestCryptoManager manager, int index) async { final rule = manager.rules[index]; - Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => MobileCryptoRuleEditPage(rule: rule))) - .then((value) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => MobileCryptoRuleEditPage(rule: rule))).then((value) { if (value != null && mounted) { setState(() {}); _refreshConfig(force: true); @@ -348,7 +340,8 @@ class _MobileRequestCryptoPageState extends State { Future _import(RequestCryptoManager manager) async { try { - FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['json']); + 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(); @@ -385,7 +378,6 @@ class _MobileRequestCryptoPageState extends State { } } - /// Mobile editor page for a single crypto rule. /// /// This mirrors the mobile rewrite editor pattern: push to a page, edit, and save. @@ -635,12 +627,12 @@ class _MobileCryptoRuleEditPageState extends State { Expanded( child: ivSource == 'manual' ? TextFormField( - controller: ivController, - decoration: _decorate('IV'), - validator: (val) => (ivSource == 'manual' && (val == null || val.trim().isEmpty)) - ? l10n.cannotBeEmpty - : null, - ) + controller: ivController, + decoration: _decorate('IV'), + validator: (val) => (ivSource == 'manual' && (val == null || val.trim().isEmpty)) + ? l10n.cannotBeEmpty + : null, + ) : _ivPrefixLengthEditor(), ), ], @@ -691,11 +683,11 @@ class _MobileCryptoRuleEditPageState extends State { }) { return Container( height: 40, - padding: const EdgeInsets.symmetric(horizontal: 10), + 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(8), - ), + border: Border.all(color: Theme.of(context).dividerColor.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(6)), child: DropdownButtonHideUnderline( child: DropdownButton( value: value, @@ -785,4 +777,3 @@ class _MobileCryptoRuleEditPageState extends State { Navigator.of(context).pop(updated); } } - From 74b0c5e07722b4d1f58dc1e79bc1bc956518bfe3 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Thu, 22 Jan 2026 12:55:07 +0800 Subject: [PATCH 17/20] Refactor request rewrite and SSL widget state management for improved clarity and functionality (#679) --- lib/ui/mobile/setting/request_rewrite.dart | 21 +++++++-------------- lib/ui/mobile/setting/ssl.dart | 10 ++-------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/lib/ui/mobile/setting/request_rewrite.dart b/lib/ui/mobile/setting/request_rewrite.dart index 0b7bb26..d1e0cf4 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: [ diff --git a/lib/ui/mobile/setting/ssl.dart b/lib/ui/mobile/setting/ssl.dart index 17ba87b..407e87a 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), From 5ff6996d3f851e380ed4829a97ba7c2532fe3647 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Thu, 22 Jan 2026 14:14:39 +0800 Subject: [PATCH 18/20] Fix null handling for response and request body content in HAR data structure (#678) --- lib/storage/histories.dart | 2 +- lib/utils/har.dart | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/utils/har.dart b/lib/utils/har.dart index c98f1b5..d988f88 100644 --- a/lib/utils/har.dart +++ b/lib/utils/har.dart @@ -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!), // 请求体内容 }; } From 3abe006e22f3aa4841592c639c638dc84536e25b Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Fri, 23 Jan 2026 14:49:51 +0800 Subject: [PATCH 19/20] Fix null handling for response and request body content in HAR data structure (#678) --- lib/utils/har.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/har.dart b/lib/utils/har.dart index d988f88..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(), From 20593ad6a0fd4f09e55db86794c99412e3254e00 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sun, 25 Jan 2026 21:54:09 +0800 Subject: [PATCH 20/20] Add request decryption feature to README documentation --- README.md | 7 ++++--- README_CN.md | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) 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格式导出与导入。 * 其他:收藏、工具箱、常用编码工具、以及二维码、正则等