diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cbdf63..9cff798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 此项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html) 规范。 +## [1.1.0] - 2026-01-20 + +### 新增 (New) +- **密码保护 (Password Protection)**: 支持设置 `password` 为面板添加安全锁,防止非授权查看。 +- **内置拦截器 (Built-in Interceptor)**: 新增 `YxNetInspectorDioInterceptor`,一行代码集成 Dio。 +- **cURL 支持**: 详情页新增 cURL 命令生成与复制功能。 +- **Headers 详情**: 详情页新增独立的请求/响应 Headers 展示卡片。 +- **API 增强**: `logResponse` 新增 `responseHeaders` 参数。 + +### 优化 (Improvements) +- 优化长文本展示,支持自动换行与滚动。 +- 修复非全屏模式下 Header 溢出问题。 + ## [1.0.0] - 2024-12-20 ### 新增 diff --git a/example/pubspec.lock b/example/pubspec.lock index 3234acf..069d69f 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -41,6 +41,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + dio: + dependency: transitive + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" fake_async: dependency: transitive description: @@ -139,6 +155,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" path: dependency: transitive description: @@ -238,7 +262,7 @@ packages: path: ".." relative: true source: path - version: "1.0.3" + version: "1.0.4" sdks: dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/lib/src/controller/yx_net_inspector_controller.dart b/lib/src/controller/yx_net_inspector_controller.dart index 6253772..cc2f84b 100644 --- a/lib/src/controller/yx_net_inspector_controller.dart +++ b/lib/src/controller/yx_net_inspector_controller.dart @@ -14,11 +14,9 @@ class YxNetInspectorController extends ChangeNotifier { /// 配置信息 late YxNetInspectorConfig _config; - YxNetInspectorConfig get config => _config; /// 网络日志列表 final List _logs = []; - List get logs => List.unmodifiable(_logs); /// 统计数据 int _requestCount = 0; @@ -33,16 +31,54 @@ class YxNetInspectorController extends ChangeNotifier { /// 悬浮球显示状态 bool _showFloatingBall = true; - bool get showFloatingBall => _showFloatingBall && _config.isEnabled; /// 面板显示状态 bool _isPanelVisible = false; - bool get isPanelVisible => _isPanelVisible && _config.isEnabled; + bool _isUnlocked = true; // 默认为已解锁 - /// 初始化控制器配置 + /// 初始化控制器 void initialize(YxNetInspectorConfig config) { _config = config; _showFloatingBall = config.showFloatingBall; + + // 如果配置了密码,初始化为锁定状态 + if (config.password != null && config.password!.isNotEmpty) { + _isUnlocked = false; + } + } + + /// 获取当前配置 + YxNetInspectorConfig get config => _config; + + /// 获取日志列表 + List get logs => List.unmodifiable(_logs); + + /// 获取悬浮球可见性 + bool get showFloatingBall => _showFloatingBall; + + /// 获取面板可见性 + bool get isPanelVisible => _isPanelVisible; + + /// 获取是否已解锁 + bool get isUnlocked => _isUnlocked; + + /// 尝试解锁 + /// 返回是否成功 + Future unlock(String password) async { + if (_config.password == password) { + _isUnlocked = true; + notifyListeners(); + return true; + } + return false; + } + + /// 锁定检查器 + void lock() { + if (_config.password != null) { + _isUnlocked = false; + notifyListeners(); + } } /// 显示面板 @@ -97,6 +133,7 @@ class YxNetInspectorController extends ChangeNotifier { int? statusCode, dynamic responseData, Duration? duration, + Map? responseHeaders, }) { if (!_config.isEnabled) return; @@ -110,6 +147,7 @@ class YxNetInspectorController extends ChangeNotifier { final updatedLog = originalLog.copyWith( statusCode: statusCode, responseData: responseData, + responseHeaders: responseHeaders, duration: duration, isSuccess: isSuccess, status: isSuccess @@ -235,6 +273,43 @@ class YxNetInspectorController extends ChangeNotifier { }).toList(); } + /// 获取所有日志的字符串表示 (用于导出/分享) + String getAllLogsAsString() { + final buffer = StringBuffer(); + buffer.writeln('YxNetInspector Exported Logs'); + buffer.writeln('Generated at: ${DateTime.now()}'); + buffer.writeln('Total Requests: $requestCount'); + buffer.writeln('Success: $successCount, Failed: $_errorCount'); + buffer.writeln('----------------------------------------'); + + for (final log in _logs) { + buffer.writeln('[${log.method}] ${log.url}'); + buffer.writeln('Status: ${log.statusCode ?? "N/A"}'); + buffer.writeln('Time: ${log.timestamp}'); + buffer.writeln('Duration: ${log.formattedDuration}'); + + if (log.headers != null && log.headers!.isNotEmpty) { + buffer.writeln('Headers: ${log.headers}'); + } + + if (log.requestData != null) { + buffer.writeln('Request Data: ${log.requestData}'); + } + + if (log.responseData != null) { + buffer.writeln('Response Data: ${log.responseData}'); + } + + if (log.errorMessage != null) { + buffer.writeln('Error: ${log.errorMessage}'); + } + + buffer.writeln('----------------------------------------'); + } + + return buffer.toString(); + } + /// 限制日志数量 void _trimLogs() { if (_logs.length > _config.maxLogs) { diff --git a/lib/src/interceptors/dio_interceptor.dart b/lib/src/interceptors/dio_interceptor.dart new file mode 100644 index 0000000..bbb8357 --- /dev/null +++ b/lib/src/interceptors/dio_interceptor.dart @@ -0,0 +1,61 @@ +import 'package:dio/dio.dart'; +import 'package:yx_net_inspector/src/yx_net_inspector_app.dart'; + +/// 网络检查器 Dio 拦截器 +/// +/// 用于自动记录 Dio 请求和响应日志到 YxNetInspector +class YxNetInspectorDioInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + // 记录请求 + YxNetInspectorGlobal.logRequest( + id: options.hashCode.toString(), + method: options.method, + url: options.uri.toString(), + headers: options.headers, + requestData: options.data, + queryParameters: options.queryParameters, + ); + handler.next(options); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + // 记录响应 + try { + // 计算请求耗时(如果可能) + // 注意:Dio 的 RequestOptions 不带时间戳,我们这里可能依赖 Controller 内部的计时, + // 或者我们可以扩展 RequestOptions 来携带时间戳 (extra) + final requestOptions = response.requestOptions; + + YxNetInspectorGlobal.logResponse( + id: requestOptions.hashCode.toString(), + statusCode: response.statusCode, + responseData: response.data, + responseHeaders: response.headers.map, + // 这里没有准确的耗时,因为 logRequest 记录了开始时间。 + // Controller 会根据 ID 自动计算耗时:Duration = Now - StartTime + ); + } catch (e) { + // 忽略日志记录错误,避免影响业务 + } + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + // 记录错误 + try { + final requestOptions = err.requestOptions; + + YxNetInspectorGlobal.logError( + id: requestOptions.hashCode.toString(), + error: err.message ?? err.toString(), + statusCode: err.response?.statusCode, + ); + } catch (e) { + // 忽略日志记录错误 + } + handler.next(err); + } +} diff --git a/lib/src/models/inspector_config.dart b/lib/src/models/inspector_config.dart index 239fe07..bd8ce8a 100644 --- a/lib/src/models/inspector_config.dart +++ b/lib/src/models/inspector_config.dart @@ -15,6 +15,7 @@ class YxNetInspectorConfig { this.draggable = true, this.showBadge = true, this.autoHide = false, + this.password, }); /// 是否显示悬浮球 @@ -47,6 +48,9 @@ class YxNetInspectorConfig { /// 是否自动隐藏悬浮球 final bool autoHide; + /// 访问检查器面板所需的密码(如果为空则无需密码) + final String? password; + /// 根据当前模式判断检查器是否应该启用 bool get isEnabled { if (kDebugMode) { @@ -68,6 +72,7 @@ class YxNetInspectorConfig { bool? draggable, bool? showBadge, bool? autoHide, + String? password, }) { return YxNetInspectorConfig( showFloatingBall: showFloatingBall ?? this.showFloatingBall, @@ -80,6 +85,7 @@ class YxNetInspectorConfig { draggable: draggable ?? this.draggable, showBadge: showBadge ?? this.showBadge, autoHide: autoHide ?? this.autoHide, + password: password ?? this.password, ); } @@ -95,7 +101,8 @@ class YxNetInspectorConfig { 'initialPosition: $initialPosition, ' 'draggable: $draggable, ' 'showBadge: $showBadge, ' - 'autoHide: $autoHide' + 'autoHide: $autoHide, ' + 'password: ${password != null ? "***" : "null"}' ')'; } @@ -112,7 +119,8 @@ class YxNetInspectorConfig { other.initialPosition == initialPosition && other.draggable == draggable && other.showBadge == showBadge && - other.autoHide == autoHide; + other.autoHide == autoHide && + other.password == password; } @override @@ -128,6 +136,7 @@ class YxNetInspectorConfig { draggable, showBadge, autoHide, + password, ); } } diff --git a/lib/src/models/network_log_entry.dart b/lib/src/models/network_log_entry.dart index c429987..750c6f3 100644 --- a/lib/src/models/network_log_entry.dart +++ b/lib/src/models/network_log_entry.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; /// 网络请求状态枚举 @@ -22,6 +24,7 @@ class NetworkLogEntry { required this.timestamp, required this.isSuccess, this.headers, + this.responseHeaders, this.requestData, this.queryParameters, this.statusCode, @@ -34,6 +37,7 @@ class NetworkLogEntry { final String method; final String url; final Map? headers; + final Map? responseHeaders; final dynamic requestData; final Map? queryParameters; final int? statusCode; @@ -125,12 +129,39 @@ class NetworkLogEntry { } } + /// 生成 cURL 命令用于调试 (跨平台兼容: macOS/Linux/Windows) + String toCurlCommand() { + final buffer = StringBuffer('curl -X $method "$url"'); + headers?.forEach((key, value) { + // 转义双引号以兼容所有平台 + final escapedValue = value.toString().replaceAll('"', '\\"'); + buffer.write(' -H "$key: $escapedValue"'); + }); + if (requestData != null) { + String body; + if (requestData is String) { + body = requestData as String; + } else { + try { + body = jsonEncode(requestData); + } catch (_) { + body = requestData.toString(); + } + } + // 转义双引号和反斜杠 + final escapedBody = body.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + buffer.write(' -d "$escapedBody"'); + } + return buffer.toString(); + } + /// 创建副本并更新字段 NetworkLogEntry copyWith({ String? id, String? method, String? url, Map? headers, + Map? responseHeaders, dynamic requestData, Map? queryParameters, int? statusCode, @@ -146,6 +177,7 @@ class NetworkLogEntry { method: method ?? this.method, url: url ?? this.url, headers: headers ?? this.headers, + responseHeaders: responseHeaders ?? this.responseHeaders, requestData: requestData ?? this.requestData, queryParameters: queryParameters ?? this.queryParameters, statusCode: statusCode ?? this.statusCode, diff --git a/lib/src/widgets/inspector_panel.dart b/lib/src/widgets/inspector_panel.dart index 137757b..f6468d2 100644 --- a/lib/src/widgets/inspector_panel.dart +++ b/lib/src/widgets/inspector_panel.dart @@ -9,6 +9,7 @@ import 'package:yx_net_inspector/src/widgets/panel/inspector_header.dart'; import 'package:yx_net_inspector/src/widgets/panel/inspector_log_list.dart'; import 'package:yx_net_inspector/src/widgets/panel/inspector_search_bar.dart'; import 'package:yx_net_inspector/src/widgets/panel/inspector_statistics.dart'; +import 'package:yx_net_inspector/src/widgets/panel/password_dialog.dart'; /// 网络检查器面板组件 class YxInspectorPanel extends StatefulWidget { @@ -142,6 +143,16 @@ ${log.duration != null ? '- 结束时间: ${_formatDateTime(log.timestamp.add(lo return ListenableBuilder( listenable: widget.controller, builder: (context, child) { + // 如果未解锁,显示密码解锁界面 + if (!widget.controller.isUnlocked) { + return Center( + child: PasswordDialog( + theme: widget.theme, + onUnlock: widget.controller.unlock, + ), + ); + } + Widget content; if (_showDetailPage && _selectedLog != null) { @@ -174,6 +185,17 @@ ${log.duration != null ? '- 结束时间: ${_formatDateTime(log.timestamp.add(lo isFullScreen: _isFullScreen, onToggleFullScreen: _toggleFullScreen, onClearLogs: widget.controller.clearLogs, + onCopyLogs: () { + final logs = widget.controller.getAllLogsAsString(); + Clipboard.setData(ClipboardData(text: logs)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Logs copied to clipboard'), + duration: Duration(milliseconds: 1500), + ), + ); + }, onClose: widget.onClose, ), InspectorStatistics( diff --git a/lib/src/widgets/log_detail_page.dart b/lib/src/widgets/log_detail_page.dart index 9c31d69..a452430 100644 --- a/lib/src/widgets/log_detail_page.dart +++ b/lib/src/widgets/log_detail_page.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:yx_net_inspector/src/models/inspector_theme.dart'; import 'package:yx_net_inspector/src/models/network_log_entry.dart'; @@ -21,10 +22,21 @@ class YxLogDetailPage extends StatelessWidget { children: [ _buildBasicInfoCard(context), const SizedBox(height: 16), + _buildCurlCard(context), + const SizedBox(height: 16), _buildRequestInfoCard(), const SizedBox(height: 16), + if (log.headers != null && log.headers!.isNotEmpty) ...[ + _buildHeadersCard('请求头 (Request Headers)', log.headers!), + const SizedBox(height: 16), + ], _buildResponseInfoCard(), const SizedBox(height: 16), + if (log.responseHeaders != null && + log.responseHeaders!.isNotEmpty) ...[ + _buildHeadersCard('响应头 (Response Headers)', log.responseHeaders!), + const SizedBox(height: 16), + ], if (log.errorMessage != null) ...[ _buildErrorInfoCard(), const SizedBox(height: 16), @@ -387,4 +399,123 @@ class YxLogDetailPage extends StatelessWidget { '${dateTime.minute.toString().padLeft(2, '0')}:' '${dateTime.second.toString().padLeft(2, '0')}'; } + + Widget _buildHeadersCard(String title, Map headers) { + return Card( + color: theme.cardColor, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.list_alt, color: theme.primaryColor), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: theme.textColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 200), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.primaryColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(4), + ), + child: SingleChildScrollView( + child: SelectableText( + headers.entries.map((e) => '${e.key}: ${e.value}').join('\n'), + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: theme.textColor, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCurlCard(BuildContext context) { + final curlCommand = log.toCurlCommand(); + return Card( + color: theme.cardColor, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.terminal, color: theme.warningColor), + const SizedBox(width: 8), + Expanded( + child: Text( + 'cURL 命令', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: theme.textColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: Icon(Icons.copy, color: theme.primaryColor, size: 20), + tooltip: '复制 cURL', + onPressed: () { + Clipboard.setData(ClipboardData(text: curlCommand)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('cURL 已复制到剪贴板'), + duration: Duration(seconds: 2), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 150), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.warningColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + child: SelectableText( + curlCommand, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: theme.textColor, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } } diff --git a/lib/src/widgets/panel/inspector_detail_header.dart b/lib/src/widgets/panel/inspector_detail_header.dart index 4a4ec29..a6e8175 100644 --- a/lib/src/widgets/panel/inspector_detail_header.dart +++ b/lib/src/widgets/panel/inspector_detail_header.dart @@ -50,7 +50,6 @@ class InspectorDetailHeader extends StatelessWidget { return isFullScreen ? AppBar( - title: const Text('请求详情'), backgroundColor: theme.primaryColor, foregroundColor: Colors.white, // excludeHeaderSemantics: false diff --git a/lib/src/widgets/panel/inspector_header.dart b/lib/src/widgets/panel/inspector_header.dart index 9f9b245..ee8c386 100644 --- a/lib/src/widgets/panel/inspector_header.dart +++ b/lib/src/widgets/panel/inspector_header.dart @@ -8,6 +8,7 @@ class InspectorHeader extends StatelessWidget { required this.onToggleFullScreen, required this.onClearLogs, required this.onClose, + this.onCopyLogs, super.key, }); @@ -16,6 +17,7 @@ class InspectorHeader extends StatelessWidget { final VoidCallback onToggleFullScreen; final VoidCallback onClearLogs; final VoidCallback onClose; + final VoidCallback? onCopyLogs; @override Widget build(BuildContext context) { @@ -33,6 +35,12 @@ class InspectorHeader extends StatelessWidget { icon: const Icon(Icons.clear_all, color: Colors.white), tooltip: '清空日志', ), + if (onCopyLogs != null) + IconButton( + onPressed: onCopyLogs, + icon: const Icon(Icons.copy, color: Colors.white), + tooltip: '复制所有日志', + ), IconButton( onPressed: onClose, icon: const Icon(Icons.close, color: Colors.white), @@ -60,17 +68,6 @@ class InspectorHeader extends StatelessWidget { ), child: Row( children: [ - const Icon(Icons.network_check, color: Colors.white, size: 24), - const SizedBox(width: 8), - const Text( - '网络检查器', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), ...actions, ], ), diff --git a/lib/src/widgets/panel/password_dialog.dart b/lib/src/widgets/panel/password_dialog.dart new file mode 100644 index 0000000..2be07ee --- /dev/null +++ b/lib/src/widgets/panel/password_dialog.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; + +/// 密码输入对话框 +class PasswordDialog extends StatefulWidget { + const PasswordDialog({ + required this.onUnlock, + required this.theme, + super.key, + }); + + /// 解锁回调 + final Future Function(String password) onUnlock; + + /// 主题 + final YxNetInspectorTheme theme; + + @override + State createState() => _PasswordDialogState(); +} + +class _PasswordDialogState extends State { + final _passwordController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + bool _obscureText = true; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _handleUnlock() async { + final password = _passwordController.text; + if (password.isEmpty) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final success = await widget.onUnlock(password); + + if (mounted) { + if (!success) { + setState(() { + _isLoading = false; + _errorMessage = '密码错误,请重试'; + }); + } + // If success, the dialog (or lock screen) will typically be removed by the parent + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: widget.theme.backgroundColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + width: 300, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lock_outline, + size: 48, + color: widget.theme.primaryColor, + ), + const SizedBox(height: 16), + Text( + '安全访问', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: widget.theme.textColor, + ), + ), + const SizedBox(height: 8), + Text( + '请输入密码以查看网络日志', + style: TextStyle( + fontSize: 14, + color: widget.theme.secondaryTextColor, + ), + ), + const SizedBox(height: 24), + TextField( + controller: _passwordController, + obscureText: _obscureText, + decoration: InputDecoration( + hintText: '输入密码', + errorText: _errorMessage, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.vpn_key), + suffixIcon: IconButton( + icon: Icon( + _obscureText ? Icons.visibility : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + ), + ), + onSubmitted: (_) => _handleUnlock(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleUnlock, + style: ElevatedButton.styleFrom( + backgroundColor: widget.theme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('解锁'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/yx_net_inspector_app.dart b/lib/src/yx_net_inspector_app.dart index b37b2b4..25ab75f 100644 --- a/lib/src/yx_net_inspector_app.dart +++ b/lib/src/yx_net_inspector_app.dart @@ -7,9 +7,9 @@ import 'package:yx_net_inspector/src/widgets/inspector_panel.dart'; /// 包装你的应用并提供网络检查功能的主要组件 class YxNetInspector extends StatefulWidget { - const YxNetInspector({ - required this.child, super.key, + required this.child, + super.key, this.config = const YxNetInspectorConfig(), this.theme = const YxNetInspectorTheme(), }); @@ -42,6 +42,7 @@ class YxNetInspector extends StatefulWidget { child: child, ); } + /// 你的应用组件 final Widget child; @@ -170,12 +171,14 @@ class YxNetInspectorGlobal { int? statusCode, dynamic responseData, Duration? duration, + Map? responseHeaders, }) { controller.logResponse( id: id, statusCode: statusCode, responseData: responseData, duration: duration, + responseHeaders: responseHeaders, ); } diff --git a/lib/yx_net_inspector.dart b/lib/yx_net_inspector.dart index e403fbc..22c6562 100644 --- a/lib/yx_net_inspector.dart +++ b/lib/yx_net_inspector.dart @@ -1,6 +1,6 @@ - // 核心导出 export 'src/controller/yx_net_inspector_controller.dart'; +export 'src/interceptors/dio_interceptor.dart'; export 'src/models/inspector_config.dart'; export 'src/models/inspector_theme.dart'; export 'src/models/network_log_entry.dart'; diff --git a/pubspec.lock b/pubspec.lock index 8c256a9..668ddfa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" fake_async: dependency: transitive description: @@ -59,6 +75,14 @@ packages: description: flutter source: sdk version: "0.0.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -107,6 +131,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" path: dependency: transitive description: @@ -168,6 +200,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -192,6 +232,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" sdks: dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index b4861fa..ebcfc58 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: yx_net_inspector description: A powerful network inspector with floating debug ball for Flutter apps. Monitor HTTP requests, responses, and debug network issues in real-time. -version: 1.0.4 +version: 1.1.0 homepage: https://github.com/your-username/yx_net_inspector environment: @@ -10,6 +10,7 @@ environment: dependencies: flutter: sdk: flutter + dio: ^5.9.0 dev_dependencies: flutter_test: diff --git a/test/unit/dio_interceptor_test.dart b/test/unit/dio_interceptor_test.dart new file mode 100644 index 0000000..0f75912 --- /dev/null +++ b/test/unit/dio_interceptor_test.dart @@ -0,0 +1,97 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart'; +import 'package:yx_net_inspector/src/interceptors/dio_interceptor.dart'; +import 'package:yx_net_inspector/src/models/inspector_config.dart'; + +void main() { + group('YxNetInspectorDioInterceptor Tests', () { + late YxNetInspectorController controller; + late Dio dio; + + setUp(() { + controller = YxNetInspectorController.instance; + // Initialize controller with logging enabled (debug mode) + controller.initialize(const YxNetInspectorConfig(showInDebugMode: true)); + + dio = Dio(); + // Add our inspector interceptor FIRST + dio.interceptors.add(YxNetInspectorDioInterceptor()); + }); + + test('Should log request and response upon success', () async { + final initialLogCount = controller.logs.length; + + // Add a mock interceptor to return success response immediately + dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + // Allow request to proceed (so our inspector sees it) + handler.next(options); + }, + onError: (e, handler) => handler.next(e), + onResponse: (e, handler) => handler.next(e), + )); + + // Mock Adapter to avoid real network + dio.httpClientAdapter = _MockAdapter((options) { + return ResponseBody.fromString( + '{"message": "success"}', + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + }); + + await dio.get('https://example.com/api/test'); + + expect(controller.logs.length, greaterThan(initialLogCount)); + final latestLog = controller.logs.first; + + expect(latestLog.url, 'https://example.com/api/test'); + expect(latestLog.method, 'GET'); + expect(latestLog.statusCode, 200); + }); + + test('Should log error upon failure', () async { + final initialLogCount = controller.logs.length; + + // Mock Adapter to throw error + dio.httpClientAdapter = _MockAdapter((options) { + throw DioException( + requestOptions: options, + error: 'Network Error', + type: DioExceptionType.connectionError, + ); + }); + + try { + await dio.post('https://example.com/api/fail', data: {'foo': 'bar'}); + } catch (e) { + // Expected + } + + expect(controller.logs.length, greaterThan(initialLogCount)); + final latestLog = controller.logs.first; + + expect(latestLog.url, 'https://example.com/api/fail'); + expect(latestLog.method, 'POST'); + expect(latestLog.isSuccess, false); + }); + }); +} + +class _MockAdapter implements HttpClientAdapter { + final ResponseBody Function(RequestOptions options) _mockResponse; + + _MockAdapter(this._mockResponse); + + @override + Future fetch(RequestOptions options, + Stream>? requestStream, Future? cancelFuture) async { + return _mockResponse(options); + } + + @override + void close({bool force = false}) {} +} diff --git a/test/widget/inspector_panel_test.dart b/test/widget/inspector_panel_test.dart index d4e6294..49539e5 100644 --- a/test/widget/inspector_panel_test.dart +++ b/test/widget/inspector_panel_test.dart @@ -4,6 +4,7 @@ import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart import 'package:yx_net_inspector/src/models/inspector_config.dart'; import 'package:yx_net_inspector/src/models/inspector_theme.dart'; import 'package:yx_net_inspector/src/widgets/inspector_panel.dart'; +import 'package:yx_net_inspector/src/widgets/panel/inspector_log_list.dart'; void main() { group('YxInspectorPanel Widget Tests', () { @@ -34,20 +35,25 @@ void main() { ), ); - // 验证面板基本组件 - expect(find.text('网络检查器'), findsOneWidget); - expect(find.byIcon(Icons.network_check), findsOneWidget); + // 验证面板基本组件 (标题在非全屏模式已移除以节省空间) + // Title removed from non-fullscreen mode to save space expect(find.byIcon(Icons.close), findsOneWidget); }); testWidgets('应该显示统计信息', (WidgetTester tester) async { // 添加一些测试数据 controller.logRequest( - id: 'test-1', method: 'GET', url: 'https://example.com',); + id: 'test-1', + method: 'GET', + url: 'https://example.com', + ); controller.logResponse(id: 'test-1', statusCode: 200); controller.logRequest( - id: 'test-2', method: 'POST', url: 'https://example.com',); + id: 'test-2', + method: 'POST', + url: 'https://example.com', + ); controller.logError(id: 'test-2', error: '网络错误'); await tester.pumpWidget( @@ -65,12 +71,15 @@ void main() { await tester.pump(); // 验证统计信息 - expect(find.text('总计'), findsOneWidget); - expect(find.text('成功'), findsOneWidget); - expect(find.text('失败'), findsOneWidget); - expect(find.text('成功率'), findsOneWidget); - expect(find.text('2'), findsOneWidget); // 总请求数 - expect(find.text('1'), findsWidgets); // 成功和失败各1个 + // 验证统计信息 + expect(find.text('总请求: 2'), findsOneWidget); + expect(find.text('错误: 1'), findsOneWidget); + + // Removed old fields + // expect(find.text('总计'), findsOneWidget); + // expect(find.text('成功'), findsOneWidget); + // expect(find.text('失败'), findsOneWidget); + // expect(find.text('成功率'), findsOneWidget); }); testWidgets('应该显示搜索栏', (WidgetTester tester) async { @@ -89,16 +98,24 @@ void main() { // 验证搜索相关组件 expect(find.byType(TextField), findsOneWidget); expect(find.text('搜索请求...'), findsOneWidget); - expect(find.byType(FilterChip), findsOneWidget); - expect(find.text('仅显示错误'), findsOneWidget); + expect(find.text('搜索请求...'), findsOneWidget); + // FilterChip was removed/moved + // expect(find.byType(FilterChip), findsOneWidget); + // expect(find.text('仅显示错误'), findsOneWidget); }); testWidgets('搜索功能应该正常工作', (WidgetTester tester) async { // 添加测试数据 controller.logRequest( - id: 'user-1', method: 'GET', url: 'https://api.example.com/users',); + id: 'user-1', + method: 'GET', + url: 'https://api.example.com/users', + ); controller.logRequest( - id: 'post-1', method: 'POST', url: 'https://api.example.com/posts',); + id: 'post-1', + method: 'POST', + url: 'https://api.example.com/posts', + ); await tester.pumpWidget( MaterialApp( @@ -125,11 +142,17 @@ void main() { testWidgets('错误过滤器应该正常工作', (WidgetTester tester) async { // 添加成功和失败的请求 controller.logRequest( - id: 'success', method: 'GET', url: 'https://example.com',); + id: 'success', + method: 'GET', + url: 'https://example.com', + ); controller.logResponse(id: 'success', statusCode: 200); controller.logRequest( - id: 'error', method: 'POST', url: 'https://example.com',); + id: 'error', + method: 'POST', + url: 'https://example.com', + ); controller.logError(id: 'error', error: '网络错误'); await tester.pumpWidget( @@ -146,19 +169,21 @@ void main() { await tester.pump(); - // 点击错误过滤器 - await tester.tap(find.byType(FilterChip)); + // 点击错误过滤器 (It's a TextButton now) + await tester.tap(find.text('仅错误')); await tester.pump(); - // 验证过滤器被激活 - final filterChip = tester.widget(find.byType(FilterChip)); - expect(filterChip.selected, isTrue); + // 验证过滤器被激活 - Text changes to "显示全部" + expect(find.text('显示全部'), findsOneWidget); }); testWidgets('清空日志按钮应该正常工作', (WidgetTester tester) async { // 添加一些日志 controller.logRequest( - id: 'test', method: 'GET', url: 'https://example.com',); + id: 'test', + method: 'GET', + url: 'https://example.com', + ); await tester.pumpWidget( MaterialApp( @@ -272,7 +297,7 @@ void main() { await tester.pump(); // 验证日志条目显示 - expect(find.byType(Card), findsWidgets); + expect(find.byType(InspectorLogItem), findsWidgets); expect(find.text('GET'), findsOneWidget); }); @@ -300,7 +325,7 @@ void main() { await tester.pump(); // 点击日志条目 - await tester.tap(find.byType(InkWell).first); + await tester.tap(find.byType(InspectorLogItem).first); await tester.pumpAndSettle(); // 验证导航到详情页(这里主要验证没有报错) diff --git a/test/widget/password_protection_test.dart b/test/widget/password_protection_test.dart new file mode 100644 index 0000000..ef2225e --- /dev/null +++ b/test/widget/password_protection_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart'; +import 'package:yx_net_inspector/src/models/inspector_config.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; +import 'package:yx_net_inspector/src/widgets/inspector_panel.dart'; +import 'package:yx_net_inspector/src/widgets/panel/password_dialog.dart'; + +void main() { + group('Password Protection Tests', () { + late YxNetInspectorController controller; + const theme = YxNetInspectorTheme(); + + setUp(() { + controller = YxNetInspectorController.instance; + }); + + testWidgets('Panel should be unlocked by default (no password)', + (WidgetTester tester) async { + controller.initialize(const YxNetInspectorConfig(showInDebugMode: true)); + + expect(controller.isUnlocked, isTrue); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: YxInspectorPanel( + theme: theme, + controller: controller, + onClose: () {}, + ), + ), + ), + ); + + expect(find.byType(PasswordDialog), findsNothing); + expect(find.byIcon(Icons.close), findsOneWidget); // Normal panel content + }); + + testWidgets('Panel should be locked if password is set', + (WidgetTester tester) async { + controller.initialize(const YxNetInspectorConfig( + showInDebugMode: true, + password: '123', + )); + + expect(controller.isUnlocked, isFalse); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: YxInspectorPanel( + theme: theme, + controller: controller, + onClose: () {}, + ), + ), + ), + ); + + expect(find.byType(PasswordDialog), findsOneWidget); + expect(find.text('安全访问'), findsOneWidget); + expect( + find.byIcon(Icons.close), findsNothing); // Should hide panel content + }); + + testWidgets('Entering correct password should unlock panel', + (WidgetTester tester) async { + controller.initialize(const YxNetInspectorConfig( + showInDebugMode: true, + password: '123', + )); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: YxInspectorPanel( + theme: theme, + controller: controller, + onClose: () {}, + ), + ), + ), + ); + + // Enter correct password + await tester.enterText(find.byType(TextField), '123'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); // Start async unlock + await tester.pump(); // Rebuild with unlocked state + + expect(controller.isUnlocked, isTrue); + expect(find.byType(PasswordDialog), findsNothing); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('Entering wrong password should show error', + (WidgetTester tester) async { + controller.initialize(const YxNetInspectorConfig( + showInDebugMode: true, + password: '123', + )); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: YxInspectorPanel( + theme: theme, + controller: controller, + onClose: () {}, + ), + ), + ), + ); + + // Enter wrong password + await tester.enterText(find.byType(TextField), '000'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); // Start async unlock + await tester.pump(); // Rebuild + + expect(controller.isUnlocked, isFalse); + expect(find.byType(PasswordDialog), findsOneWidget); + expect(find.text('密码错误,请重试'), findsOneWidget); + }); + }); +}