Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .claude/skills/maui-ai-debugging/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,49 @@ maui-devflow MAUI recording stop
**Options:** `--timeout <seconds>` (default 30), `--output <path>` (default `recording_<timestamp>.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 <requestId>

# 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)
Expand Down Expand Up @@ -265,6 +308,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 <requestId>` | 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
Expand Down
16 changes: 14 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -162,6 +163,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 <id> # 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"
Expand Down Expand Up @@ -236,6 +244,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
Expand Down
201 changes: 197 additions & 4 deletions src/MauiDevFlow.Agent.Core/AgentHttpServer.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

Expand All @@ -18,6 +19,7 @@ public class AgentHttpServer : IDisposable
private readonly int _port;
private readonly Dictionary<string, Func<HttpRequest, Task<HttpResponse>>> _getRoutes = new();
private readonly Dictionary<string, Func<HttpRequest, Task<HttpResponse>>> _postRoutes = new();
private readonly Dictionary<string, Func<TcpClient, NetworkStream, HttpRequest, CancellationToken, Task>> _wsRoutes = new();

public int Port => _port;
public bool IsRunning => _listenTask != null && !_listenTask.IsCompleted;
Expand All @@ -33,6 +35,9 @@ public void MapGet(string path, Func<HttpRequest, Task<HttpResponse>> handler)
public void MapPost(string path, Func<HttpRequest, Task<HttpResponse>> handler)
=> _postRoutes[path.TrimEnd('/')] = handler;

public void MapWebSocket(string path, Func<TcpClient, NetworkStream, HttpRequest, CancellationToken, Task> handler)
=> _wsRoutes[path.TrimEnd('/')] = handler;

public void Start()
{
if (_disposed) throw new ObjectDisposedException(nameof(AgentHttpServer));
Expand Down Expand Up @@ -71,12 +76,53 @@ 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)
{
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))
{
var stream = client.GetStream();
var request = await ReadRequestAsync(stream, ct).ConfigureAwait(false);
if (request == null) return;
// 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)
client.Client.SetSocketOption(
System.Net.Sockets.SocketOptionLevel.Socket,
System.Net.Sockets.SocketOptionName.KeepAlive, true);
_ = 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);
}
Expand Down Expand Up @@ -127,6 +173,16 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken ct)
}
}

// Parse headers
var headers = new Dictionary<string, string>(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");
Expand Down Expand Up @@ -160,6 +216,7 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken ct)
Method = method,
Path = path.TrimEnd('/'),
QueryParams = queryParams,
Headers = headers,
Body = body
};
}
Expand Down Expand Up @@ -228,6 +285,141 @@ 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);
}

/// <summary>
/// Sends a text frame over a WebSocket connection.
/// </summary>
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
}

/// <summary>
/// Sends a ping frame to keep the WebSocket connection alive.
/// </summary>
public static async Task WebSocketSendPingAsync(NetworkStream stream, CancellationToken ct)
{
await WebSocketSendFrameAsync(stream, 0x89, Array.Empty<byte>(), ct); // 0x89 = FIN + ping opcode
}

/// <summary>
/// Reads a text frame from a WebSocket connection. Returns null on close/error.
/// </summary>
public static async Task<string?> 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<int> 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
Expand All @@ -236,6 +428,7 @@ public class HttpRequest
public string Path { get; set; } = "/";
public Dictionary<string, string> QueryParams { get; set; } = new();
public Dictionary<string, string> RouteParams { get; set; } = new();
public Dictionary<string, string> Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public string? Body { get; set; }

private static readonly JsonSerializerOptions _readOptions = new() { PropertyNameCaseInsensitive = true };
Expand Down
Loading
Loading