Refactor request crypto UI and enhance AES key handling (#500)(#335)(#472)

This commit is contained in:
wanghongenpin
2026-01-06 00:18:15 +08:00
parent dee8f45d91
commit cc503dc42a
12 changed files with 434 additions and 281 deletions

View File

@@ -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"
}

View File

@@ -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> {

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -363,5 +363,8 @@
"cryptoDecoded": "已解密",
"cryptoDecodeToggle": "解密",
"optional": "可选",
"cryptoRuleField": "字段"
"cryptoRuleField": "字段名称",
"cryptoIvPrefixLabel": "IV 前缀",
"cryptoIvPrefixTooltip": "使用响应体前 N 个字节作为 IV"
}

View File

@@ -312,6 +312,13 @@
"encrypt": "加密",
"decrypt": "解密",
"cipher": "密文",
"requestCrypto": "請求解密",
"cryptoDecoded": "已解密",
"cryptoDecodeToggle": "解密",
"optional": "可選",
"cryptoRuleField": "字段",
"cryptoIvPrefixLabel": "IV 前綴",
"cryptoIvPrefixTooltip": "使用回應內容的前 N 個字節作為 IV",
"appUpdateCheckVersion": "檢查更新",
"appUpdateNotAvailableMsg": "已是最新版本",
"appUpdateDialogTitle": "有可用更新",

View File

@@ -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 (_) {

View File

@@ -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': {

View File

@@ -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;
}

View File

@@ -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,
),
),
),
]),
]),
),
),

View File

@@ -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;

View File

@@ -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