mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-05-16 15:56:51 +08:00
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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<AppLocalizations> {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -363,5 +363,8 @@
|
||||
"cryptoDecoded": "已解密",
|
||||
"cryptoDecodeToggle": "解密",
|
||||
"optional": "可选",
|
||||
"cryptoRuleField": "字段"
|
||||
"cryptoRuleField": "字段名称",
|
||||
|
||||
"cryptoIvPrefixLabel": "IV 前缀",
|
||||
"cryptoIvPrefixTooltip": "使用响应体前 N 个字节作为 IV"
|
||||
}
|
||||
@@ -312,6 +312,13 @@
|
||||
"encrypt": "加密",
|
||||
"decrypt": "解密",
|
||||
"cipher": "密文",
|
||||
"requestCrypto": "請求解密",
|
||||
"cryptoDecoded": "已解密",
|
||||
"cryptoDecodeToggle": "解密",
|
||||
"optional": "可選",
|
||||
"cryptoRuleField": "字段",
|
||||
"cryptoIvPrefixLabel": "IV 前綴",
|
||||
"cryptoIvPrefixTooltip": "使用回應內容的前 N 個字節作為 IV",
|
||||
"appUpdateCheckVersion": "檢查更新",
|
||||
"appUpdateNotAvailableMsg": "已是最新版本",
|
||||
"appUpdateDialogTitle": "有可用更新",
|
||||
|
||||
@@ -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 (_) {
|
||||
|
||||
@@ -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<String, dynamic> toJson() {
|
||||
return {
|
||||
'_class': 'HttpResponse',
|
||||
'requestUrl': request?.requestUrl ?? _requestUrl,
|
||||
'protocolVersion': protocolVersion,
|
||||
'packageSize': packageSize,
|
||||
'status': {
|
||||
|
||||
@@ -85,6 +85,7 @@ class HttpBodyState extends State<HttpBodyWidget> {
|
||||
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<Widget>? 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<String, dynamic> toJson() => original.toJson();
|
||||
|
||||
@override
|
||||
String? get requestUrl => original.requestUrl;
|
||||
}
|
||||
|
||||
@@ -47,13 +47,9 @@ class RequestCryptoPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _RequestCryptoPageState extends State<RequestCryptoPage> {
|
||||
final Map<int, bool> 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<RequestCryptoPage> {
|
||||
}
|
||||
|
||||
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<RequestCryptoPage> {
|
||||
|
||||
@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<RequestCryptoPage> {
|
||||
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<RequestCryptoPage> {
|
||||
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<RequestCryptoPage> {
|
||||
_refreshConfig(force: true);
|
||||
}
|
||||
|
||||
Future<void> _editRule(int index) async {
|
||||
final rule = manager.rules[index];
|
||||
final updated = await showDialog<CryptoRule>(context: context, builder: (_) => CryptoRuleDialog(rule: rule));
|
||||
if (updated == null) return;
|
||||
await manager.updateRule(index, updated);
|
||||
_refreshConfig(force: true);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _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<void> _removeRules(List<int> 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<void> _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<void> _import() async {
|
||||
String? path;
|
||||
if (Platform.isMacOS) {
|
||||
@@ -304,11 +156,225 @@ class _RequestCryptoPageState extends State<RequestCryptoPage> {
|
||||
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<void> _export(List<int> 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<CryptoRuleList> createState() => _CryptoRuleListState();
|
||||
}
|
||||
|
||||
class _CryptoRuleListState extends State<CryptoRuleList> {
|
||||
RequestCryptoManager get manager => widget.manager;
|
||||
Set<int> 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<Widget> rows(List<CryptoRule> 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<void> showEdit([int? index]) async {
|
||||
final rule = index == null ? null : manager.rules[index];
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final updated = await showDialog<CryptoRule>(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<void> removeRules(List<int> 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<void> enableStatus(bool enable) async {
|
||||
if (selected.isEmpty) return;
|
||||
for (final entry in selected) {
|
||||
manager.rules[entry].enabled = enable;
|
||||
}
|
||||
setState(() {});
|
||||
_refreshConfig(force: true);
|
||||
}
|
||||
|
||||
Future<void> export(List<int> 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<RequestCryptoPage> {
|
||||
}
|
||||
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<CryptoRuleDialog> {
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<int>(
|
||||
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<String>(
|
||||
value: keyFormat,
|
||||
@@ -508,33 +643,56 @@ class _CryptoRuleDialogState extends State<CryptoRuleDialog> {
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<CryptoRuleDialog> {
|
||||
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<CryptoRuleDialog> {
|
||||
]),
|
||||
),
|
||||
]),
|
||||
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<String>(
|
||||
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<String>(
|
||||
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<int>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -19,20 +19,14 @@ class CryptoDecodedResult {
|
||||
|
||||
class CryptoBodyDecoder {
|
||||
static Future<CryptoDecodedResult?> 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user