From c2db582f6171ffbc70f4d63a633c46a9ae7f20a2 Mon Sep 17 00:00:00 2001 From: redth Date: Fri, 27 Feb 2026 09:49:01 -0500 Subject: [PATCH 1/8] Add network request monitoring feature - Add DevFlowHttpHandler (DelegatingHandler) for HTTP traffic interception - Auto-inject via ConfigureHttpClientDefaults in AddMauiDevFlowAgent() - Add NetworkRequestStore ring buffer (default 500 entries) with events - Add DevFlowHttp static helper for non-DI HttpClient wrapping - Add REST endpoints: /api/network, /api/network/{id}, /api/network/clear - Add WebSocket endpoint /ws/network for real-time streaming with replay - Add RFC 6455 WebSocket support to AgentHttpServer (upgrade, frames, ping/pong) - Add CLI commands: MAUI network (live monitor), network list, network detail, network clear - Add --json flag for JSONL streaming output (AI-friendly) - Add --host and --method filter options - Add Spectre.Console for rich terminal output - Add NetworkTestPage to SampleMauiApp for testing - Capture request/response headers, bodies (256KB limit), timing, status - Tested on Mac Catalyst, Android, and iOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .claude/skills/maui-ai-debugging/SKILL.md | 47 +++ AGENTS.md | 16 +- README.md | 12 + src/MauiDevFlow.Agent.Core/AgentHttpServer.cs | 190 ++++++++++- src/MauiDevFlow.Agent.Core/AgentOptions.cs | 17 + .../DevFlowAgentService.cs | 129 ++++++++ .../Network/DevFlowHttp.cs | 36 ++ .../Network/DevFlowHttpHandler.cs | 146 +++++++++ .../Network/NetworkRequestEntry.cs | 167 ++++++++++ .../Network/NetworkRequestStore.cs | 62 ++++ .../AgentServiceExtensions.cs | 14 + .../MauiDevFlow.Agent.csproj | 1 + src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj | 1 + src/MauiDevFlow.CLI/Program.cs | 308 ++++++++++++++++++ src/MauiDevFlow.Driver/AgentClient.cs | 89 +++++ src/SampleMauiApp/AppShell.xaml | 2 + src/SampleMauiApp/MauiProgram.cs | 4 + src/SampleMauiApp/NetworkTestPage.xaml | 61 ++++ src/SampleMauiApp/NetworkTestPage.xaml.cs | 70 ++++ 19 files changed, 1366 insertions(+), 6 deletions(-) create mode 100644 src/MauiDevFlow.Agent.Core/Network/DevFlowHttp.cs create mode 100644 src/MauiDevFlow.Agent.Core/Network/DevFlowHttpHandler.cs create mode 100644 src/MauiDevFlow.Agent.Core/Network/NetworkRequestEntry.cs create mode 100644 src/MauiDevFlow.Agent.Core/Network/NetworkRequestStore.cs create mode 100644 src/SampleMauiApp/NetworkTestPage.xaml create mode 100644 src/SampleMauiApp/NetworkTestPage.xaml.cs diff --git a/.claude/skills/maui-ai-debugging/SKILL.md b/.claude/skills/maui-ai-debugging/SKILL.md index 6738d18..625f8c4 100644 --- a/.claude/skills/maui-ai-debugging/SKILL.md +++ b/.claude/skills/maui-ai-debugging/SKILL.md @@ -234,6 +234,49 @@ maui-devflow MAUI recording stop **Options:** `--timeout ` (default 30), `--output ` (default `recording_.mp4`). Only one recording at a time — stop before starting a new one. +### 7. Network Request Monitoring + +Monitor HTTP requests made by the app in real-time. MauiDevFlow automatically intercepts +all `IHttpClientFactory`-based HTTP traffic via a `DelegatingHandler` — no app code changes +needed beyond the standard `AddMauiDevFlowAgent()` setup. + +```bash +# Live monitor — streams requests as they happen (Ctrl+C to stop) +maui-devflow MAUI network + +# JSONL streaming — machine-readable, one JSON object per line +maui-devflow MAUI network --json + +# One-shot: list recent captured requests +maui-devflow MAUI network list + +# Filter by method or host +maui-devflow MAUI network list --method POST +maui-devflow MAUI network list --host api.example.com + +# Full request/response details (headers + body) +maui-devflow MAUI network detail + +# Clear captured requests +maui-devflow MAUI network clear +``` + +**How it works:** +- A `DelegatingHandler` wraps the platform's HTTP handler (AndroidMessageHandler, + NSUrlSessionHandler, etc.), capturing request/response metadata, headers, and bodies +- Auto-injected via `ConfigureHttpClientDefaults` — works for all `IHttpClientFactory` clients +- For `new HttpClient()` outside DI, use `DevFlowHttp.CreateClient()` helper +- Bodies up to 256KB are captured (configurable via `AgentOptions.MaxNetworkBodySize`) +- A ring buffer (default 500 entries) stores recent requests in-memory + +**JSONL output** is ideal for AI parsing — pipe to `jq` or process programmatically: +```bash +maui-devflow MAUI network --json | jq 'select(.statusCode >= 400)' +``` + +**WebSocket streaming:** The live monitor uses WebSocket (`/ws/network`) for real-time push. +Connecting clients receive a replay of buffered history, then live entries as they arrive. + ## Command Reference ### maui-devflow MAUI (Native Agent) @@ -263,6 +306,10 @@ or `maui-devflow --agent-port 10224 MAUI status` — both are valid. | `MAUI recording start [--output path] [--timeout 30]` | Start screen recording. Default timeout 30s. Uses platform-native tools (adb screenrecord, xcrun simctl, screencapture, ffmpeg) | | `MAUI recording stop` | Stop active recording and save the video file | | `MAUI recording status` | Check if a recording is currently in progress | +| `MAUI network` | Live network monitor — streams HTTP requests in real-time (Ctrl+C to stop). Use `--json` for JSONL output | +| `MAUI network list [--host H] [--method M] [--json]` | One-shot: dump recent captured HTTP requests as table or JSONL | +| `MAUI network detail ` | Full request/response details: headers, body, timing | +| `MAUI network clear` | Clear the captured request buffer | Element IDs come from `MAUI tree` or `MAUI query`. AutomationId-based elements use their AutomationId directly. Others use generated hex IDs. When multiple elements share the same diff --git a/AGENTS.md b/AGENTS.md index e9e57d7..5716eb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ Agent architecture: └── Agent.Gtk (net10.0) ← GTK/Linux platform code (GirCore.Gtk-4.0) ``` -- **Single port** (default 9223, configurable via `.mauidevflow` file) serves both native MAUI commands and CDP -- **No WebSocket** — CDP uses HTTP POST request/response via Chobitsu's synchronous JS eval +- **Single port** (default 9223, configurable via `.mauidevflow` file) serves both native MAUI commands, CDP, and WebSocket connections +- **WebSocket support** — `/ws/network` streams captured HTTP requests in real-time; CDP still uses HTTP POST via Chobitsu - **Blazor→Agent wiring** uses reflection to avoid a direct package dependency between the two NuGet packages ## Building & Testing @@ -98,6 +98,18 @@ The CLI command `maui-devflow update-skill` downloads the latest skill files fro - **WebView logs**: JS `console.*` → intercepted by `console-intercept.js` → buffered in `window.__webviewLogs` → drained every 2s by native timer → written to same JSONL files with `source: "webview"` - Log entries have a `source` field (`"native"` or `"webview"`) for filtering via `?source=` query param +## Network Monitoring Architecture + +- **Interception**: `DevFlowHttpHandler` (DelegatingHandler) wraps platform-specific handlers (AndroidMessageHandler, NSUrlSessionHandler, etc.) +- **Auto-injection**: `ConfigureHttpClientDefaults` in `AddMauiDevFlowAgent()` registers the handler for all `IHttpClientFactory` clients +- **Non-DI clients**: `DevFlowHttp.CreateClient()` helper wraps `new HttpClient()` with the interceptor +- **Storage**: `NetworkRequestStore` (ConcurrentQueue ring buffer, default 500 entries) with `OnRequestCaptured` event +- **Body capture**: Text bodies up to 256KB (configurable), binary as base64. Truncated bodies flagged +- **REST API**: `/api/network` (list), `/api/network/{id}` (detail), `/api/network/clear` (clear buffer) +- **WebSocket**: `/ws/network` sends replay of buffered history on connect, then streams new entries live +- **CLI**: `MAUI network` (live TUI), `MAUI network --json` (JSONL streaming), `MAUI network list`, `MAUI network detail`, `MAUI network clear` +- **Apple namespace conflict**: Agent.Core's `Network` namespace conflicts with Apple's `Network` framework — use fully-qualified `MauiDevFlow.Agent.Core.Network.DevFlowHttpHandler` in AgentServiceExtensions.cs + ## Windows Support - **Agent**: Reports `platform: "WinUI"`, `idiom: "Desktop"`. Startup uses `OnActivated` lifecycle event because `Application.Current` is not available during `OnLaunched`. diff --git a/README.md b/README.md index 1a8b619..69f102e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ manually check the simulator. - **Native MAUI Automation** — Visual tree inspection, element interaction (tap, fill, clear), screenshots via in-app Agent - **Blazor WebView Debugging** — CDP bridge using Chobitsu for JavaScript evaluation, DOM manipulation, page navigation - **Unified Logging** — Native `ILogger` and WebView `console.log/warn/error` unified into a single log stream with source filtering +- **Network Request Monitoring** — Automatic HTTP traffic interception via DelegatingHandler with real-time WebSocket streaming, body capture, and JSONL output - **Broker Daemon** — Automatic port assignment and agent discovery for simultaneous multi-app debugging - **CLI Tool** (`maui-devflow`) — Scriptable commands for both native and Blazor automation - **Driver Library** — Platform-aware (Mac Catalyst, Android, iOS Simulator, Linux/GTK) orchestration @@ -158,6 +159,13 @@ maui-devflow MAUI recording start --timeout 30 # ... interact with the app ... maui-devflow MAUI recording stop +# Network request monitoring +maui-devflow MAUI network # live monitor (Ctrl+C to stop) +maui-devflow MAUI network --json # JSONL streaming for AI +maui-devflow MAUI network list # one-shot dump of recent requests +maui-devflow MAUI network detail # full headers + body for a request +maui-devflow MAUI network clear # clear captured requests + # Live edit native properties (no rebuild) maui-devflow MAUI set-property HeaderLabel TextColor "Tomato" maui-devflow MAUI set-property HeaderLabel FontSize "32" @@ -232,6 +240,10 @@ auto-assigned by the broker (range 10223–10899), or configurable via `.mauidev | `/api/property/{id}/{name}` | GET | Get property value | | `/api/property/{id}/{name}` | POST | Set property `{"value":"..."}` | | `/api/logs?limit=N&skip=N&source=S` | GET | Application logs (source: `native`, `webview`, or omit for all) | +| `/api/network?limit=N&host=H&method=M` | GET | Recent captured HTTP requests (summary) | +| `/api/network/{id}` | GET | Full request/response details (headers, body) | +| `/api/network/clear` | POST | Clear captured request buffer | +| `/ws/network` | WS | WebSocket stream of HTTP requests (replay + live) | | `/api/cdp` | POST | Forward CDP command to Blazor WebView | ## Project Structure diff --git a/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs b/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs index 93a284a..7f1f45d 100644 --- a/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs +++ b/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Sockets; +using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -18,6 +19,7 @@ public class AgentHttpServer : IDisposable private readonly int _port; private readonly Dictionary>> _getRoutes = new(); private readonly Dictionary>> _postRoutes = new(); + private readonly Dictionary> _wsRoutes = new(); public int Port => _port; public bool IsRunning => _listenTask != null && !_listenTask.IsCompleted; @@ -33,6 +35,9 @@ public void MapGet(string path, Func> handler) public void MapPost(string path, Func> handler) => _postRoutes[path.TrimEnd('/')] = handler; + public void MapWebSocket(string path, Func handler) + => _wsRoutes[path.TrimEnd('/')] = handler; + public void Start() { if (_disposed) throw new ObjectDisposedException(nameof(AgentHttpServer)); @@ -71,12 +76,50 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken ct) { try { - using (client) + var stream = client.GetStream(); + var request = await ReadRequestAsync(stream, ct).ConfigureAwait(false); + if (request == null) { - var stream = client.GetStream(); - var request = await ReadRequestAsync(stream, ct).ConfigureAwait(false); - if (request == null) return; + client.Dispose(); + return; + } + // Check for WebSocket upgrade + if (request.Headers.TryGetValue("Upgrade", out var upgrade) + && upgrade.Equals("websocket", StringComparison.OrdinalIgnoreCase) + && _wsRoutes.TryGetValue(request.Path, out var wsHandler)) + { + // Perform WebSocket handshake + if (!request.Headers.TryGetValue("Sec-WebSocket-Key", out var wsKey)) + { + client.Dispose(); + return; + } + + var acceptKey = ComputeWebSocketAcceptKey(wsKey); + var handshake = "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + $"Sec-WebSocket-Accept: {acceptKey}\r\n" + + "Access-Control-Allow-Origin: *\r\n" + + "\r\n"; + var handshakeBytes = Encoding.UTF8.GetBytes(handshake); + await stream.WriteAsync(handshakeBytes, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + + // Hand off to WebSocket handler (takes ownership of client — no using/dispose here) + _ = Task.Run(async () => + { + try { await wsHandler(client, stream, request, ct); } + catch { } + finally { client.Dispose(); } + }, ct); + return; + } + + // Normal HTTP flow + using (client) + { var response = await RouteRequestAsync(request).ConfigureAwait(false); await WriteResponseAsync(stream, response, ct).ConfigureAwait(false); } @@ -127,6 +170,16 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken ct) } } + // Parse headers + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i < lines.Length; i++) + { + if (string.IsNullOrWhiteSpace(lines[i])) break; + var colonIdx = lines[i].IndexOf(':'); + if (colonIdx > 0) + headers[lines[i][..colonIdx].Trim()] = lines[i][(colonIdx + 1)..].Trim(); + } + // Find body (after blank line) string? body = null; var blankLineIdx = raw.IndexOf("\r\n\r\n"); @@ -160,6 +213,7 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken ct) Method = method, Path = path.TrimEnd('/'), QueryParams = queryParams, + Headers = headers, Body = body }; } @@ -228,6 +282,133 @@ public void Dispose() _listener?.Stop(); _cts?.Dispose(); } + + // ── WebSocket helpers (RFC 6455) ── + + private static readonly byte[] WsMagicGuid = Encoding.UTF8.GetBytes("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + + private static string ComputeWebSocketAcceptKey(string clientKey) + { + var combined = Encoding.UTF8.GetBytes(clientKey.Trim() + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + var hash = SHA1.HashData(combined); + return Convert.ToBase64String(hash); + } + + /// + /// Sends a text frame over a WebSocket connection. + /// + public static async Task WebSocketSendTextAsync(NetworkStream stream, string text, CancellationToken ct) + { + var payload = Encoding.UTF8.GetBytes(text); + await WebSocketSendFrameAsync(stream, 0x81, payload, ct); // 0x81 = FIN + text opcode + } + + /// + /// Reads a text frame from a WebSocket connection. Returns null on close/error. + /// + public static async Task WebSocketReadTextAsync(NetworkStream stream, CancellationToken ct) + { + try + { + var header = new byte[2]; + if (await ReadExactAsync(stream, header, ct) < 2) return null; + + var fin = (header[0] & 0x80) != 0; + var opcode = header[0] & 0x0F; + var masked = (header[1] & 0x80) != 0; + var payloadLen = (long)(header[1] & 0x7F); + + if (opcode == 0x08) return null; // close frame + + if (payloadLen == 126) + { + var extLen = new byte[2]; + if (await ReadExactAsync(stream, extLen, ct) < 2) return null; + payloadLen = (extLen[0] << 8) | extLen[1]; + } + else if (payloadLen == 127) + { + var extLen = new byte[8]; + if (await ReadExactAsync(stream, extLen, ct) < 8) return null; + payloadLen = 0; + for (int i = 0; i < 8; i++) + payloadLen = (payloadLen << 8) | extLen[i]; + } + + byte[]? mask = null; + if (masked) + { + mask = new byte[4]; + if (await ReadExactAsync(stream, mask, ct) < 4) return null; + } + + if (payloadLen > 1_048_576) return null; // 1MB limit + + var payload = new byte[payloadLen]; + if (payloadLen > 0 && await ReadExactAsync(stream, payload, ct) < payloadLen) return null; + + if (mask != null) + { + for (int i = 0; i < payload.Length; i++) + payload[i] ^= mask[i % 4]; + } + + // Text frame (opcode 1) or continuation + if (opcode == 0x01 || opcode == 0x00) + return Encoding.UTF8.GetString(payload); + + // Ping → send pong + if (opcode == 0x09) + { + await WebSocketSendFrameAsync(stream, 0x8A, payload, ct); // pong + return await WebSocketReadTextAsync(stream, ct); // continue reading + } + + return null; + } + catch { return null; } + } + + private static async Task WebSocketSendFrameAsync(NetworkStream stream, byte opcodeWithFin, byte[] payload, CancellationToken ct) + { + using var ms = new MemoryStream(); + ms.WriteByte(opcodeWithFin); + + if (payload.Length < 126) + { + ms.WriteByte((byte)payload.Length); + } + else if (payload.Length <= 65535) + { + ms.WriteByte(126); + ms.WriteByte((byte)(payload.Length >> 8)); + ms.WriteByte((byte)(payload.Length & 0xFF)); + } + else + { + ms.WriteByte(127); + var len = (long)payload.Length; + for (int i = 7; i >= 0; i--) + ms.WriteByte((byte)((len >> (i * 8)) & 0xFF)); + } + + ms.Write(payload); + var frame = ms.ToArray(); + await stream.WriteAsync(frame, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + } + + private static async Task ReadExactAsync(NetworkStream stream, byte[] buffer, CancellationToken ct) + { + int totalRead = 0; + while (totalRead < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), ct); + if (read == 0) return totalRead; + totalRead += read; + } + return totalRead; + } } public class HttpRequest @@ -236,6 +417,7 @@ public class HttpRequest public string Path { get; set; } = "/"; public Dictionary QueryParams { get; set; } = new(); public Dictionary RouteParams { get; set; } = new(); + public Dictionary Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase); public string? Body { get; set; } private static readonly JsonSerializerOptions _readOptions = new() { PropertyNameCaseInsensitive = true }; diff --git a/src/MauiDevFlow.Agent.Core/AgentOptions.cs b/src/MauiDevFlow.Agent.Core/AgentOptions.cs index 746fe33..854787e 100644 --- a/src/MauiDevFlow.Agent.Core/AgentOptions.cs +++ b/src/MauiDevFlow.Agent.Core/AgentOptions.cs @@ -38,4 +38,21 @@ public class AgentOptions /// Maximum number of rotated log files to keep. Default: 5. /// public int MaxLogFiles { get; set; } = 5; + + /// + /// Whether to intercept HttpClient requests for network monitoring. Default: true. + /// When enabled, all IHttpClientFactory-created HttpClients are automatically monitored. + /// + public bool EnableNetworkMonitoring { get; set; } = true; + + /// + /// Maximum size of request/response bodies to capture, in bytes. Default: 256KB. + /// Bodies larger than this are truncated. Set to 0 to disable body capture. + /// + public int MaxNetworkBodySize { get; set; } = 256 * 1024; + + /// + /// Maximum number of network requests to keep in the ring buffer. Default: 500. + /// + public int MaxNetworkBufferSize { get; set; } = 500; } diff --git a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs index 68f989d..6477bbf 100644 --- a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs +++ b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs @@ -5,6 +5,7 @@ using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Dispatching; using MauiDevFlow.Logging; +using MauiDevFlow.Agent.Core.Network; namespace MauiDevFlow.Agent.Core; @@ -23,6 +24,11 @@ public class DevFlowAgentService : IDisposable protected IDispatcher? _dispatcher; private bool _disposed; + /// + /// The network request store for capturing HTTP traffic. + /// + public NetworkRequestStore NetworkStore { get; } + /// /// Delegate for sending CDP commands to the Blazor WebView. /// Set by the Blazor package when both are registered. @@ -40,6 +46,9 @@ public DevFlowAgentService(AgentOptions? options = null) _options = options ?? new AgentOptions(); _server = new AgentHttpServer(_options.Port); _treeWalker = CreateTreeWalker(); + NetworkStore = new NetworkRequestStore(_options.MaxNetworkBufferSize); + if (_options.EnableNetworkMonitoring) + DevFlowHttp.SetStore(NetworkStore); RegisterRoutes(); } @@ -154,6 +163,14 @@ private void RegisterRoutes() _server.MapPost("/api/action/scroll", HandleScroll); _server.MapGet("/api/logs", HandleLogs); _server.MapPost("/api/cdp", HandleCdp); + + // Network monitoring + _server.MapGet("/api/network", HandleNetworkList); + _server.MapGet("/api/network/{id}", HandleNetworkDetail); + _server.MapPost("/api/network/clear", HandleNetworkClear); + + // WebSocket: live network monitoring stream + _server.MapWebSocket("/ws/network", HandleNetworkWebSocket); } private async Task HandleStatus(HttpRequest request) @@ -943,6 +960,118 @@ public void Dispose() _logProvider?.Dispose(); } + // ── Network monitoring endpoints ── + + private Task HandleNetworkList(HttpRequest request) + { + var limit = int.TryParse(request.QueryParams.GetValueOrDefault("limit", "100"), out var l) ? l : 100; + var host = request.QueryParams.GetValueOrDefault("host"); + var method = request.QueryParams.GetValueOrDefault("method"); + int? status = request.QueryParams.TryGetValue("status", out var s) && int.TryParse(s, out var si) ? si : null; + + var entries = NetworkStore.GetRecent(limit, host, method, status); + // Return summary-only (no headers/body) for the list + var summaries = entries.Select(e => e.ToSummary()).ToList(); + return Task.FromResult(HttpResponse.Json(summaries)); + } + + private Task HandleNetworkDetail(HttpRequest request) + { + var id = request.RouteParams.GetValueOrDefault("id"); + if (string.IsNullOrEmpty(id)) + return Task.FromResult(HttpResponse.Error("Missing request ID")); + + var entry = NetworkStore.GetById(id); + if (entry == null) + return Task.FromResult(HttpResponse.NotFound($"Network request '{id}' not found")); + + return Task.FromResult(HttpResponse.Json(entry)); + } + + private Task HandleNetworkClear(HttpRequest request) + { + NetworkStore.Clear(); + return Task.FromResult(HttpResponse.Ok("Network request buffer cleared")); + } + + private async Task HandleNetworkWebSocket( + System.Net.Sockets.TcpClient client, + System.Net.Sockets.NetworkStream stream, + HttpRequest request, + CancellationToken ct) + { + // Send replay of recent entries + var recent = NetworkStore.GetRecent(100); + var replayMsg = JsonSerializer.Serialize(new + { + type = "replay", + entries = recent.Select(e => e.ToSummary()) + }); + await AgentHttpServer.WebSocketSendTextAsync(stream, replayMsg, ct); + + // Subscribe to live entries + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var sendQueue = new System.Collections.Concurrent.ConcurrentQueue(); + + void OnRequest(Network.NetworkRequestEntry entry) => sendQueue.Enqueue(entry); + NetworkStore.OnRequestCaptured += OnRequest; + + try + { + // Read loop (handles client messages + detects disconnection) + var readTask = Task.Run(async () => + { + while (!cts.Token.IsCancellationRequested) + { + var msg = await AgentHttpServer.WebSocketReadTextAsync(stream, cts.Token); + if (msg == null) { cts.Cancel(); break; } + + try + { + using var doc = JsonDocument.Parse(msg); + var msgType = doc.RootElement.GetProperty("type").GetString(); + + if (msgType == "get_details" && doc.RootElement.TryGetProperty("id", out var idEl)) + { + var id = idEl.GetString(); + var entry = id != null ? NetworkStore.GetById(id) : null; + var resp = JsonSerializer.Serialize(new { type = "details", entry }); + await AgentHttpServer.WebSocketSendTextAsync(stream, resp, cts.Token); + } + else if (msgType == "clear") + { + NetworkStore.Clear(); + } + } + catch { } + } + }, cts.Token); + + // Send loop — drain queue periodically + while (!cts.Token.IsCancellationRequested) + { + while (sendQueue.TryDequeue(out var entry)) + { + try + { + var json = JsonSerializer.Serialize(new { type = "request", entry = entry.ToSummary() }); + await AgentHttpServer.WebSocketSendTextAsync(stream, json, cts.Token); + } + catch { cts.Cancel(); break; } + } + + try { await Task.Delay(50, cts.Token); } + catch { break; } + } + + await readTask; + } + finally + { + NetworkStore.OnRequestCaptured -= OnRequest; + } + } + private Task HandleLogs(HttpRequest request) { if (_logProvider == null) diff --git a/src/MauiDevFlow.Agent.Core/Network/DevFlowHttp.cs b/src/MauiDevFlow.Agent.Core/Network/DevFlowHttp.cs new file mode 100644 index 0000000..91624be --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Network/DevFlowHttp.cs @@ -0,0 +1,36 @@ +namespace MauiDevFlow.Agent.Core.Network; + +/// +/// Convenience methods for creating HttpClient instances that are automatically +/// monitored by MauiDevFlow's network interceptor. Use for HttpClients created +/// outside of DI (e.g. new HttpClient()). DI-based clients are auto-intercepted. +/// +public static class DevFlowHttp +{ + private static NetworkRequestStore? _store; + + internal static void SetStore(NetworkRequestStore store) => _store = store; + + /// + /// Creates an HttpClient wrapped with the DevFlow network interceptor. + /// Falls back to a plain HttpClient if network monitoring is not initialized. + /// + public static HttpClient CreateClient(HttpMessageHandler? innerHandler = null) + { + var handler = CreateHandler(innerHandler); + return handler != null ? new HttpClient(handler) : new HttpClient(); + } + + /// + /// Creates a DevFlowHttpHandler wrapping the given inner handler. + /// Returns null if network monitoring is not initialized. + /// + public static DevFlowHttpHandler? CreateHandler(HttpMessageHandler? innerHandler = null) + { + if (_store == null) return null; + + return innerHandler != null + ? new DevFlowHttpHandler(_store, innerHandler) + : new DevFlowHttpHandler(_store); + } +} diff --git a/src/MauiDevFlow.Agent.Core/Network/DevFlowHttpHandler.cs b/src/MauiDevFlow.Agent.Core/Network/DevFlowHttpHandler.cs new file mode 100644 index 0000000..245ac60 --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Network/DevFlowHttpHandler.cs @@ -0,0 +1,146 @@ +using System.Net.Http.Headers; +using System.Text; + +namespace MauiDevFlow.Agent.Core.Network; + +/// +/// DelegatingHandler that intercepts HTTP requests/responses and records them +/// in the NetworkRequestStore. Wraps any inner handler (preserving platform-specific +/// handlers like AndroidMessageHandler, NSUrlSessionHandler, etc.). +/// +public class DevFlowHttpHandler : DelegatingHandler +{ + private readonly NetworkRequestStore _store; + private readonly int _maxBodySize; + + private static readonly HashSet TextContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "application/json", "application/xml", "application/soap+xml", + "application/graphql", "application/javascript", + "application/x-www-form-urlencoded", "application/ld+json", + "application/vnd.api+json" + }; + + public DevFlowHttpHandler(NetworkRequestStore store, int maxBodySize = 256 * 1024) + : base() + { + _store = store; + _maxBodySize = maxBodySize; + } + + public DevFlowHttpHandler(NetworkRequestStore store, HttpMessageHandler innerHandler, int maxBodySize = 256 * 1024) + : base(innerHandler) + { + _store = store; + _maxBodySize = maxBodySize; + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var entry = NetworkRequestEntry.BeginCapture(request); + + // Capture request body + if (request.Content != null) + { + try + { + var (body, encoding, size, truncated) = await CaptureBodyAsync(request.Content); + entry.RequestBody = body; + entry.RequestBodyEncoding = encoding; + entry.RequestSize = size; + entry.RequestBodyTruncated = truncated; + } + catch { /* Don't fail the request if body capture fails */ } + } + + try + { + var response = await base.SendAsync(request, cancellationToken); + entry.CompleteCapture(response); + + // Capture response body + if (response.Content != null) + { + try + { + var (body, encoding, size, truncated) = await CaptureResponseBodyAsync(response); + entry.ResponseBody = body; + entry.ResponseBodyEncoding = encoding; + entry.ResponseSize = size; + entry.ResponseBodyTruncated = truncated; + } + catch { /* Don't fail the response if body capture fails */ } + } + + _store.Add(entry); + return response; + } + catch (Exception ex) + { + entry.CompleteWithError(ex); + _store.Add(entry); + throw; + } + } + + private async Task<(string? body, string? encoding, long size, bool truncated)> CaptureBodyAsync( + HttpContent content) + { + var bytes = await content.ReadAsByteArrayAsync(); + var size = (long)bytes.Length; + var isText = IsTextContent(content.Headers.ContentType); + + if (bytes.Length == 0) + return (null, null, 0, false); + + var truncated = bytes.Length > _maxBodySize; + var captureBytes = truncated ? bytes.AsSpan(0, _maxBodySize).ToArray() : bytes; + + if (isText) + { + return (Encoding.UTF8.GetString(captureBytes), "text", size, truncated); + } + else + { + return (Convert.ToBase64String(captureBytes), "base64", size, truncated); + } + } + + private async Task<(string? body, string? encoding, long size, bool truncated)> CaptureResponseBodyAsync( + HttpResponseMessage response) + { + // For responses, we need to buffer the content so the app can still read it. + // ReadAsByteArrayAsync buffers the content, making it re-readable. + var bytes = await response.Content.ReadAsByteArrayAsync(); + var size = (long)bytes.Length; + var isText = IsTextContent(response.Content.Headers.ContentType); + + if (bytes.Length == 0) + return (null, null, 0, false); + + var truncated = bytes.Length > _maxBodySize; + var captureBytes = truncated ? bytes.AsSpan(0, _maxBodySize).ToArray() : bytes; + + if (isText) + { + return (Encoding.UTF8.GetString(captureBytes), "text", size, truncated); + } + else + { + return (Convert.ToBase64String(captureBytes), "base64", size, truncated); + } + } + + private static bool IsTextContent(MediaTypeHeaderValue? contentType) + { + if (contentType == null) return false; + var mediaType = contentType.MediaType; + if (mediaType == null) return false; + + if (mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase)) + return true; + + return TextContentTypes.Contains(mediaType); + } +} diff --git a/src/MauiDevFlow.Agent.Core/Network/NetworkRequestEntry.cs b/src/MauiDevFlow.Agent.Core/Network/NetworkRequestEntry.cs new file mode 100644 index 0000000..b533396 --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Network/NetworkRequestEntry.cs @@ -0,0 +1,167 @@ +using System.Diagnostics; +using System.Text.Json.Serialization; + +namespace MauiDevFlow.Agent.Core.Network; + +/// +/// Captured HTTP request/response entry. +/// Summary fields are always populated; detail fields (headers, body) are populated +/// based on configuration and may be null in summary-only contexts. +/// +public class NetworkRequestEntry +{ + [JsonPropertyName("id")] + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..12]; + + [JsonPropertyName("timestamp")] + public DateTimeOffset Timestamp { get; set; } + + [JsonPropertyName("method")] + public string Method { get; set; } = ""; + + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + [JsonPropertyName("host")] + public string? Host { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("statusCode")] + public int? StatusCode { get; set; } + + [JsonPropertyName("statusText")] + public string? StatusText { get; set; } + + [JsonPropertyName("durationMs")] + public long DurationMs { get; set; } + + [JsonPropertyName("requestSize")] + public long? RequestSize { get; set; } + + [JsonPropertyName("responseSize")] + public long? ResponseSize { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("requestContentType")] + public string? RequestContentType { get; set; } + + [JsonPropertyName("responseContentType")] + public string? ResponseContentType { get; set; } + + // Detail fields — populated when full details are requested + + [JsonPropertyName("requestHeaders")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? RequestHeaders { get; set; } + + [JsonPropertyName("responseHeaders")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? ResponseHeaders { get; set; } + + [JsonPropertyName("requestBody")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RequestBody { get; set; } + + [JsonPropertyName("responseBody")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResponseBody { get; set; } + + [JsonPropertyName("requestBodyEncoding")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RequestBodyEncoding { get; set; } + + [JsonPropertyName("responseBodyEncoding")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ResponseBodyEncoding { get; set; } + + [JsonPropertyName("requestBodyTruncated")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool RequestBodyTruncated { get; set; } + + [JsonPropertyName("responseBodyTruncated")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool ResponseBodyTruncated { get; set; } + + // Non-serialized timing helper + [JsonIgnore] + internal Stopwatch? Stopwatch { get; set; } + + public static NetworkRequestEntry BeginCapture(HttpRequestMessage request) + { + var uri = request.RequestUri; + var entry = new NetworkRequestEntry + { + Timestamp = DateTimeOffset.UtcNow, + Method = request.Method.Method, + Url = uri?.ToString() ?? "", + Host = uri?.Host, + Path = uri?.AbsolutePath, + RequestContentType = request.Content?.Headers.ContentType?.MediaType, + RequestHeaders = CaptureHeaders(request.Headers, request.Content?.Headers), + Stopwatch = Stopwatch.StartNew() + }; + return entry; + } + + public void CompleteCapture(HttpResponseMessage response) + { + Stopwatch?.Stop(); + DurationMs = Stopwatch?.ElapsedMilliseconds ?? 0; + StatusCode = (int)response.StatusCode; + StatusText = response.ReasonPhrase; + ResponseContentType = response.Content?.Headers.ContentType?.MediaType; + ResponseHeaders = CaptureHeaders(response.Headers, response.Content?.Headers); + } + + public void CompleteWithError(Exception ex) + { + Stopwatch?.Stop(); + DurationMs = Stopwatch?.ElapsedMilliseconds ?? 0; + Error = ex.Message; + } + + /// + /// Returns a summary copy without detail fields (headers, body). + /// + public NetworkRequestEntry ToSummary() => new() + { + Id = Id, + Timestamp = Timestamp, + Method = Method, + Url = Url, + Host = Host, + Path = Path, + StatusCode = StatusCode, + StatusText = StatusText, + DurationMs = DurationMs, + RequestSize = RequestSize, + ResponseSize = ResponseSize, + Error = Error, + RequestContentType = RequestContentType, + ResponseContentType = ResponseContentType, + RequestBodyTruncated = RequestBodyTruncated, + ResponseBodyTruncated = ResponseBodyTruncated + }; + + private static Dictionary CaptureHeaders( + System.Net.Http.Headers.HttpHeaders headers, + System.Net.Http.Headers.HttpContentHeaders? contentHeaders) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var h in headers) + dict[h.Key] = h.Value.ToArray(); + if (contentHeaders != null) + { + foreach (var h in contentHeaders) + { + if (!dict.ContainsKey(h.Key)) + dict[h.Key] = h.Value.ToArray(); + } + } + return dict; + } +} diff --git a/src/MauiDevFlow.Agent.Core/Network/NetworkRequestStore.cs b/src/MauiDevFlow.Agent.Core/Network/NetworkRequestStore.cs new file mode 100644 index 0000000..7d170df --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Network/NetworkRequestStore.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; + +namespace MauiDevFlow.Agent.Core.Network; + +/// +/// Thread-safe ring buffer that stores captured network requests. +/// Evicts oldest entries when capacity is reached. +/// +public class NetworkRequestStore +{ + private readonly ConcurrentQueue _entries = new(); + private int _count; + private readonly int _maxEntries; + + public event Action? OnRequestCaptured; + + public NetworkRequestStore(int maxEntries = 500) + { + _maxEntries = maxEntries; + } + + public void Add(NetworkRequestEntry entry) + { + _entries.Enqueue(entry); + var count = Interlocked.Increment(ref _count); + + // Evict oldest if over capacity + while (count > _maxEntries && _entries.TryDequeue(out _)) + { + count = Interlocked.Decrement(ref _count); + } + + OnRequestCaptured?.Invoke(entry); + } + + public IReadOnlyList GetRecent(int count = 100, string? host = null, string? method = null, int? status = null) + { + IEnumerable query = _entries.Reverse(); + + if (!string.IsNullOrEmpty(host)) + query = query.Where(e => e.Host != null && e.Host.Contains(host, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrEmpty(method)) + query = query.Where(e => e.Method.Equals(method, StringComparison.OrdinalIgnoreCase)); + if (status.HasValue) + query = query.Where(e => e.StatusCode == status.Value); + + return query.Take(count).ToList(); + } + + public NetworkRequestEntry? GetById(string id) + { + return _entries.FirstOrDefault(e => e.Id == id); + } + + public void Clear() + { + while (_entries.TryDequeue(out _)) { } + Interlocked.Exchange(ref _count, 0); + } + + public int Count => _count; +} diff --git a/src/MauiDevFlow.Agent/AgentServiceExtensions.cs b/src/MauiDevFlow.Agent/AgentServiceExtensions.cs index d0ea5a9..68349c7 100644 --- a/src/MauiDevFlow.Agent/AgentServiceExtensions.cs +++ b/src/MauiDevFlow.Agent/AgentServiceExtensions.cs @@ -1,9 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Maui.Controls; using Microsoft.Maui.Dispatching; using Microsoft.Maui.Hosting; using Microsoft.Maui.LifecycleEvents; using MauiDevFlow.Agent.Core; +using MauiDevFlow.Agent.Core.Network; using MauiDevFlow.Logging; namespace MauiDevFlow.Agent; @@ -96,6 +98,18 @@ public static MauiAppBuilder AddMauiDevFlowAgent(this MauiAppBuilder builder, Ac builder.Logging.AddProvider(logProvider); } + // Auto-inject network monitoring handler into all IHttpClientFactory-created clients + if (options.EnableNetworkMonitoring) + { + var store = service.NetworkStore; + var maxBody = options.MaxNetworkBodySize; + builder.Services.AddSingleton(store); + builder.Services.ConfigureHttpClientDefaults(httpBuilder => + { + httpBuilder.AddHttpMessageHandler(() => new MauiDevFlow.Agent.Core.Network.DevFlowHttpHandler(store, maxBody)); + }); + } + builder.ConfigureLifecycleEvents(lifecycle => { #if ANDROID diff --git a/src/MauiDevFlow.Agent/MauiDevFlow.Agent.csproj b/src/MauiDevFlow.Agent/MauiDevFlow.Agent.csproj index 9fc421e..7601660 100644 --- a/src/MauiDevFlow.Agent/MauiDevFlow.Agent.csproj +++ b/src/MauiDevFlow.Agent/MauiDevFlow.Agent.csproj @@ -21,6 +21,7 @@ + diff --git a/src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj b/src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj index 431fc4b..1d4e33b 100644 --- a/src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj +++ b/src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj @@ -13,6 +13,7 @@ + diff --git a/src/MauiDevFlow.CLI/Program.cs b/src/MauiDevFlow.CLI/Program.cs index e64ce7d..003a848 100644 --- a/src/MauiDevFlow.CLI/Program.cs +++ b/src/MauiDevFlow.CLI/Program.cs @@ -337,6 +337,44 @@ await MauiScrollAsync(host, port, elementId, dx, dy, animated, window), agentHostOption, agentPortOption, logsLimitOption, logsSkipOption, logsSourceOption); mauiCommand.Add(mauiLogsCmd); + // ── Network monitoring command ── + var networkCommand = new Command("network", "Monitor HTTP network requests"); + var networkJsonOption = new Option("--json", () => false, "Output as JSONL (machine-readable)"); + var networkLimitOption = new Option("--limit", () => 100, "Maximum number of entries to show"); + var networkHostOption = new Option("--host", () => null, "Filter by host"); + var networkMethodOption = new Option("--method", () => null, "Filter by HTTP method"); + networkCommand.AddOption(networkJsonOption); + networkCommand.AddOption(networkLimitOption); + networkCommand.AddOption(networkHostOption); + networkCommand.AddOption(networkMethodOption); + networkCommand.SetHandler(async (host, port, json, limit, filterHost, filterMethod) => + await MauiNetworkMonitorAsync(host, port, json, limit, filterHost, filterMethod), + agentHostOption, agentPortOption, networkJsonOption, networkLimitOption, networkHostOption, networkMethodOption); + + var networkListCmd = new Command("list", "List recent network requests (one-shot)"); + networkListCmd.AddOption(networkJsonOption); + networkListCmd.AddOption(networkLimitOption); + networkListCmd.AddOption(networkHostOption); + networkListCmd.AddOption(networkMethodOption); + networkListCmd.SetHandler(async (host, port, json, limit, filterHost, filterMethod) => + await MauiNetworkListAsync(host, port, json, limit, filterHost, filterMethod), + agentHostOption, agentPortOption, networkJsonOption, networkLimitOption, networkHostOption, networkMethodOption); + networkCommand.Add(networkListCmd); + + var networkDetailId = new Argument("id", "Request ID to show details for"); + var networkDetailCmd = new Command("detail", "Show full request/response details") { networkDetailId }; + networkDetailCmd.SetHandler(async (host, port, id) => + await MauiNetworkDetailAsync(host, port, id), + agentHostOption, agentPortOption, networkDetailId); + networkCommand.Add(networkDetailCmd); + + var networkClearCmd = new Command("clear", "Clear the network request buffer"); + networkClearCmd.SetHandler(async (host, port) => await MauiNetworkClearAsync(host, port), + agentHostOption, agentPortOption); + networkCommand.Add(networkClearCmd); + + mauiCommand.Add(networkCommand); + rootCommand.Add(mauiCommand); // ===== update-skill command ===== @@ -1303,6 +1341,276 @@ private static async Task MauiLogsAsync(string host, int port, int limit, int sk catch (Exception ex) { WriteError(ex.Message); } } + // ── Network monitoring ── + + private static async Task MauiNetworkMonitorAsync(string host, int port, bool json, int limit, string? filterHost, string? filterMethod) + { + try + { + var wsUrl = $"ws://{host}:{port}/ws/network"; + using var ws = new System.Net.WebSockets.ClientWebSocket(); + var cts = new CancellationTokenSource(); + + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + await ws.ConnectAsync(new Uri(wsUrl), cts.Token); + + if (!json) + { + Console.WriteLine($"Connected to network monitor at {host}:{port}"); + Console.WriteLine("Listening for HTTP requests... (Ctrl+C to stop)\n"); + PrintNetworkTableHeader(); + } + + int counter = 0; + var buffer = new byte[65536]; + var sb = new StringBuilder(); + + while (!cts.Token.IsCancellationRequested && ws.State == System.Net.WebSockets.WebSocketState.Open) + { + System.Net.WebSockets.WebSocketReceiveResult result; + sb.Clear(); + do + { + result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + if (result.MessageType == System.Net.WebSockets.WebSocketMessageType.Close) { cts.Cancel(); break; } + sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); + } while (!result.EndOfMessage); + + if (cts.Token.IsCancellationRequested) break; + + var msg = sb.ToString(); + using var doc = JsonDocument.Parse(msg); + var type = doc.RootElement.GetProperty("type").GetString(); + + if (type == "replay" && doc.RootElement.TryGetProperty("entries", out var entries)) + { + foreach (var entry in entries.EnumerateArray()) + { + if (!MatchesFilter(entry, filterHost, filterMethod)) continue; + counter++; + if (json) PrintNetworkEntryJson(entry); + else PrintNetworkEntryRow(counter, entry); + } + } + else if (type == "request" && doc.RootElement.TryGetProperty("entry", out var reqEntry)) + { + if (!MatchesFilter(reqEntry, filterHost, filterMethod)) continue; + counter++; + if (counter > limit) continue; + if (json) PrintNetworkEntryJson(reqEntry); + else PrintNetworkEntryRow(counter, reqEntry); + } + } + + if (!json) Console.WriteLine($"\n{counter} requests captured."); + } + catch (OperationCanceledException) { } + catch (Exception ex) { WriteError(ex.Message); } + } + + private static async Task MauiNetworkListAsync(string host, int port, bool json, int limit, string? filterHost, string? filterMethod) + { + try + { + using var client = new MauiDevFlow.Driver.AgentClient(host, port); + var requests = await client.GetNetworkRequestsAsync(limit, filterHost, filterMethod); + + if (json) + { + foreach (var r in requests) + Console.WriteLine(JsonSerializer.Serialize(r, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull })); + } + else + { + if (requests.Count == 0) + { + Console.WriteLine("No network requests captured."); + return; + } + + PrintNetworkTableHeader(); + int counter = 0; + foreach (var r in requests) + { + counter++; + PrintNetworkRequestRow(counter, r); + } + Console.WriteLine($"\n{requests.Count} requests."); + } + } + catch (Exception ex) { WriteError(ex.Message); } + } + + private static async Task MauiNetworkDetailAsync(string host, int port, string id) + { + try + { + using var client = new MauiDevFlow.Driver.AgentClient(host, port); + var req = await client.GetNetworkRequestDetailAsync(id); + + if (req == null) + { + WriteError($"Network request '{id}' not found."); + return; + } + + Console.WriteLine($"{"ID:",-20} {req.Id}"); + Console.WriteLine($"{"Timestamp:",-20} {req.Timestamp:O}"); + Console.WriteLine($"{"Method:",-20} {req.Method}"); + Console.WriteLine($"{"URL:",-20} {req.Url}"); + Console.WriteLine($"{"Status:",-20} {(req.StatusCode?.ToString() ?? "ERROR")} {req.StatusText ?? ""}"); + Console.WriteLine($"{"Duration:",-20} {req.DurationMs}ms"); + if (req.Error != null) Console.WriteLine($"{"Error:",-20} {req.Error}"); + + if (req.RequestHeaders != null && req.RequestHeaders.Count > 0) + { + Console.WriteLine("\n── Request Headers ──"); + foreach (var h in req.RequestHeaders) + Console.WriteLine($" {h.Key}: {string.Join(", ", h.Value)}"); + } + + if (req.RequestBody != null) + { + Console.WriteLine($"\n── Request Body ({req.RequestSize ?? 0} bytes{(req.RequestBodyTruncated ? ", truncated" : "")}) ──"); + PrintBody(req.RequestBody, req.RequestBodyEncoding); + } + + if (req.ResponseHeaders != null && req.ResponseHeaders.Count > 0) + { + Console.WriteLine("\n── Response Headers ──"); + foreach (var h in req.ResponseHeaders) + Console.WriteLine($" {h.Key}: {string.Join(", ", h.Value)}"); + } + + if (req.ResponseBody != null) + { + Console.WriteLine($"\n── Response Body ({req.ResponseSize ?? 0} bytes{(req.ResponseBodyTruncated ? ", truncated" : "")}) ──"); + PrintBody(req.ResponseBody, req.ResponseBodyEncoding); + } + } + catch (Exception ex) { WriteError(ex.Message); } + } + + private static async Task MauiNetworkClearAsync(string host, int port) + { + try + { + using var client = new MauiDevFlow.Driver.AgentClient(host, port); + var result = await client.ClearNetworkRequestsAsync(); + Console.WriteLine(result ? "Network request buffer cleared." : "Failed to clear."); + } + catch (Exception ex) { WriteError(ex.Message); } + } + + // ── Network display helpers ── + + private static void PrintNetworkTableHeader() + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($"{"#",-5} {"Method",-7} {"URL",-50} {"Status",-7} {"Duration",-10} {"Size",-10}"); + Console.WriteLine(new string('─', 89)); + Console.ResetColor(); + } + + private static void PrintNetworkEntryRow(int index, JsonElement entry) + { + var method = entry.GetProperty("method").GetString() ?? ""; + var url = entry.GetProperty("url").GetString() ?? ""; + var statusCode = entry.TryGetProperty("statusCode", out var sc) && sc.ValueKind == JsonValueKind.Number ? sc.GetInt32() : (int?)null; + var durationMs = entry.TryGetProperty("durationMs", out var d) ? d.GetInt64() : 0; + var responseSize = entry.TryGetProperty("responseSize", out var rs) && rs.ValueKind == JsonValueKind.Number ? rs.GetInt64() : (long?)null; + var error = entry.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null; + + // Truncate URL + if (url.Length > 48) url = url[..45] + "..."; + + var statusStr = statusCode?.ToString() ?? (error != null ? "ERR" : "---"); + var durationStr = durationMs > 0 ? $"{durationMs}ms" : "--"; + var sizeStr = responseSize.HasValue ? FormatSize(responseSize.Value) : "--"; + + Console.ForegroundColor = GetStatusColor(statusCode, error); + Console.WriteLine($"{index,-5} {method,-7} {url,-50} {statusStr,-7} {durationStr,-10} {sizeStr,-10}"); + Console.ResetColor(); + } + + private static void PrintNetworkRequestRow(int index, MauiDevFlow.Driver.NetworkRequest r) + { + var url = r.Url; + if (url.Length > 48) url = url[..45] + "..."; + + var statusStr = r.StatusCode?.ToString() ?? (r.Error != null ? "ERR" : "---"); + var durationStr = r.DurationMs > 0 ? $"{r.DurationMs}ms" : "--"; + var sizeStr = r.ResponseSize.HasValue ? FormatSize(r.ResponseSize.Value) : "--"; + + Console.ForegroundColor = GetStatusColor(r.StatusCode, r.Error); + Console.WriteLine($"{index,-5} {r.Method,-7} {url,-50} {statusStr,-7} {durationStr,-10} {sizeStr,-10}"); + Console.ResetColor(); + } + + private static void PrintNetworkEntryJson(JsonElement entry) + { + Console.WriteLine(entry.GetRawText()); + } + + private static void PrintBody(string body, string? encoding) + { + if (encoding == "base64") + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [base64, {body.Length} chars]"); + Console.ResetColor(); + } + else + { + // Try to pretty-print JSON + try + { + using var doc = JsonDocument.Parse(body); + Console.WriteLine(JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true })); + } + catch + { + Console.WriteLine(body); + } + } + } + + private static bool MatchesFilter(JsonElement entry, string? filterHost, string? filterMethod) + { + if (!string.IsNullOrEmpty(filterHost)) + { + var host = entry.TryGetProperty("host", out var h) ? h.GetString() : null; + if (host == null || !host.Contains(filterHost, StringComparison.OrdinalIgnoreCase)) return false; + } + if (!string.IsNullOrEmpty(filterMethod)) + { + var method = entry.GetProperty("method").GetString(); + if (!string.Equals(method, filterMethod, StringComparison.OrdinalIgnoreCase)) return false; + } + return true; + } + + private static ConsoleColor GetStatusColor(int? statusCode, string? error) + { + if (error != null) return ConsoleColor.Red; + return statusCode switch + { + >= 200 and < 300 => ConsoleColor.Green, + >= 300 and < 400 => ConsoleColor.Yellow, + >= 400 and < 500 => ConsoleColor.Red, + >= 500 => ConsoleColor.DarkRed, + _ => ConsoleColor.Gray + }; + } + + private static string FormatSize(long bytes) => bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + _ => $"{bytes / (1024.0 * 1024.0):F1} MB" + }; + private static void PrintTree(List elements, int indent) { foreach (var el in elements) diff --git a/src/MauiDevFlow.Driver/AgentClient.cs b/src/MauiDevFlow.Driver/AgentClient.cs index 94710f4..7e8075b 100644 --- a/src/MauiDevFlow.Driver/AgentClient.cs +++ b/src/MauiDevFlow.Driver/AgentClient.cs @@ -210,6 +210,47 @@ public void Dispose() _disposed = true; _http.Dispose(); } + + // ── Network monitoring ── + + public async Task> GetNetworkRequestsAsync( + int limit = 100, string? host = null, string? method = null) + { + try + { + var url = $"{_baseUrl}/api/network?limit={limit}"; + if (!string.IsNullOrEmpty(host)) url += $"&host={Uri.EscapeDataString(host)}"; + if (!string.IsNullOrEmpty(method)) url += $"&method={Uri.EscapeDataString(method)}"; + + var response = await _http.GetStringAsync(url); + return JsonSerializer.Deserialize>(response) ?? new(); + } + catch { return new(); } + } + + public async Task GetNetworkRequestDetailAsync(string id) + { + try + { + var response = await _http.GetStringAsync($"{_baseUrl}/api/network/{Uri.EscapeDataString(id)}"); + return JsonSerializer.Deserialize(response); + } + catch { return null; } + } + + public async Task ClearNetworkRequestsAsync() + { + return await PostActionAsync("/api/network/clear", new { }); + } + + /// + /// Returns the WebSocket URL for live network monitoring. + /// + public string GetNetworkWebSocketUrl() + { + var wsBase = _baseUrl.Replace("http://", "ws://").Replace("https://", "wss://"); + return $"{wsBase}/ws/network"; + } } public class AgentStatus @@ -229,3 +270,51 @@ public class AgentStatus [System.Text.Json.Serialization.JsonPropertyName("running")] public bool Running { get; set; } } + +public class NetworkRequest +{ + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string Id { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("timestamp")] + public DateTimeOffset Timestamp { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("method")] + public string Method { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("url")] + public string Url { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("host")] + public string? Host { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("path")] + public string? Path { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("statusCode")] + public int? StatusCode { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("statusText")] + public string? StatusText { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("durationMs")] + public long DurationMs { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("requestSize")] + public long? RequestSize { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("responseSize")] + public long? ResponseSize { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string? Error { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("requestContentType")] + public string? RequestContentType { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("responseContentType")] + public string? ResponseContentType { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("requestHeaders")] + public Dictionary? RequestHeaders { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("responseHeaders")] + public Dictionary? ResponseHeaders { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("requestBody")] + public string? RequestBody { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("responseBody")] + public string? ResponseBody { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("requestBodyEncoding")] + public string? RequestBodyEncoding { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("responseBodyEncoding")] + public string? ResponseBodyEncoding { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("requestBodyTruncated")] + public bool RequestBodyTruncated { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("responseBodyTruncated")] + public bool ResponseBodyTruncated { get; set; } +} diff --git a/src/SampleMauiApp/AppShell.xaml b/src/SampleMauiApp/AppShell.xaml index 3b601eb..5dafe88 100644 --- a/src/SampleMauiApp/AppShell.xaml +++ b/src/SampleMauiApp/AppShell.xaml @@ -13,5 +13,7 @@ ContentTemplate="{DataTemplate local:InteractionTestPage}" /> + diff --git a/src/SampleMauiApp/MauiProgram.cs b/src/SampleMauiApp/MauiProgram.cs index 1f55914..73c8204 100644 --- a/src/SampleMauiApp/MauiProgram.cs +++ b/src/SampleMauiApp/MauiProgram.cs @@ -22,9 +22,13 @@ public static MauiApp CreateMauiApp() // Shared data builder.Services.AddSingleton(); + // HTTP client factory (for network monitoring demo) + builder.Services.AddHttpClient(); + // Pages (DI-resolved by Shell's DataTemplate) builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); #if DEBUG //builder.Services.AddBlazorWebViewDeveloperTools(); diff --git a/src/SampleMauiApp/NetworkTestPage.xaml b/src/SampleMauiApp/NetworkTestPage.xaml new file mode 100644 index 0000000..4bd41e7 --- /dev/null +++ b/src/SampleMauiApp/NetworkTestPage.xaml @@ -0,0 +1,61 @@ + + + + + + + + + + + +