diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 00df9fd..29bd6c5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -363,5 +363,8 @@ "cryptoDecoded": "Decoded", "cryptoDecodeToggle": "Decrypt", "optional": "Optional", - "cryptoRuleField": "Field Name" + "cryptoRuleField": "Field Name", + + "cryptoIvPrefixLabel": "IV Prefix", + "cryptoIvPrefixTooltip": "Use the first N bytes of the response body as IV" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c5f01a6..c5bc9b5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2123,6 +2123,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Field Name'** String get cryptoRuleField; + + /// No description provided for @cryptoIvPrefixLabel. + /// + /// In en, this message translates to: + /// **'IV Prefix'** + String get cryptoIvPrefixLabel; + + /// No description provided for @cryptoIvPrefixTooltip. + /// + /// In en, this message translates to: + /// **'Use the first N bytes of the response body as IV'** + String get cryptoIvPrefixTooltip; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5b7d4b5..6d46b2e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1052,4 +1052,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cryptoRuleField => 'Field Name'; + + @override + String get cryptoIvPrefixLabel => 'IV Prefix'; + + @override + String get cryptoIvPrefixTooltip => 'Use the first N bytes of the response body as IV'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 610a66b..2a3707b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1038,7 +1038,13 @@ class AppLocalizationsZh extends AppLocalizations { String get optional => '可选'; @override - String get cryptoRuleField => '字段'; + String get cryptoRuleField => '字段名称'; + + @override + String get cryptoIvPrefixLabel => 'IV 前缀'; + + @override + String get cryptoIvPrefixTooltip => '使用响应体前 N 个字节作为 IV'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -2062,4 +2068,25 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get privacyContent => '本專案為開源抓包工具,所有功能均在本機裝置上運行;無任何後端伺服器,不會蒐集、儲存或上傳任何使用者資訊。擷取的網路資料僅在本機處理,除非您主動使用遠端轉發功能。所需權限(如網路、儲存、相機用於掃碼)僅用於實現相應功能。您可在公開的原始碼中稽核其行為。'; + + @override + String get requestCrypto => '請求解密'; + + @override + String get cryptoDecoded => '已解密'; + + @override + String get cryptoDecodeToggle => '解密'; + + @override + String get optional => '可選'; + + @override + String get cryptoRuleField => '字段'; + + @override + String get cryptoIvPrefixLabel => 'IV 前綴'; + + @override + String get cryptoIvPrefixTooltip => '使用回應內容的前 N 個字節作為 IV'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fb3412d..1d952c6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -363,5 +363,8 @@ "cryptoDecoded": "已解密", "cryptoDecodeToggle": "解密", "optional": "可选", - "cryptoRuleField": "字段" + "cryptoRuleField": "字段名称", + + "cryptoIvPrefixLabel": "IV 前缀", + "cryptoIvPrefixTooltip": "使用响应体前 N 个字节作为 IV" } \ No newline at end of file diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 733f4d7..c87555d 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -312,6 +312,13 @@ "encrypt": "加密", "decrypt": "解密", "cipher": "密文", + "requestCrypto": "請求解密", + "cryptoDecoded": "已解密", + "cryptoDecodeToggle": "解密", + "optional": "可選", + "cryptoRuleField": "字段", + "cryptoIvPrefixLabel": "IV 前綴", + "cryptoIvPrefixTooltip": "使用回應內容的前 N 個字節作為 IV", "appUpdateCheckVersion": "檢查更新", "appUpdateNotAvailableMsg": "已是最新版本", "appUpdateDialogTitle": "有可用更新", diff --git a/lib/network/components/manager/request_crypto_manager.dart b/lib/network/components/manager/request_crypto_manager.dart index 1dfff70..739ff67 100644 --- a/lib/network/components/manager/request_crypto_manager.dart +++ b/lib/network/components/manager/request_crypto_manager.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/util/file_read.dart'; import 'package:proxypin/network/util/logger.dart'; @@ -73,7 +74,9 @@ class RequestCryptoManager { } /// Get the first matching rule for the given URL and optional field name - CryptoRule? getMatchingRule(String url) { + CryptoRule? getMatchingRule(HttpMessage message) { + final url = message.requestUrl; + if (url == null) return null; if (!enabled) return null; for (final rule in rules) { if (!rule.enabled || !rule.matches(url)) continue; @@ -119,10 +122,10 @@ class CryptoRule { final String name; final String urlPattern; final String? field; // single field supported - final bool enabled; + bool enabled; final CryptoKeyConfig config; - const CryptoRule({ + CryptoRule({ required this.name, required this.urlPattern, this.field, @@ -131,9 +134,6 @@ class CryptoRule { }); bool matches(String url) { - if (urlPattern.isEmpty) { - return true; - } try { return RegExp(urlPattern).hasMatch(url); } catch (_) { diff --git a/lib/network/http/http.dart b/lib/network/http/http.dart index 9993376..26654fd 100644 --- a/lib/network/http/http.dart +++ b/lib/network/http/http.dart @@ -53,6 +53,8 @@ abstract class HttpMessage { int get contentLength => headers.contentLength; + String? get requestUrl; + //报文大小 int? packageSize; @@ -202,6 +204,7 @@ class HttpRequest extends HttpMessage { return hostAndPort?.domain; } + @override String get requestUrl { if (HostAndPort.startsWithScheme(uri)) { return uri; @@ -296,6 +299,10 @@ class HttpResponse extends HttpMessage { HttpStatus status; DateTime responseTime = DateTime.now(); HttpRequest? request; + String? _requestUrl; + + @override + String? get requestUrl => request?.requestUrl ?? _requestUrl; HttpResponse(this.status, {String protocolVersion = "HTTP/1.1"}) : super(protocolVersion); @@ -320,6 +327,7 @@ class HttpResponse extends HttpMessage { httpResponse.responseTime = DateTime.fromMillisecondsSinceEpoch(json['responseTime']); } httpResponse.packageSize = json['packageSize']; + httpResponse._requestUrl = json['requestUrl']; return httpResponse; } @@ -327,6 +335,7 @@ class HttpResponse extends HttpMessage { Map toJson() { return { '_class': 'HttpResponse', + 'requestUrl': request?.requestUrl ?? _requestUrl, 'protocolVersion': protocolVersion, 'packageSize': packageSize, 'status': { diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index c28907b..c6d0ce3 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -85,6 +85,7 @@ class HttpBodyState extends State { if (widget.windowController != null) { HardwareKeyboard.instance.addHandler(onKeyEvent); } + _loadDecoded(); } @@ -490,7 +491,8 @@ class _BodyState extends State<_Body> { ? _DecodedHttpMessage(widget.message!, parent!.decoded!) : widget.message; - if (message?.isWebSocket == true || (message?.contentType == ContentType.sse && message?.messages.isNotEmpty == true)) { + if (message?.isWebSocket == true || + (message?.contentType == ContentType.sse && message?.messages.isNotEmpty == true)) { List? list = message?.messages .map((e) => Container( margin: const EdgeInsets.only(top: 2, bottom: 2), @@ -523,9 +525,7 @@ class _BodyState extends State<_Body> { } if (type == ViewType.image) { - return Center( - child: Image.memory( - Uint8List.fromList(message?.body ?? []), fit: BoxFit.scaleDown)); + return Center(child: Image.memory(Uint8List.fromList(message?.body ?? []), fit: BoxFit.scaleDown)); } if (type == ViewType.video) { return const Center(child: Text("video not support preview")); @@ -541,8 +541,7 @@ class _BodyState extends State<_Body> { contextMenuBuilder: contextMenu); } - return futureWidget(message!.decodeBodyString(), - initialData: message!.getBodyString(), (body) { + return futureWidget(message!.decodeBodyString(), initialData: message!.getBodyString(), (body) { try { if (type == ViewType.jsonText) { var jsonObject = json.decode(body); @@ -702,4 +701,7 @@ class _DecodedHttpMessage extends HttpMessage { @override Map toJson() => original.toJson(); + + @override + String? get requestUrl => original.requestUrl; } diff --git a/lib/ui/desktop/setting/request_crypto.dart b/lib/ui/desktop/setting/request_crypto.dart index 9c523dd..9df2293 100644 --- a/lib/ui/desktop/setting/request_crypto.dart +++ b/lib/ui/desktop/setting/request_crypto.dart @@ -47,13 +47,9 @@ class RequestCryptoPage extends StatefulWidget { } class _RequestCryptoPageState extends State { - final Map selected = {}; + AppLocalizations get localizations => AppLocalizations.of(context)!; RequestCryptoManager get manager => widget.manager; - bool isPressed = false; - Offset? lastPressPosition; - - AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { @@ -68,6 +64,11 @@ class _RequestCryptoPageState extends State { } bool _onKeyEvent(KeyEvent event) { + if (HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.escape) && Navigator.canPop(context)) { + Navigator.maybePop(context); + return true; + } + if ((HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) && event.logicalKey == LogicalKeyboardKey.keyW) { if (Navigator.canPop(context)) { @@ -82,6 +83,7 @@ class _RequestCryptoPageState extends State { @override Widget build(BuildContext context) { + bool isEN = Localizations.localeOf(context).languageCode == 'en'; return Scaffold( backgroundColor: Theme.of(context).dialogTheme.backgroundColor, appBar: AppBar( @@ -94,7 +96,7 @@ class _RequestCryptoPageState extends State { child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ SizedBox( - width: 225, + width: isEN ? 310 : 225, child: ListTile( title: Text("${localizations.enable} ${localizations.requestCrypto}"), trailing: SwitchWidget( @@ -118,101 +120,7 @@ class _RequestCryptoPageState extends State { const SizedBox(width: 15) ]), const SizedBox(height: 16), - _buildRuleList() - ])))); - } - - Widget _buildRuleList() { - final theme = Theme.of(context); - return GestureDetector( - onSecondaryTapDown: (details) => _showGlobalMenu(details.globalPosition), - child: Listener( - onPointerUp: (_) => isPressed = false, - onPointerDown: (event) { - lastPressPosition = event.localPosition; - if (event.buttons == kPrimaryButton) { - isPressed = true; - } - }, - child: Container( - padding: const EdgeInsets.only(top: 10), - decoration: BoxDecoration(border: Border.all(color: Colors.grey.withAlpha((0.2 * 255).round()))), - child: Column(children: [ - Padding( - padding: EdgeInsets.only(left: 5, bottom: 5), - child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [ - Container(width: 80, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), - SizedBox(width: 80, child: Text(localizations.enable, textAlign: TextAlign.center)), - const VerticalDivider(width: 24), - const Expanded(child: Text('URL')), - SizedBox(width: 220, child: Text(localizations.cryptoRuleField, textAlign: TextAlign.center)), - SizedBox(width: 120, child: Text(localizations.action, textAlign: TextAlign.center)) - ])), - const Divider(thickness: 0.5, height: 5), - ...List.generate(manager.rules.length, (index) { - final rule = manager.rules[index]; - final selectedState = selected[index] == true; - return InkWell( - highlightColor: Colors.transparent, - splashColor: Colors.transparent, - hoverColor: theme.colorScheme.primary.withValues(alpha: 0.1), - onSecondaryTapDown: (details) => _showRowMenu(details.globalPosition, index), - onDoubleTap: () => _editRule(index), - onHover: (hover) { - if (isPressed && !selectedState) { - setState(() => selected[index] = true); - } - }, - onTap: () { - if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { - setState(() => selected[index] = !(selected[index] ?? false)); - return; - } - if (selected.isEmpty) { - return; - } - setState(() => selected.clear()); - }, - child: Container( - color: selectedState - ? theme.colorScheme.primary.withValues(alpha: 0.2) - : index.isEven - ? Colors.grey.withAlpha((0.06 * 255).round()) - : null, - height: 42, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row(children: [ - SizedBox( - width: 80, - child: Text(rule.name, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))), - SizedBox( - width: 80, - child: SwitchWidget( - scale: 0.7, value: rule.enabled, onChanged: (val) => _toggleRule(index, val))), - const SizedBox(width: 8), - Expanded( - child: Text(rule.urlPattern.isEmpty ? localizations.emptyMatchAll : rule.urlPattern, - overflow: TextOverflow.ellipsis)), - SizedBox( - width: 220, - child: Text(rule.field ?? '', - overflow: TextOverflow.ellipsis, textAlign: TextAlign.center)), - SizedBox( - width: 120, - child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - IconButton( - icon: const Icon(Icons.edit, size: 18), - tooltip: localizations.edit, - onPressed: () => _editRule(index)), - IconButton( - icon: const Icon(Icons.delete_outline, size: 18), - tooltip: localizations.delete, - onPressed: () => _removeRules([index])) - ])) - ]))); - }) + CryptoRuleList(manager: manager, windowId: widget.windowId), ])))); } @@ -225,62 +133,6 @@ class _RequestCryptoPageState extends State { _refreshConfig(force: true); } - Future _editRule(int index) async { - final rule = manager.rules[index]; - final updated = await showDialog(context: context, builder: (_) => CryptoRuleDialog(rule: rule)); - if (updated == null) return; - await manager.updateRule(index, updated); - _refreshConfig(force: true); - setState(() {}); - } - - Future _toggleRule(int index, bool value) async { - await manager.updateRule(index, manager.rules[index].copyWith(enabled: value)); - _refreshConfig(force: true); - setState(() {}); - } - - void _showRowMenu(Offset position, int index) { - showContextMenu(context, position, items: [ - PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => _editRule(index)), - PopupMenuItem(height: 35, child: Text(localizations.delete), onTap: () => _removeRules([index])) - ]); - } - - void _showGlobalMenu(Offset offset) { - showContextMenu(context, offset, items: [ - PopupMenuItem(height: 35, onTap: _addRule, child: Text(localizations.newBuilt)), - PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => _export(selected.keys.toList())), - const PopupMenuDivider(), - PopupMenuItem(height: 35, child: Text(localizations.enableSelect), onTap: () => _enableStatus(true)), - PopupMenuItem(height: 35, child: Text(localizations.disableSelect), onTap: () => _enableStatus(false)), - const PopupMenuDivider(), - PopupMenuItem( - height: 35, child: Text(localizations.deleteSelect), onTap: () => _removeRules(selected.keys.toList())) - ]); - } - - Future _removeRules(List indexes) async { - if (indexes.isEmpty) return; - indexes.sort((a, b) => b.compareTo(a)); - for (final index in indexes) { - await manager.removeRule(index); - } - selected.clear(); - _refreshConfig(force: true); - } - - Future _enableStatus(bool enable) async { - if (selected.isEmpty) return; - for (final entry in selected.entries) { - if (entry.value) { - await manager.updateRule(entry.key, manager.rules[entry.key].copyWith(enabled: enable)); - } - } - selected.clear(); - _refreshConfig(force: true); - } - Future _import() async { String? path; if (Platform.isMacOS) { @@ -304,11 +156,225 @@ class _RequestCryptoPageState extends State { if (mounted) FlutterToastr.show(localizations.importSuccess, context); } catch (e) { logger.e('导入失败 $path', error: e); - if (mounted) FlutterToastr.show("${localizations.importFailed} $e", context); + if (mounted) FlutterToastr.show('${localizations.importFailed} $e', context); } } +} - Future _export(List indexes) async { +// Reusable rule list component extracted from _RequestCryptoPageState +class CryptoRuleList extends StatefulWidget { + final int? windowId; + final RequestCryptoManager manager; + + const CryptoRuleList({ + required this.manager, + super.key, + this.windowId, + }); + + @override + State createState() => _CryptoRuleListState(); +} + +class _CryptoRuleListState extends State { + RequestCryptoManager get manager => widget.manager; + Set selected = {}; + bool isPressed = false; + Offset? lastPressPosition; + + AppLocalizations get localizations => AppLocalizations.of(context)!; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onSecondaryTap: () { + if (lastPressPosition == null) { + return; + } + showGlobalMenu(lastPressPosition!); + }, + onTapDown: (details) { + if (selected.isEmpty) { + return; + } + if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { + return; + } + setState(() { + selected.clear(); + }); + }, + child: Listener( + onPointerUp: (event) => isPressed = false, + onPointerDown: (event) { + lastPressPosition = event.localPosition; + if (event.buttons == kPrimaryMouseButton) { + isPressed = true; + } + }, + child: Container( + padding: const EdgeInsets.only(top: 10), + constraints: const BoxConstraints(minHeight: 200, maxHeight: 600), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withAlpha((0.2 * 255).round()))), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 5, bottom: 5), + child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + Container(width: 80, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), + SizedBox(width: 80, child: Text(localizations.enable, textAlign: TextAlign.center)), + const VerticalDivider(width: 24), + const Expanded(child: Text('URL', textAlign: TextAlign.center)), + SizedBox(width: 120, child: Text(localizations.cryptoRuleField, textAlign: TextAlign.center)), + SizedBox(width: 220, child: Text('AES Key', textAlign: TextAlign.center)), + ]), + ), + const Divider(thickness: 0.5, height: 5), + Column(children: rows(manager.rules)) + ], + ), + ), + ), + ); + } + + List rows(List rules) { + var primaryColor = Theme.of(context).colorScheme.primary; + + return List.generate(rules.length, (index) { + final rule = rules[index]; + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + hoverColor: primaryColor.withOpacity(0.3), + onDoubleTap: () => showEdit(index), + onSecondaryTapDown: (details) => showMenus(details, index), + onHover: (hover) { + if (isPressed && !selected.contains(index)) { + setState(() { + selected.add(index); + }); + } + }, + onTap: () { + if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { + setState(() { + selected.contains(index) ? selected.remove(index) : selected.add(index); + }); + return; + } + if (selected.isEmpty) { + return; + } + setState(() { + selected.clear(); + }); + }, + child: Container( + color: selected.contains(index) + ? primaryColor.withOpacity(0.6) + : index.isEven + ? Colors.grey.withOpacity(0.1) + : null, + height: 32, + padding: const EdgeInsets.all(5), + child: Row(children: [ + SizedBox( + width: 80, + child: Text(rule.name, + overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + ), + SizedBox( + width: 80, + child: SwitchWidget( + scale: 0.7, + value: rule.enabled, + onChanged: (val) { + rules[index].enabled = val; + _refreshConfig(); + })), + const SizedBox(width: 8), + Expanded( + child: Text(rule.urlPattern.isEmpty ? localizations.emptyMatchAll : rule.urlPattern, + overflow: TextOverflow.ellipsis)), + SizedBox( + width: 120, + child: Text(rule.field ?? '', overflow: TextOverflow.ellipsis, textAlign: TextAlign.center)), + SizedBox( + width: 220, + child: Text(_formatKey(rule.config.key), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center)), + ]), + ), + ); + }); + } + + Future showEdit([int? index]) async { + final rule = index == null ? null : manager.rules[index]; + if (!mounted) { + return; + } + + final updated = await showDialog(context: context, builder: (_) => CryptoRuleDialog(rule: rule)); + if (updated == null) return; + if (index == null) { + await manager.addRule(updated); + } else { + await manager.updateRule(index, updated); + } + _refreshConfig(force: true); + setState(() {}); + } + + Future removeRules(List indexes) async { + if (indexes.isEmpty) return; + showConfirmDialog(context, content: localizations.confirmContent, onConfirm: () async { + indexes.sort((a, b) => b.compareTo(a)); + for (final index in indexes) { + await manager.removeRule(index); + } + selected.clear(); + _refreshConfig(force: true); + }); + } + + void showMenus(TapDownDetails details, int index) { + if (selected.length > 1) { + showGlobalMenu(details.globalPosition); + return; + } + setState(() { + selected.add(index); + }); + + showContextMenu(context, details.globalPosition, items: [ + PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => showEdit(index)), + PopupMenuItem(height: 35, child: Text(localizations.delete), onTap: () => removeRules([index])) + ]); + } + + void showGlobalMenu(Offset offset) { + showContextMenu(context, offset, items: [ + PopupMenuItem(height: 35, onTap: showEdit, child: Text(localizations.newBuilt)), + PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => export(selected.toList())), + const PopupMenuDivider(), + PopupMenuItem(height: 35, child: Text(localizations.enableSelect), onTap: () => enableStatus(true)), + PopupMenuItem(height: 35, child: Text(localizations.disableSelect), onTap: () => enableStatus(false)), + const PopupMenuDivider(), + PopupMenuItem(height: 35, child: Text(localizations.deleteSelect), onTap: () => removeRules(selected.toList())) + ]); + } + + Future enableStatus(bool enable) async { + if (selected.isEmpty) return; + for (final entry in selected) { + manager.rules[entry].enabled = enable; + } + setState(() {}); + _refreshConfig(force: true); + } + + Future export(List indexes) async { if (indexes.isEmpty) return; indexes.sort(); final data = indexes.map((i) => manager.rules[i].toJson()).toList(); @@ -321,7 +387,18 @@ class _RequestCryptoPageState extends State { } if (path == null) return; await File(path).writeAsString(jsonEncode(data)); - FlutterToastr.show(localizations.exportSuccess, context); + if (mounted) FlutterToastr.show(localizations.exportSuccess, context); + } + + // Format AES key for display: strip optional 'base64:' prefix and truncate long values + String _formatKey(String? raw) { + if (raw == null || raw.trim().isEmpty) return ''; + var k = raw.trim(); + if (k.startsWith('base64:')) { + k = k.substring(7); + } + if (k.length > 40) return '${k.substring(0, 40)}...'; + return k; } } @@ -478,26 +555,84 @@ class _CryptoRuleDialogState extends State { child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("AES", style: theme.textTheme.titleSmall), const SizedBox(height: 12), - // Key input and format selector in a single row for nicer UI Row(children: [ - Expanded( - child: SizedBox( - child: TextFormField( - controller: keyController, - maxLength: 128, - decoration: decorate(context, "Key").copyWith(counterText: ''), - validator: (val) => val == null || val.trim().isEmpty ? l10n.cannotBeEmpty : null, + Text("Mode", style: theme.textTheme.labelMedium), + const SizedBox(width: 8), + Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: mode, + items: const [ + DropdownMenuItem(value: 'ECB', child: Text('ECB')), + DropdownMenuItem(value: 'CBC', child: Text('CBC')), + ], + onChanged: (v) => setState(() => mode = v ?? 'ECB'), + style: Theme.of(context).textTheme.bodyMedium, ), ), ), + const SizedBox(width: 12), + Text('Padding', style: theme.textTheme.labelMedium), const SizedBox(width: 8), Container( - height: 44, + height: 42, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), borderRadius: BorderRadius.circular(6), ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: padding, + items: const [ + DropdownMenuItem(value: 'PKCS7', child: Text('PKCS7')), + DropdownMenuItem(value: 'ZeroPadding', child: Text('ZeroPadding')), + ], + onChanged: (v) => setState(() => padding = v ?? 'PKCS7'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + const SizedBox(width: 12), + Text('Key Length', style: theme.textTheme.labelMedium), + const SizedBox(width: 8), + Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: length, + items: const [ + DropdownMenuItem(value: 128, child: Text('128')), + DropdownMenuItem(value: 192, child: Text('192')), + DropdownMenuItem(value: 256, child: Text('256')), + ], + onChanged: (v) => setState(() => length = v ?? 128), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ]), + const SizedBox(height: 12), + // Key input and format selector in a single row for nicer UI + Row(children: [ + Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), + borderRadius: BorderRadius.circular(6), + ), child: DropdownButtonHideUnderline( child: DropdownButton( value: keyFormat, @@ -508,33 +643,56 @@ class _CryptoRuleDialogState extends State { onChanged: (v) => setState(() => keyFormat = v ?? 'text'), style: Theme.of(context).textTheme.bodyMedium, iconEnabledColor: Theme.of(context).colorScheme.primary, - itemHeight: 48, ), ), - ) + ), + const SizedBox(width: 12), + + Expanded( + child: SizedBox( + child: TextFormField( + controller: keyController, + maxLength: 128, + decoration: decorate(context, "Key").copyWith(counterText: ''), + validator: (val) => val == null || val.trim().isEmpty ? l10n.cannotBeEmpty : null, + ), + ), + ), + ]), const SizedBox(height: 12), // Compact single-line IV controls for CBC if (mode == 'CBC') Row(children: [ - // small segmented control - SegmentedButton( - segments: const [ - ButtonSegment(value: 'manual', label: Text('输入')), - ButtonSegment(value: 'prefix', label: Text('从密文取')), - ], - selected: {ivSource}, - onSelectionChanged: (selection) => setState(() => ivSource = selection.first), + Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: ivSource, + items: [ + DropdownMenuItem(value: 'manual', child: Text(l10n.manual)), + DropdownMenuItem(value: 'prefix', child: Text(l10n.cryptoIvPrefixLabel)), + ], + onChanged: (v) => setState(() => ivSource = v ?? 'manual'), + style: Theme.of(context).textTheme.bodyMedium, + iconEnabledColor: Theme.of(context).colorScheme.primary, + ), + ), ), const SizedBox(width: 8), // narrow IV input when manual (fixed width for compactness) if (ivSource == 'manual') SizedBox( - width: 220, - height: 40, + width: 260, + height: 42, child: TextFormField( controller: ivController, - decoration: decorate(context, 'IV', hint: l10n.optional).copyWith( + decoration: decorate(context, 'IV').copyWith( isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10)), validator: (val) => (ivSource == 'manual' && (val == null || val.trim().isEmpty)) @@ -545,7 +703,7 @@ class _CryptoRuleDialogState extends State { if (ivSource == 'manual') const SizedBox(width: 8), if (ivSource == 'prefix') Tooltip( - message: '从密文的前 N 字节提取 IV(通常为 16)', + message: l10n.cryptoIvPrefixTooltip, child: Icon(Icons.info_outline, size: 16, color: theme.dividerColor)), if (ivSource == 'prefix') const SizedBox(width: 8), // compact numeric stepper (prefix length) @@ -578,76 +736,6 @@ class _CryptoRuleDialogState extends State { ]), ), ]), - const SizedBox(height: 12), - // Compact row: Mode | Padding | Key Length - Row(children: [ - Text("Mode", style: theme.textTheme.labelMedium), - const SizedBox(width: 8), - Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), - borderRadius: BorderRadius.circular(6), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: mode, - items: const [ - DropdownMenuItem(value: 'ECB', child: Text('ECB')), - DropdownMenuItem(value: 'CBC', child: Text('CBC')), - ], - onChanged: (v) => setState(() => mode = v ?? 'ECB'), - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ), - const SizedBox(width: 12), - Text('Padding', style: theme.textTheme.labelMedium), - const SizedBox(width: 8), - Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), - borderRadius: BorderRadius.circular(6), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: padding, - items: const [ - DropdownMenuItem(value: 'PKCS7', child: Text('PKCS7')), - DropdownMenuItem(value: 'ZeroPadding', child: Text('ZeroPadding')), - ], - onChanged: (v) => setState(() => padding = v ?? 'PKCS7'), - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ), - const SizedBox(width: 12), - Text('Key Length', style: theme.textTheme.labelMedium), - const SizedBox(width: 8), - Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).dividerColor.withAlpha((0.12 * 255).round())), - borderRadius: BorderRadius.circular(6), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: length, - items: const [ - DropdownMenuItem(value: 128, child: Text('128')), - DropdownMenuItem(value: 192, child: Text('192')), - DropdownMenuItem(value: 256, child: Text('256')), - ], - onChanged: (v) => setState(() => length = v ?? 128), - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ), - ]), ]), ), ), diff --git a/lib/utils/crypto_body_decoder.dart b/lib/utils/crypto_body_decoder.dart index f2b82bf..4c46f40 100644 --- a/lib/utils/crypto_body_decoder.dart +++ b/lib/utils/crypto_body_decoder.dart @@ -19,20 +19,14 @@ class CryptoDecodedResult { class CryptoBodyDecoder { static Future maybeDecode(HttpMessage message) async { - final url = message is HttpRequest - ? message.requestUrl - : message is HttpResponse - ? message.request?.requestUrl - : null; - if (url == null) return null; final ruleStore = await RequestCryptoManager.instance; - CryptoRule? match = ruleStore.getMatchingRule(url); - if (match != null) { - return _tryDecode(message, match.config, rule: match); + CryptoRule? match = ruleStore.getMatchingRule(message); + if (match == null) { + return null; } - return null; + return _tryDecode(message, match.config, rule: match); } static CryptoDecodedResult? decode(HttpMessage message, CryptoKeyConfig config) { @@ -124,7 +118,8 @@ class CryptoBodyDecoder { if (config.padding != 'PKCS7' && (cipherBytes.length % aesBlockSize != 0)) return null; final ivStr = 'base64:' + base64.encode(ivBytes); try { - return AesUtils.decrypt(cipherBytes, key: config.key, keyLength: config.keyLength, mode: config.mode, padding: config.padding, iv: ivStr); + return AesUtils.decrypt(cipherBytes, + key: config.key, keyLength: config.keyLength, mode: config.mode, padding: config.padding, iv: ivStr); } catch (e) { logger.d('CryptoBodyDecoder _decryptCandidate error (prefix): $e'); return null; @@ -135,7 +130,8 @@ class CryptoBodyDecoder { if (config.padding != 'PKCS7' && (candidate.length % aesBlockSize != 0)) return null; final ivParam = (config.mode == 'CBC') ? config.iv : null; try { - return AesUtils.decrypt(candidate, key: config.key, keyLength: config.keyLength, mode: config.mode, padding: config.padding, iv: ivParam); + return AesUtils.decrypt(candidate, + key: config.key, keyLength: config.keyLength, mode: config.mode, padding: config.padding, iv: ivParam); } catch (e) { logger.d('CryptoBodyDecoder _decryptCandidate error: $e'); return null; diff --git a/pubspec.yaml b/pubspec.yaml index b1a4eb5..4753534 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,7 +45,7 @@ dependencies: flutter_qr_reader_plus: ^1.0.6 brotli: ^0.6.0 # macos_window_utils: 1.6.1 - win32audio: ^1.3.1 + win32audio: ^1.5.0 vclibs: ^0.1.3 scrollable_positioned_list_nic: ^0.0.2