From c7061748948d03cced960dac0ca2ac299d076809 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Sun, 22 Mar 2026 01:43:24 +0800 Subject: [PATCH] add export functionality for domain HAR files (#725) --- lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 ++++ lib/l10n/app_localizations_en.dart | 3 ++ lib/l10n/app_localizations_zh.dart | 6 ++++ lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hant.arb | 1 + lib/storage/histories.dart | 2 +- lib/ui/desktop/request/domians.dart | 56 +++++++++++++++++++++++++++-- lib/ui/mobile/request/domians.dart | 38 ++++++++++++++++++++ 9 files changed, 111 insertions(+), 3 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ab5b441..996f238 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -168,6 +168,7 @@ "editRequest": "Edit and Request", "reSendRequest": "The request has been resent", "viewExport": "View Export", + "exportDomainHar": "Export This Domain HAR", "timeDesc": "Descending by time", "timeAsc": "Ascending by time", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 777b2ea..e663104 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1068,6 +1068,12 @@ abstract class AppLocalizations { /// **'View Export'** String get viewExport; + /// No description provided for @exportDomainHar. + /// + /// In en, this message translates to: + /// **'Export This Domain HAR'** + String get exportDomainHar; + /// No description provided for @timeDesc. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 022a4d9..605493f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -499,6 +499,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get viewExport => 'View Export'; + @override + String get exportDomainHar => 'Export This Domain HAR'; + @override String get timeDesc => 'Descending by time'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4612a28..4f05119 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -498,6 +498,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get viewExport => '视图导出'; + @override + String get exportDomainHar => '导出该域名 HAR'; + @override String get timeDesc => '按时间降序'; @@ -1562,6 +1565,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get viewExport => '檢視匯出'; + @override + String get exportDomainHar => '匯出該網域名稱 HAR'; + @override String get timeDesc => '按時間降序'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9b25072..26cd8d0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -169,6 +169,7 @@ "editRequest": "编辑请求", "reSendRequest": "已重新发送请求", "viewExport": "视图导出", + "exportDomainHar": "导出该域名 HAR", "timeDesc": "按时间降序", "timeAsc": "按时间升序", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index ff7cb68..bcdc5ab 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -161,6 +161,7 @@ "editRequest": "編輯請求", "reSendRequest": "已重新傳送請求", "viewExport": "檢視匯出", + "exportDomainHar": "匯出該網域名稱 HAR", "timeDesc": "按時間降序", "timeAsc": "按時間升序", "search": "搜尋", diff --git a/lib/storage/histories.dart b/lib/storage/histories.dart index 066ef15..5678fc9 100644 --- a/lib/storage/histories.dart +++ b/lib/storage/histories.dart @@ -78,7 +78,7 @@ class HistoryStorage { return _histories.source; } - addListener(ListenerListEvent listener) async { + void addListener(ListenerListEvent listener) { _histories.addListener(listener); } diff --git a/lib/ui/desktop/request/domians.dart b/lib/ui/desktop/request/domians.dart index 32e7dcc..826a9fd 100644 --- a/lib/ui/desktop/request/domians.dart +++ b/lib/ui/desktop/request/domians.dart @@ -17,6 +17,7 @@ import 'dart:collection'; import 'dart:io'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_desktop_context_menu/flutter_desktop_context_menu.dart'; @@ -34,7 +35,9 @@ import 'package:proxypin/ui/component/transition.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/content/panel.dart'; import 'package:proxypin/ui/desktop/request/request.dart'; +import 'package:proxypin/utils/har.dart'; import 'package:proxypin/utils/keyword_highlight.dart'; +import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/listenable_list.dart'; import '../../component/model/search_model.dart'; @@ -79,6 +82,8 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM bool sortDesc = true; + AppLocalizations get localizations => AppLocalizations.of(context)!; + void changeState() { if (!changing) { changing = true; @@ -198,6 +203,7 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM proxyServer: widget.proxyServer, trailing: appIcon(request), onDelete: deleteHost, + onExportHar: exportDomainHar, onRequestRemove: (req) { widget.onRemove?.call([req]); changeState(); @@ -281,8 +287,41 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM return container.expand((list) => list.body.map((it) => it.request)).toList(); } + Future exportDomainHar(String domain) async { + var requests = containerMap[domain]?.body.map((it) => it.request).toList() ?? []; + if (requests.isEmpty) { + if (mounted) FlutterToastr.show(localizations.emptyData, context); + return; + } + + var fileName = _domainHarFileName(domain); + var path = await FilePicker.platform.saveFile(fileName: fileName); + if (path == null) { + return; + } + + try { + var file = await File(path).create(recursive: true); + await Har.writeFile(requests, file, title: fileName); + if (mounted) FlutterToastr.show(localizations.exportSuccess, context); + } catch (e) { + if (mounted) FlutterToastr.show('${localizations.exportFailed} $e', context); + } + } + + String _domainHarFileName(String domain) { + var uri = Uri.tryParse(domain); + var host = (uri?.host.isNotEmpty == true) ? uri!.host : domain; + var suffix = uri?.hasPort == true ? '_${uri!.port}' : ''; + var safeDomain = '$host$suffix'.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); + if (safeDomain.isEmpty) { + safeDomain = 'domain'; + } + return 'ProxyPin_${safeDomain}_${DateTime.now().dateFormat()}.har'; + } + ///排序 - sort(bool desc) { + void sort(bool desc) { sortDesc = desc; containerMap.forEach((key, request) { var reversed = request.body.toList().reversed; @@ -310,10 +349,16 @@ class DomainRequests extends StatefulWidget { //移除回调 final Function(String host)? onDelete; + final Function(String host)? onExportHar; final Function(HttpRequest request)? onRequestRemove; DomainRequests(this.domain, - {this.selected = false, this.onDelete, required this.proxyServer, this.onRequestRemove, this.trailing}) + {this.selected = false, + this.onDelete, + this.onExportHar, + required this.proxyServer, + this.onRequestRemove, + this.trailing}) : super(key: GlobalKey<_DomainRequestsState>()); ///添加请求 @@ -368,6 +413,7 @@ class DomainRequests extends StatefulWidget { trailing: trailing, selected: selected ?? state.currentState?.selected == true, onDelete: onDelete, + onExportHar: onExportHar, onRequestRemove: onRequestRemove, proxyServer: proxyServer); if (body != null) { @@ -479,6 +525,8 @@ class _DomainRequestsState extends State { submenu: hostFilterMenu(), ), MenuItem.separator(), + MenuItem(label: localizations.exportDomainHar, onClick: (_) => exportDomainHar()), + MenuItem.separator(), MenuItem(label: localizations.repeatDomainRequests, onClick: (_) => repeatDomainRequests()), MenuItem.separator(), MenuItem(label: localizations.delete, onClick: (_) => _delete()), @@ -502,6 +550,10 @@ class _DomainRequestsState extends State { } } + void exportDomainHar() { + widget.onExportHar?.call(widget.domain); + } + Menu hostFilterMenu() { return Menu(items: [ MenuItem( diff --git a/lib/ui/mobile/request/domians.dart b/lib/ui/mobile/request/domians.dart index c405d6f..0283d80 100644 --- a/lib/ui/mobile/request/domians.dart +++ b/lib/ui/mobile/request/domians.dart @@ -15,8 +15,10 @@ */ import 'dart:collection'; +import 'dart:convert'; import 'package:date_format/date_format.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:proxypin/l10n/app_localizations.dart'; @@ -29,6 +31,8 @@ import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/http/http_client.dart'; import 'package:proxypin/ui/component/widgets.dart'; import 'package:proxypin/ui/mobile/request/request_sequence.dart'; +import 'package:proxypin/utils/har.dart'; +import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/listenable_list.dart'; ///域名列表 @@ -297,6 +301,12 @@ class DomainListState extends State with AutomaticKeepAliveClientMix repeatDomainRequests(hostAndPort); }), const Divider(thickness: 0.5, height: 5), + BottomSheetItem( + text: localizations.exportDomainHar, + onPressed: () { + exportDomainHar(hostAndPort); + }), + const Divider(thickness: 0.5, height: 5), BottomSheetItem( text: localizations.delete, onPressed: () { @@ -345,4 +355,32 @@ class DomainListState extends State with AutomaticKeepAliveClientMix } } } + + Future exportDomainHar(HostAndPort hostAndPort) async { + var requests = containerMap[hostAndPort] ?? []; + if (requests.isEmpty) { + if (mounted) FlutterToastr.show(localizations.emptyData, context); + return; + } + + var fileName = _domainHarFileName(hostAndPort); + var json = await Har.writeJson(requests, title: fileName); + var bytes = utf8.encode(json); + + var path = await FilePicker.platform.saveFile(fileName: fileName, bytes: bytes); + if (path == null) { + return; + } + + if (mounted) FlutterToastr.show(localizations.exportSuccess, context); + } + + String _domainHarFileName(HostAndPort hostAndPort) { + var suffix = (hostAndPort.port == 80 || hostAndPort.port == 443) ? '' : '_${hostAndPort.port}'; + var safeDomain = '${hostAndPort.host}$suffix'.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); + if (safeDomain.isEmpty) { + safeDomain = 'domain'; + } + return 'ProxyPin_${safeDomain}_${DateTime.now().dateFormat()}.har'; + } }