diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 832b94e..255d0a7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -254,6 +254,10 @@ "headerExpandedSubtitle": "Details page Headers is expanded by default", "bottomNavigation": "Bottom Navigation", "bottomNavigationSubtitle": "Bottom navigation bar is displayed, effective after restart", + "memoryCleanup": "Memory Cleanup", + "memoryCleanupSubtitle": "Automatically clean up requests on memory limit reached and keep 32 most recent after cleaning", + "unlimited": "Unlimited", + "custom": "Custom", "externalProxyAuth": "Proxy Auth (Optional)", "externalProxyServer": "Proxy Server", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a59998a..6bf7b73 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -254,6 +254,10 @@ "headerExpandedSubtitle": "详情页Headers栏是否自动展开", "bottomNavigation": "底部导航", "bottomNavigationSubtitle": "底部导航栏是否显示,重启后生效", + "memoryCleanup": "内存清理", + "memoryCleanupSubtitle": "到内存限制自动清理请求,清理后保留最近32条请求", + "unlimited": "无限制", + "custom": "自定义", "externalProxyAuth": "代理认证 (可选)", "externalProxyServer": "代理服务器", diff --git a/lib/network/bin/configuration.dart b/lib/network/bin/configuration.dart index 895e380..842974b 100644 --- a/lib/network/bin/configuration.dart +++ b/lib/network/bin/configuration.dart @@ -113,7 +113,7 @@ class Configuration { HostFilter.whitelist.toJson(); HostFilter.blacklist.toJson(); var json = jsonEncode(toJson()); - logger.i('刷新配置文件 $runtimeType ${toJson()}'); + logger.d('Refresh configuration file $runtimeType ${toJson()}'); file.writeAsString(json); } diff --git a/lib/network/http/http_parser.dart b/lib/network/http/http_parser.dart index 50a67ba..4d035bf 100644 --- a/lib/network/http/http_parser.dart +++ b/lib/network/http/http_parser.dart @@ -57,10 +57,12 @@ class HttpParse { return false; } - if (data.length > defaultMaxLength) { - throw Exception("header too long"); - } + int startIndex = data.readerIndex; for (int i = data.readerIndex; i < data.length; i++) { + if ((i - startIndex) > defaultMaxLength) { + throw Exception("header too long"); + } + if (_isLineEnd(data, i)) { Uint8List line = data.readBytes(i - data.readerIndex); data.skipBytes(2); diff --git a/lib/storage/histories.dart b/lib/storage/histories.dart index 89dbf10..7384af9 100644 --- a/lib/storage/histories.dart +++ b/lib/storage/histories.dart @@ -243,6 +243,7 @@ class HistoryTask extends ListenerListEvent { resetList() async { locked = true; + await open?.lock().timeout(Duration(seconds: 3), onTimeout: () => open!.unlock()); open = await open?.truncate(0); await open?.setPosition(0); history?.requestLength = 0; @@ -250,6 +251,7 @@ class HistoryTask extends ListenerListEvent { writeList.clear(); writeList.addAll(sourceList.source); locked = false; + open?.unlock(); } cancelTask() { diff --git a/lib/ui/component/memory_cleanup.dart b/lib/ui/component/memory_cleanup.dart new file mode 100644 index 0000000..d50ab41 --- /dev/null +++ b/lib/ui/component/memory_cleanup.dart @@ -0,0 +1,50 @@ +/* + * Copyright 2024 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 'dart:io'; + +import 'package:network_proxy/network/util/logger.dart'; +import 'package:network_proxy/ui/configuration.dart'; + +/// Memory cleanup handle +/// @author wanghongen + +class MemoryCleanupMonitor { + static bool _processing = false; + + static void onMonitor({Function? onCleanup}) { + var threshold = AppConfiguration.current?.memoryCleanupThreshold; + if (threshold == null || threshold <= 0) { + return; + } + + if (_processing) return; + _processing = true; + Future.delayed(const Duration(seconds: 3), () { + _processing = false; + _cleanup(threshold, onCleanup); + }); + } + + static void _cleanup(int threshold, Function? onCleanup) { + final memory = ProcessInfo.currentRss / 1024 / 1024; + logger.d('Memory cleanup, current memory: ${memory.toInt()}M, threshold: ${threshold}M'); + if (memory > threshold) { + onCleanup?.call(); + logger.i('Memory cleanup, current memory: ${memory.toInt()}M, threshold: ${threshold}M, cleanup'); + } + } +} diff --git a/lib/ui/configuration.dart b/lib/ui/configuration.dart index 5dab45b..4dc021f 100644 --- a/lib/ui/configuration.dart +++ b/lib/ui/configuration.dart @@ -74,6 +74,9 @@ class AppConfiguration { /// 底部导航栏 bool bottomNavigation = true; + /// 内存清理 + int? memoryCleanupThreshold; + //桌面window大小 Size? windowSize; @@ -180,6 +183,7 @@ class AppConfiguration { pipIcon.value = config['pipIcon'] ?? false; headerExpanded = config['headerExpanded'] ?? true; bottomNavigation = config['bottomNavigation'] ?? true; + memoryCleanupThreshold = config['memoryCleanupThreshold']; windowSize = config['windowSize'] == null ? null : Size(config['windowSize']['width'], config['windowSize']['height']); @@ -222,6 +226,8 @@ class AppConfiguration { "language": _language?.languageCode, "headerExpanded": headerExpanded, + if (memoryCleanupThreshold != null) 'memoryCleanupThreshold': memoryCleanupThreshold, + if (Platforms.isMobile()) 'pipEnabled': pipEnabled.value, if (Platforms.isMobile()) 'pipIcon': pipIcon.value ? true : null, if (Platforms.isMobile()) 'bottomNavigation': bottomNavigation, diff --git a/lib/ui/desktop/desktop.dart b/lib/ui/desktop/desktop.dart index efa53f3..bbef47e 100644 --- a/lib/ui/desktop/desktop.dart +++ b/lib/ui/desktop/desktop.dart @@ -22,6 +22,7 @@ import 'package:network_proxy/network/channel.dart'; import 'package:network_proxy/network/handler.dart'; import 'package:network_proxy/network/http/http.dart'; import 'package:network_proxy/network/http/websocket.dart'; +import 'package:network_proxy/ui/component/memory_cleanup.dart'; import 'package:network_proxy/ui/component/toolbox.dart'; import 'package:network_proxy/ui/component/widgets.dart'; import 'package:network_proxy/ui/configuration.dart'; @@ -62,6 +63,11 @@ class _DesktopHomePagePageState extends State implements EventL @override void onRequest(Channel channel, HttpRequest request) { requestListStateKey.currentState!.add(channel, request); + + //监控内存 到达阈值清理 + MemoryCleanupMonitor.onMonitor(onCleanup: () { + requestListStateKey.currentState?.cleanupEarlyData(32); + }); } @override diff --git a/lib/ui/desktop/left_menus/history.dart b/lib/ui/desktop/left_menus/history.dart index e6aa7a5..df8d476 100644 --- a/lib/ui/desktop/left_menus/history.dart +++ b/lib/ui/desktop/left_menus/history.dart @@ -168,6 +168,7 @@ class _HistoryListWidget extends StatefulWidget { class _HistoryListState extends State<_HistoryListWidget> { ///是否保存会话 static bool _sessionSaved = false; + int selectIndex = -1; // 存储 late HistoryStorage storage; @@ -278,40 +279,43 @@ class _HistoryListState extends State<_HistoryListWidget> { //构建历史记录 Widget buildItem(BuildContext rootContext, int index, HistoryItem item) { return GestureDetector( - onSecondaryTapDown: (details) => { - showContextMenu(rootContext, details.globalPosition, items: [ - CustomPopupMenuItem( - height: 35, - child: Text(localizations.rename, style: const TextStyle(fontSize: 13)), - onTap: () => renameHistory(storage, item)), - CustomPopupMenuItem( - height: 35, - child: Text(localizations.export, style: const TextStyle(fontSize: 13)), - onTap: () => export(item)), - const PopupMenuDivider(height: 3), - CustomPopupMenuItem( - height: 35, - child: Text(localizations.repeatAllRequests, style: const TextStyle(fontSize: 13)), - onTap: () async { - var requests = (await storage.getRequests(item)).reversed; - //重发所有请求 - _repeatAllRequests(requests.toList(), proxyServer, - context: rootContext.mounted ? rootContext : null); - }), - const PopupMenuDivider(height: 3), - CustomPopupMenuItem( - height: 35, - child: Text(localizations.delete, style: const TextStyle(fontSize: 13)), - onTap: () { - if (item == widget.historyTask.history) { - widget.historyTask.cancelTask(); - } - storage.removeHistory(index); - FlutterToastr.show(localizations.deleteSuccess, context); - }), - ]) - }, + onSecondaryTapDown: (details) { + setState(() { + selectIndex = index; + }); + showContextMenu(rootContext, details.globalPosition, items: [ + CustomPopupMenuItem( + height: 35, + child: Text(localizations.rename, style: const TextStyle(fontSize: 13)), + onTap: () => renameHistory(storage, item)), + CustomPopupMenuItem( + height: 35, + child: Text(localizations.export, style: const TextStyle(fontSize: 13)), + onTap: () => export(item)), + const PopupMenuDivider(height: 3), + CustomPopupMenuItem( + height: 35, + child: Text(localizations.repeatAllRequests, style: const TextStyle(fontSize: 13)), + onTap: () async { + var requests = (await storage.getRequests(item)).reversed; + //重发所有请求 + _repeatAllRequests(requests.toList(), proxyServer, context: rootContext.mounted ? rootContext : null); + }), + const PopupMenuDivider(height: 3), + CustomPopupMenuItem( + height: 35, + child: Text(localizations.delete, style: const TextStyle(fontSize: 13)), + onTap: () { + if (item == widget.historyTask.history) { + widget.historyTask.cancelTask(); + } + storage.removeHistory(index); + FlutterToastr.show(localizations.deleteSuccess, context); + }), + ]).whenComplete(() => setState(() => selectIndex = -1)); + }, child: ListTile( + selected: selectIndex == index, dense: true, title: Text(item.name), subtitle: Text(localizations.historySubtitle(item.requestLength, item.size)), diff --git a/lib/ui/desktop/preference.dart b/lib/ui/desktop/preference.dart index 2249120..e91eaf7 100644 --- a/lib/ui/desktop/preference.dart +++ b/lib/ui/desktop/preference.dart @@ -14,23 +14,53 @@ * limitations under the License. */ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:network_proxy/network/bin/configuration.dart'; +import 'package:network_proxy/network/util/logger.dart'; import 'package:network_proxy/ui/component/widgets.dart'; import 'package:network_proxy/ui/configuration.dart'; /// @author wanghongen /// 2024/1/2 -class Preference extends StatelessWidget { +class Preference extends StatefulWidget { final Configuration configuration; final AppConfiguration appConfiguration; const Preference(this.appConfiguration, this.configuration, {super.key}); + @override + State createState() => _PreferenceState(); +} + +class _PreferenceState extends State { + late Configuration configuration; + late AppConfiguration appConfiguration; + + final memoryCleanupController = TextEditingController(); + final memoryCleanupList = [null, 512, 1024, 2048, 4096]; + + @override + void initState() { + super.initState(); + configuration = widget.configuration; + appConfiguration = widget.appConfiguration; + if (!memoryCleanupList.contains(appConfiguration.memoryCleanupThreshold)) { + memoryCleanupController.text = appConfiguration.memoryCleanupThreshold.toString(); + } + } + + @override + void dispose() { + memoryCleanupController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { AppLocalizations localizations = AppLocalizations.of(context)!; - var titleMedium = Theme.of(context).textTheme.titleMedium; + var titleStyle = Theme.of(context).textTheme.titleSmall; + var subtitleStyle = TextStyle(fontSize: 12, color: Colors.grey); return AlertDialog( scrollable: true, @@ -41,10 +71,10 @@ class Preference extends StatelessWidget { const Expanded(child: Align(alignment: Alignment.topRight, child: CloseButton())) ]), content: SizedBox( - width: 300, + width: 400, child: Column(children: [ Row(children: [ - SizedBox(width: 100, child: Text("${localizations.language}: ", style: titleMedium)), + SizedBox(width: 100, child: Text("${localizations.language}: ", style: titleStyle)), DropdownButton( value: appConfiguration.language, onChanged: (Locale? value) => appConfiguration.language = value, @@ -57,7 +87,7 @@ class Preference extends StatelessWidget { ]), //主题 Row(children: [ - SizedBox(width: 100, child: Text("${localizations.theme}: ", style: titleMedium)), + SizedBox(width: 100, child: Text("${localizations.theme}: ", style: titleStyle)), DropdownButton( value: appConfiguration.themeMode, onChanged: (ThemeMode? value) => appConfiguration.themeMode = value!, @@ -68,48 +98,33 @@ class Preference extends StatelessWidget { DropdownMenuItem(value: ThemeMode.dark, child: Text(localizations.themeDark)), ]), ]), - Tooltip( message: localizations.material3, child: Row( children: [ - Text("Material3: ", style: titleMedium), - Expanded( - child: Transform.scale( - scale: 0.8, - child: Switch( - value: appConfiguration.useMaterial3, - onChanged: (bool value) => appConfiguration.useMaterial3 = value, - ))) + SizedBox(width: 100, child: Text("Material3: ", style: titleStyle)), + Transform.scale( + scale: 0.75, + child: Switch( + value: appConfiguration.useMaterial3, + onChanged: (bool value) => appConfiguration.useMaterial3 = value, + )) ], )), //主题颜色 Row(children: [ SizedBox( width: 120, - child: Text("${localizations.themeColor}: ", style: titleMedium, textAlign: TextAlign.start)), + child: Text("${localizations.themeColor}: ", style: titleStyle, textAlign: TextAlign.start)), ]), themeColor(context), const Divider(), - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(localizations.autoStartup), - //默认是否启动 - subtitle: Text(localizations.autoStartupDescribe, style: const TextStyle(fontSize: 12)), - trailing: SwitchWidget( - scale: 0.8, - value: configuration.startup, - onChanged: (value) { - configuration.startup = value; - configuration.flushConfig(); - })), - const Divider(), ListTile( contentPadding: EdgeInsets.zero, title: Text(localizations.autoStartup), //默认是否启动 - subtitle: Text(localizations.autoStartupDescribe, style: const TextStyle(fontSize: 12)), + subtitle: Text(localizations.autoStartupDescribe, style: subtitleStyle), trailing: SwitchWidget( - scale: 0.8, + scale: 0.75, value: configuration.startup, onChanged: (value) { configuration.startup = value; @@ -118,17 +133,32 @@ class Preference extends StatelessWidget { ListTile( contentPadding: EdgeInsets.zero, title: Text(localizations.headerExpanded), - subtitle: Text(localizations.headerExpandedSubtitle, style: const TextStyle(fontSize: 12)), + subtitle: Text(localizations.headerExpandedSubtitle, style: subtitleStyle), trailing: SwitchWidget( - scale: 0.8, + scale: 0.75, value: appConfiguration.headerExpanded, onChanged: (value) { appConfiguration.headerExpanded = value; appConfiguration.flushConfig(); - })) + })), + SizedBox(height: 5), + Row(children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(localizations.memoryCleanup, style: titleStyle), + Text(localizations.memoryCleanupSubtitle, style: subtitleStyle), + ], + )), + SizedBox(width: 10), + memoryCleanup(context, localizations), + ]), + SizedBox(height: 5), ]))); } + ///主题颜色 Widget themeColor(BuildContext context) { return Wrap( children: ThemeModel.colors.entries.map((pair) { @@ -151,4 +181,67 @@ class Preference extends StatelessWidget { }).toList(), ); } + + bool memoryCleanupOpened = false; + + ///内存清理 + Widget memoryCleanup(BuildContext context, AppLocalizations localizations) { + try { + return DropdownButton( + value: appConfiguration.memoryCleanupThreshold, + onTap: () { + memoryCleanupOpened = true; + }, + onChanged: (val) { + memoryCleanupOpened = false; + setState(() { + appConfiguration.memoryCleanupThreshold = val; + }); + appConfiguration.flushConfig(); + }, + underline: Container(), + items: [ + DropdownMenuItem(value: null, child: Text(localizations.unlimited)), + const DropdownMenuItem(value: 512, child: Text("512M")), + const DropdownMenuItem(value: 1024, child: Text("1024M")), + const DropdownMenuItem(value: 2048, child: Text("2048M")), + const DropdownMenuItem(value: 4096, child: Text("4096M")), + DropdownMenuInputItem( + controller: memoryCleanupController, + child: Container( + constraints: BoxConstraints(maxWidth: 65, minWidth: 35), + child: TextField( + controller: memoryCleanupController, + onSubmitted: (value) { + setState(() {}); + appConfiguration.memoryCleanupThreshold = int.tryParse(value); + appConfiguration.flushConfig(); + + if (memoryCleanupOpened) { + memoryCleanupOpened = false; + Navigator.pop(context); + return; + } + }, + inputFormatters: [ + LengthLimitingTextInputFormatter(5), + FilteringTextInputFormatter.allow(RegExp("[0-9]")) + ], + decoration: InputDecoration(hintText: localizations.custom, suffixText: "M")))), + ]); + } catch (e) { + appConfiguration.memoryCleanupThreshold = null; + logger.e('memory button build error', error: e, stackTrace: StackTrace.current); + return const SizedBox(); + } + } +} + +class DropdownMenuInputItem extends DropdownMenuItem { + final TextEditingController controller; + + @override + int? get value => int.tryParse(controller.text) ?? 0; + + const DropdownMenuInputItem({super.key, required this.controller, required super.child}); } diff --git a/lib/ui/desktop/request/list.dart b/lib/ui/desktop/request/list.dart index d34d751..5a3d0a2 100644 --- a/lib/ui/desktop/request/list.dart +++ b/lib/ui/desktop/request/list.dart @@ -156,6 +156,19 @@ class DesktopRequestListState extends State with Autom }); } + cleanupEarlyData(int retain) { + var list = container.source; + if (list.length <= retain) { + return; + } + + container.removeRange(0, list.length - retain); + + domainListKey.currentState?.clean(); + requestSequenceKey.currentState?.clean(); + } + + ///导出 export(String fileName) async { final FileSaveLocation? result = await getSaveLocation(suggestedName: fileName); if (result == null) { @@ -395,6 +408,13 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM clean() { setState(() { containerMap.clear(); + searchView.clear(); + + var container = widget.list; + for (var request in container.source) { + DomainRequests domainRequests = getDomainRequests(request); + domainRequests.addRequest(request.requestId, request); + } }); } diff --git a/lib/ui/desktop/request/request_sequence.dart b/lib/ui/desktop/request/request_sequence.dart index edb1d2c..5078132 100644 --- a/lib/ui/desktop/request/request_sequence.dart +++ b/lib/ui/desktop/request/request_sequence.dart @@ -179,6 +179,7 @@ class RequestSequenceState extends State with AutomaticKeepAliv clean() { setState(() { view.clear(); + view.addAll(widget.container.source.reversed); }); } } diff --git a/lib/ui/mobile/request/history.dart b/lib/ui/mobile/request/history.dart index fdf95fa..a8f3400 100644 --- a/lib/ui/mobile/request/history.dart +++ b/lib/ui/mobile/request/history.dart @@ -180,9 +180,12 @@ class _MobileHistoryState extends State { //构建历史记录 Widget buildItem(HistoryStorage storage, int index, HistoryItem item) { return InkWell( + enableFeedback: false, onTapDown: (detail) async { HapticFeedback.mediumImpact(); - + setState(() { + selectIndex = index; + }); showContextMenu(context, detail.globalPosition.translate(-50, index == 0 ? -100 : 100), items: [ PopupMenuItem(child: Text(localizations.rename), onTap: () => renameHistory(storage, item)), PopupMenuItem(child: Text(localizations.share), onTap: () => export(storage, item)), @@ -196,7 +199,11 @@ class _MobileHistoryState extends State { }), const PopupMenuDivider(height: 0.3), PopupMenuItem(child: Text(localizations.delete), onTap: () => deleteHistory(storage, index)) - ]); + ]).whenComplete(() { + setState(() { + selectIndex = -1; + }); + }); }, child: ListTile( dense: true, diff --git a/lib/utils/listenable_list.dart b/lib/utils/listenable_list.dart index de62b12..30e7ac5 100644 --- a/lib/utils/listenable_list.dart +++ b/lib/utils/listenable_list.dart @@ -81,6 +81,17 @@ class ListenableList extends Iterable { @override T elementAt(int index) => source[index]; + List sublist(int start, [int? end]) { + return source.sublist(start, end); + } + + void removeRange(start, end) { + source.removeRange(start, end > source.length ? source.length : end); + for (var element in _listeners) { + element.clear(); + } + } + update(int index, T item) { source[index] = item; for (var element in _listeners) { diff --git a/test/cert_test.dart b/test/cert_test.dart index 479671b..817f82b 100644 --- a/test/cert_test.dart +++ b/test/cert_test.dart @@ -12,7 +12,7 @@ void main() async { var caRoot = X509Utils.x509CertificateFromPem(caPem); var generateRSAKeyPair = CryptoUtils.generateRSAKeyPair(); var serverPubKey = generateRSAKeyPair.publicKey as RSAPublicKey; - var serverPriKey = generateRSAKeyPair.privateKey as RSAPrivateKey; + // var serverPriKey = generateRSAKeyPair.privateKey as RSAPrivateKey; print(CryptoUtils.encodeRSAPublicKeyToPem(serverPubKey)); //保存私钥