diff --git a/lib/network/http/http_headers.dart b/lib/network/http/http_headers.dart index fa1644a..1ecc120 100644 --- a/lib/network/http/http_headers.dart +++ b/lib/network/http/http_headers.dart @@ -27,6 +27,84 @@ class HttpHeaders { static const String Cookie = "Cookie"; static const String PROXY_AUTHORIZATION = "Proxy-Authorization"; + static const List commonHeaderKeys = [ + 'Accept', + 'Accept-Charset', + 'Accept-Encoding', + 'Accept-Language', + 'Accept-Ranges', + 'Authorization', + 'Cache-Control', + 'Connection', + 'Content-Type', + 'Content-Length', + 'Content-Encoding', + 'Cookie', + 'Date', + 'Expect', + 'From', + 'Host', + 'If-Match', + 'If-Modified-Since', + 'If-None-Match', + 'If-Range', + 'If-Unmodified-Since', + 'Max-Forwards', + 'Origin', + 'Pragma', + 'Proxy-Authorization', + 'Range', + 'Referer', + 'TE', + 'Upgrade', + 'User-Agent', + 'Via', + 'Warning', + 'X-Requested-With', + 'DNT', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'Front-End-Https', + 'X-Http-Method-Override', + 'X-ATT-DeviceId', + 'X-Wap-Profile', + 'Proxy-Connection', + 'X-UIDH', + 'X-Csrf-Token', + 'X-Request-ID', + 'X-Correlation-ID', + 'Save-Data' + ]; + + static const Map> commonHeaderValues = { + 'Accept': [ + 'application/json, text/plain, */*', + 'application/xml, text/xml, */*', + 'text/html, application/xhtml+xml, */*', + '*/*' + ], + 'Accept-Charset': ['utf-8, iso-8859-1;q=0.5', 'utf-8'], + 'Accept-Encoding': ['gzip, deflate, br', 'gzip, deflate'], + 'Accept-Language': ['en-US,en;q=0.9', 'zh-CN,zh;q=0.9'], + 'Cache-Control': ['no-cache', 'max-age=0', 'no-store'], + 'Connection': ['keep-alive', 'close'], + 'Content-Type': [ + 'application/json', + 'application/x-www-form-urlencoded', + 'multipart/form-data', + 'text/plain', + 'text/html', + 'application/xml' + ], + 'User-Agent': [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Android 11; Mobile; rv:68.0) Gecko/68.0 Firefox/68.0' + ], + }; + final LinkedHashMap> _headers = LinkedHashMap>(); // 由小写标头名称键入的原始标头名称。 diff --git a/lib/ui/desktop/request/request_editor.dart b/lib/ui/desktop/request/request_editor.dart index 60ee094..a3582c2 100644 --- a/lib/ui/desktop/request/request_editor.dart +++ b/lib/ui/desktop/request/request_editor.dart @@ -208,7 +208,7 @@ class RequestEditorState extends State { ])) ]), message: response, - readOnly: widget.source == RequestEditorSource.breakpointRequest)) + readOnly: widget.source != RequestEditorSource.breakpointResponse)) ], ); }), @@ -377,7 +377,7 @@ class _HttpState extends State<_HttpWidget> { } } - change(HttpMessage? message) { + void change(HttpMessage? message) { this.message = message; body?.text = message?.bodyAsString ?? ''; headerKey.currentState?.refreshParam(message?.headers.getHeaders()); @@ -416,7 +416,8 @@ class _HttpState extends State<_HttpWidget> { KeyValWidget( key: headerKey, params: message?.headers.getHeaders() ?? initHeader, - readOnly: widget.readOnly), + readOnly: widget.readOnly, + suggestions: HttpHeaders.commonHeaderKeys), _body() ], )), @@ -521,6 +522,8 @@ class KeyVal { bool enabled = true; TextEditingController key; TextEditingController value; + FocusNode? keyFocusNode; + FocusNode? valueFocusNode; KeyVal(this.key, this.value); } @@ -530,8 +533,9 @@ class KeyValWidget extends StatefulWidget { final Map>? params; final bool readOnly; //只读 final UrlQueryNotifier? paramNotifier; + final List? suggestions; - const KeyValWidget({super.key, this.params, this.readOnly = false, this.paramNotifier}); + const KeyValWidget({super.key, this.params, this.readOnly = false, this.paramNotifier, this.suggestions}); @override State createState() => KeyValState(); @@ -570,7 +574,7 @@ class KeyValState extends State with AutomaticKeepAliveClientMixin } //监听url发生变化 更改表单 - onChange(String value) { + void onChange(String value) { var query = value.split("&"); int index = 0; while (index < query.length) { @@ -591,7 +595,7 @@ class KeyValState extends State with AutomaticKeepAliveClientMixin setState(() {}); } - notifierChange() { + void notifierChange() { if (widget.paramNotifier == null) return; String query = _params .where((e) => e.enabled && e.key.text.isNotEmpty) @@ -600,7 +604,7 @@ class KeyValState extends State with AutomaticKeepAliveClientMixin widget.paramNotifier?.onParamChange(query); } - clear() { + void clear() { for (var element in _params) { element.key.dispose(); element.value.dispose(); @@ -609,7 +613,7 @@ class KeyValState extends State with AutomaticKeepAliveClientMixin } //刷新param - refreshParam(Map>? headers) { + void refreshParam(Map>? headers) { clear(); setState(() { headers?.forEach((name, values) { @@ -692,13 +696,103 @@ class KeyValState extends State with AutomaticKeepAliveClientMixin return list; } - Widget _cell(TextEditingController val, {bool isKey = false}) { + Widget _cell(KeyVal keyVal, + {bool isKey = false, + FocusNode? focusNode, + List? suggestions, + Map>? valueSuggestions}) { + TextEditingController textController = isKey ? keyVal.key : keyVal.value; + + if (!widget.readOnly && (suggestions != null || valueSuggestions != null)) { + return Container( + padding: const EdgeInsets.only(right: 5), + child: RawAutocomplete( + textEditingController: textController, + focusNode: focusNode, + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + + var currentSuggestions = suggestions; + if (!isKey && valueSuggestions?.containsKey(keyVal.key.text) == true) { + currentSuggestions = valueSuggestions![keyVal.key.text]; + } + + if (currentSuggestions == null) { + return const Iterable.empty(); + } + + return currentSuggestions.where((String option) { + return option.toLowerCase().contains(textEditingValue.text.toLowerCase()); + }); + }, + onSelected: (String selection) { + textController.text = selection; + notifierChange(); + }, + fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, + FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { + return TextFormField( + controller: textEditingController, + focusNode: fieldFocusNode, + onFieldSubmitted: (String value) { + onFieldSubmitted(); + }, + onChanged: (val) { + if (isKey) setState(() {}); + notifierChange(); + }, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + minLines: 1, + maxLines: 3, + decoration: InputDecoration( + isDense: true, + hintStyle: const TextStyle(color: Colors.grey), + contentPadding: const EdgeInsets.fromLTRB(5, 13, 5, 13), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 1.5)), + border: InputBorder.none, + hintText: isKey ? "Key" : "Value")); + }, + optionsViewBuilder: + (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final String option = options.elementAt(index); + return InkWell( + onTap: () { + onSelected(option); + }, + child: Container( + padding: const EdgeInsets.all(10.0), + child: _buildHighlightText(option, textController.text), + ), + ); + }, + ), + ), + ), + ); + }, + )); + } + return Container( padding: const EdgeInsets.only(right: 5), child: TextFormField( readOnly: widget.readOnly, style: TextStyle(fontSize: 13, fontWeight: isKey ? FontWeight.w500 : null), - controller: val, + controller: textController, onChanged: (val) => notifierChange(), minLines: 1, maxLines: 3, @@ -715,6 +809,16 @@ class KeyValState extends State with AutomaticKeepAliveClientMixin } Widget _row(KeyVal keyVal, Widget? op) { + if (widget.suggestions != null) { + keyVal.keyFocusNode ??= FocusNode(); + } + + Map>? valueSuggestions; + if (widget.suggestions != null) { + keyVal.valueFocusNode ??= FocusNode(); + valueSuggestions = HttpHeaders.commonHeaderValues; + } + return Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ if (op != null) Checkbox( @@ -726,11 +830,31 @@ class KeyValState extends State with AutomaticKeepAliveClientMixin notifierChange(); }), Container(width: 5), - Expanded(flex: 4, child: _cell(keyVal.key, isKey: true)), + Expanded( + flex: 4, child: _cell(keyVal, isKey: true, suggestions: widget.suggestions, focusNode: keyVal.keyFocusNode)), const Text(":", style: TextStyle(color: Colors.deepOrangeAccent)), const SizedBox(width: 8), - Expanded(flex: 6, child: _cell(keyVal.value)), + Expanded(flex: 6, child: _cell(keyVal, focusNode: keyVal.valueFocusNode, valueSuggestions: valueSuggestions)), op ?? const SizedBox() ]); } + + Widget _buildHighlightText(String text, String query) { + if (query.isEmpty) { + return Text(text); + } + + int index = text.toLowerCase().indexOf(query.toLowerCase()); + if (index < 0) { + return Text(text); + } + + return Text.rich(TextSpan(children: [ + TextSpan(text: text.substring(0, index)), + TextSpan( + text: text.substring(index, index + query.length), + style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)), + TextSpan(text: text.substring(index + query.length)) + ])); + } } diff --git a/lib/ui/mobile/request/request_editor.dart b/lib/ui/mobile/request/request_editor.dart index 5892f04..086c994 100644 --- a/lib/ui/mobile/request/request_editor.dart +++ b/lib/ui/mobile/request/request_editor.dart @@ -34,7 +34,6 @@ import 'package:proxypin/ui/mobile/request/request_editor_source.dart'; import '../../component/http_method_popup.dart'; - /// @author wanghongen class MobileRequestEditor extends StatefulWidget { final HttpRequest? request; @@ -92,7 +91,10 @@ class RequestEditorState extends State with SingleTickerPro void initState() { super.initState(); - tabController = TabController(length: tabs.length, vsync: this, initialIndex: widget.source == RequestEditorSource.breakpointResponse ? 1 : 0); + tabController = TabController( + length: tabs.length, + vsync: this, + initialIndex: widget.source == RequestEditorSource.breakpointResponse ? 1 : 0); request = widget.request; response = widget.response; if (widget.request == null) { @@ -210,7 +212,7 @@ class RequestEditorState extends State with SingleTickerPro style: TextStyle( color: response?.status.isSuccessful() == true ? Colors.blue : Colors.red)) ]), - readOnly: widget.source == RequestEditorSource.breakpointRequest, + readOnly: widget.source != RequestEditorSource.breakpointResponse, message: response); }), ], @@ -333,7 +335,7 @@ class _HttpState extends State<_HttpWidget> with AutomaticKeepAliveClientMixin { } } - change(HttpMessage? message) { + void change(HttpMessage? message) { this.message = message; body = message?.bodyAsString; headerKey.currentState?.refreshParam(message?.headers.getHeaders()); @@ -367,6 +369,7 @@ class _HttpState extends State<_HttpWidget> with AutomaticKeepAliveClientMixin { title: "Headers", params: message?.headers.getHeaders() ?? initHeader, key: headerKey, + suggestions: HttpHeaders.commonHeaderKeys, readOnly: widget.readOnly), // 请求头 const SizedBox(height: 10), @@ -492,9 +495,16 @@ class KeyValWidget extends StatefulWidget { final bool readOnly; //只读 final UrlQueryNotifier? paramNotifier; final bool expanded; + final List? suggestions; const KeyValWidget( - {super.key, this.params, this.readOnly = false, this.paramNotifier, required this.title, this.expanded = true}); + {super.key, + this.params, + this.readOnly = false, + this.paramNotifier, + required this.title, + this.expanded = true, + this.suggestions}); @override State createState() { @@ -523,7 +533,7 @@ class KeyValState extends State { } //监听url发生变化 更改表单 - onChange(String value) { + void onChange(String value) { var query = value.split("&"); int index = 0; while (index < query.length) { @@ -544,7 +554,7 @@ class KeyValState extends State { setState(() {}); } - notifierChange() { + void notifierChange() { if (widget.paramNotifier == null) return; String query = _params .where((e) => e.enabled && e.key.isNotEmpty) @@ -568,7 +578,7 @@ class KeyValState extends State { } //刷新param - refreshParam(Map>? headers) { + void refreshParam(Map>? headers) { _params.clear(); setState(() { headers?.forEach((name, values) { @@ -630,7 +640,7 @@ class KeyValState extends State { } /// 修改请求头 - modifyParam(KeyVal keyVal) { + void modifyParam(KeyVal keyVal) { //隐藏输入框焦点 hideKeyword(context); String headerName = keyVal.key; @@ -638,42 +648,164 @@ class KeyValState extends State { showDialog( context: context, builder: (ctx) { - return AlertDialog( - titlePadding: const EdgeInsets.only(left: 25, top: 10), - actionsPadding: const EdgeInsets.only(right: 10, bottom: 10), - title: Text(localizations.modifyRequestHeader, style: const TextStyle(fontSize: 18)), - content: Wrap( - children: [ - TextFormField( - minLines: 1, - maxLines: 3, - initialValue: headerName, - decoration: InputDecoration(labelText: localizations.headerName), - onChanged: (value) => headerName = value, - ), - TextFormField( - minLines: 1, - maxLines: 8, - initialValue: val, - decoration: InputDecoration(labelText: localizations.value), - onChanged: (value) => val = value, - ) + return StatefulBuilder(builder: (context, setState) { + return AlertDialog( + titlePadding: const EdgeInsets.only(left: 25, top: 10), + actionsPadding: const EdgeInsets.only(right: 10, bottom: 10), + title: Text(localizations.modifyRequestHeader, style: const TextStyle(fontSize: 18)), + content: Wrap( + children: [ + if (widget.suggestions != null) + Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + return widget.suggestions!.where((String option) { + return option.toLowerCase().contains(textEditingValue.text.toLowerCase()); + }); + }, + onSelected: (String selection) { + setState(() { + headerName = selection; + }); + }, + fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, + FocusNode focusNode, VoidCallback onFieldSubmitted) { + return TextFormField( + controller: textEditingController, + focusNode: focusNode, + minLines: 1, + maxLines: 3, + decoration: InputDecoration(labelText: localizations.headerName), + onChanged: (value) { + headerName = value; + setState(() {}); + }, + ); + }, + initialValue: TextEditingValue(text: headerName), + optionsViewBuilder: + (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final String option = options.elementAt(index); + return InkWell( + onTap: () { + onSelected(option); + }, + child: Container( + padding: const EdgeInsets.all(10.0), + child: _buildHighlightText(option, headerName), + ), + ); + }, + ), + ), + ), + ); + }, + ) + else + TextFormField( + minLines: 1, + maxLines: 3, + initialValue: headerName, + decoration: InputDecoration(labelText: localizations.headerName), + onChanged: (value) { + headerName = value; + setState(() {}); + }, + ), + if (HttpHeaders.commonHeaderValues.containsKey(headerName)) + Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + return HttpHeaders.commonHeaderValues[headerName]!.where((String option) { + return option.toLowerCase().contains(textEditingValue.text.toLowerCase()); + }); + }, + onSelected: (String selection) { + val = selection; + }, + fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, + FocusNode focusNode, VoidCallback onFieldSubmitted) { + return TextFormField( + controller: textEditingController, + focusNode: focusNode, + minLines: 1, + maxLines: 8, + decoration: InputDecoration(labelText: localizations.value), + onChanged: (value) => val = value, + ); + }, + initialValue: TextEditingValue(text: val), + optionsViewBuilder: + (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final String option = options.elementAt(index); + return InkWell( + onTap: () { + onSelected(option); + }, + child: Container( + padding: const EdgeInsets.all(10.0), + child: _buildHighlightText(option, val), + ), + ); + }, + ), + ), + ), + ); + }, + ) + else + TextFormField( + minLines: 1, + maxLines: 8, + initialValue: val, + decoration: InputDecoration(labelText: localizations.value), + onChanged: (value) => val = value, + ) + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(localizations.cancel)), + TextButton( + onPressed: () { + this.setState(() { + keyVal.key = headerName; + keyVal.value = val; + }); + notifierChange(); + Navigator.pop(ctx); + }, + child: Text(localizations.modify)), ], - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text(localizations.cancel)), - TextButton( - onPressed: () { - setState(() { - keyVal.key = headerName; - keyVal.value = val; - }); - notifierChange(); - Navigator.pop(ctx); - }, - child: Text(localizations.modify)), - ], - ); + ); + }); }); } @@ -718,4 +850,23 @@ class KeyValState extends State { ), ]); } + + Widget _buildHighlightText(String text, String query) { + if (query.isEmpty) { + return Text(text); + } + + int index = text.toLowerCase().indexOf(query.toLowerCase()); + if (index < 0) { + return Text(text); + } + + return Text.rich(TextSpan(children: [ + TextSpan(text: text.substring(0, index)), + TextSpan( + text: text.substring(index, index + query.length), + style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)), + TextSpan(text: text.substring(index + query.length)) + ])); + } } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 52c3d50..ad1b2a5 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -621,7 +621,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; + MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 1.1.8; PRODUCT_BUNDLE_IDENTIFIER = com.proxy.pin; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -762,7 +762,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; + MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 1.1.8; PRODUCT_BUNDLE_IDENTIFIER = com.proxy.pin; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -791,7 +791,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; + MACOSX_DEPLOYMENT_TARGET = 10.15; MARKETING_VERSION = 1.1.8; PRODUCT_BUNDLE_IDENTIFIER = com.proxy.pin; PROVISIONING_PROFILE_SPECIFIER = "";