diff --git a/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs b/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs index e93e6f90..06e1fc6b 100644 --- a/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs +++ b/src/MauiSherpa.Core/Models/DevFlow/DevFlowModels.cs @@ -495,10 +495,34 @@ public class DevFlowNetworkRequest public string? ResponseContentType { get; set; } [JsonPropertyName("requestHeaders")] - public Dictionary? RequestHeaders { get; set; } + public JsonElement RequestHeadersRaw { get; set; } [JsonPropertyName("responseHeaders")] - public Dictionary? ResponseHeaders { get; set; } + public JsonElement ResponseHeadersRaw { get; set; } + + [JsonIgnore] + public Dictionary? RequestHeaders => NormalizeHeaders(RequestHeadersRaw); + + [JsonIgnore] + public Dictionary? ResponseHeaders => NormalizeHeaders(ResponseHeadersRaw); + + private static Dictionary? NormalizeHeaders(JsonElement el) + { + if (el.ValueKind != JsonValueKind.Object) return null; + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var prop in el.EnumerateObject()) + { + string[] values; + if (prop.Value.ValueKind == JsonValueKind.Array) + values = prop.Value.EnumerateArray().Select(v => v.GetString() ?? "").ToArray(); + else if (prop.Value.ValueKind == JsonValueKind.String) + values = new[] { prop.Value.GetString() ?? "" }; + else + values = new[] { prop.Value.ToString() }; + dict[prop.Name] = values; + } + return dict; + } [JsonPropertyName("requestBody")] public string? RequestBody { get; set; } @@ -520,27 +544,54 @@ public class DevFlowNetworkRequest } /// -/// Log entry from /api/logs. +/// Log entry. Supports v1 (timestamp/level/source/message/category/exception) +/// and legacy (t/l/s/m/c) field names. /// public class DevFlowLogEntry { [JsonPropertyName("t")] - public DateTimeOffset Timestamp { get; set; } + public DateTimeOffset? LegacyTimestamp { get; set; } + + [JsonPropertyName("timestamp")] + public DateTimeOffset? V1Timestamp { get; set; } [JsonPropertyName("l")] - public string? Level { get; set; } + public string? LegacyLevel { get; set; } + + [JsonPropertyName("level")] + public string? V1Level { get; set; } [JsonPropertyName("s")] - public string? Source { get; set; } + public string? LegacySource { get; set; } + + [JsonPropertyName("source")] + public string? V1Source { get; set; } [JsonPropertyName("m")] - public string? Message { get; set; } + public string? LegacyMessage { get; set; } + + [JsonPropertyName("message")] + public string? V1Message { get; set; } [JsonPropertyName("c")] - public string? Category { get; set; } + public string? LegacyCategory { get; set; } + + [JsonPropertyName("category")] + public string? V1Category { get; set; } + + [JsonPropertyName("exception")] + public string? V1Exception { get; set; } + + [JsonIgnore] public DateTimeOffset Timestamp => V1Timestamp ?? LegacyTimestamp ?? default; + [JsonIgnore] public string? Level => V1Level ?? LegacyLevel; + [JsonIgnore] public string? Source => V1Source ?? LegacySource; + [JsonIgnore] public string? Message => V1Message ?? LegacyMessage; + [JsonIgnore] public string? Category => V1Category ?? LegacyCategory; [JsonPropertyName("e")] - public string? Exception { get; set; } + public string? LegacyException { get; set; } + + [JsonIgnore] public string? Exception => V1Exception ?? LegacyException; } /// @@ -691,17 +742,31 @@ public class DevFlowGeolocation public class DevFlowSensorStatus { - [JsonPropertyName("sensor")] public string Sensor { get; set; } = string.Empty; + // v1 (Ailoha/DevFlow) uses "name"+"available"; legacy used "sensor"+"supported". + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("sensor")] public string? LegacySensor { get; set; } [JsonPropertyName("active")] public bool Active { get; set; } - [JsonPropertyName("supported")] public bool Supported { get; set; } + [JsonPropertyName("available")] public bool Available { get; set; } = true; + [JsonPropertyName("supported")] public bool? LegacySupported { get; set; } [JsonPropertyName("subscribers")] public int Subscribers { get; set; } + + [JsonIgnore] + public string Sensor => !string.IsNullOrEmpty(Name) ? Name : (LegacySensor ?? string.Empty); + [JsonIgnore] + public bool Supported => LegacySupported ?? Available; } public class DevFlowSensorReading { [JsonPropertyName("sensor")] public string Sensor { get; set; } = string.Empty; [JsonPropertyName("timestamp")] public string? Timestamp { get; set; } - [JsonPropertyName("data")] public JsonElement Data { get; set; } + // v1 uses "values"; legacy used "data". + [JsonPropertyName("values")] public JsonElement Values { get; set; } + [JsonPropertyName("data")] public JsonElement LegacyData { get; set; } + + [JsonIgnore] + public JsonElement Data => Values.ValueKind != JsonValueKind.Undefined && Values.ValueKind != JsonValueKind.Null + ? Values : LegacyData; } // ── Storage DTOs ── @@ -710,6 +775,7 @@ public class DevFlowPreferenceEntry { [JsonPropertyName("key")] public string Key { get; set; } = string.Empty; [JsonPropertyName("value")] public string? Value { get; set; } + [JsonPropertyName("type")] public string? Type { get; set; } [JsonPropertyName("sharedName")] public string? SharedName { get; set; } } diff --git a/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs b/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs index 21843a18..7f2c614f 100644 --- a/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs +++ b/src/MauiSherpa.Core/Services/DevFlowAgentClient.cs @@ -5,6 +5,18 @@ namespace MauiSherpa.Core.Services; +/// +/// Thrown when the agent returns a non-success HTTP status or an unparseable response body. +/// Surfaces the agent-reported status code and error message so the inspector UI can show +/// the real cause (e.g. "Screenshot capture failed" from the Ailoha React Native agent) +/// instead of a silent failure. +/// +public sealed class DevFlowAgentException : Exception +{ + public int StatusCode { get; } + public DevFlowAgentException(int statusCode, string message) : base(message) => StatusCode = statusCode; +} + /// /// HTTP/WebSocket client for communicating with a MAUI DevFlow agent and broker. /// @@ -16,8 +28,20 @@ public class DevFlowAgentClient : IDisposable private ClientWebSocket? _logsWs; private CancellationTokenSource? _logsWsCts; private readonly Dictionary _sensorStreams = new(); + private string? _currentProfilerSessionId; + // Default to v1 (modern DevFlow + Ailoha). GetStatusAsync probes both protocols + // and switches to legacy if only /api/status is reachable. + private bool _useV1 = true; + private bool _protocolDetected; private bool _disposed; + /// + /// Protocol the client is using once detection has run. Null until the first + /// successful call. + /// + public DevFlowAgentProtocol? Protocol + => _protocolDetected ? (_useV1 ? DevFlowAgentProtocol.V1 : DevFlowAgentProtocol.Legacy) : null; + public string AgentHost { get; } public int AgentPort { get; } public string BaseUrl => $"http://{AgentHost}:{AgentPort}"; @@ -31,11 +55,20 @@ public DevFlowAgentClient(string host = "localhost", int port = 9223) // --- Broker API --- + /// Default broker port for the MAUI DevFlow CLI. + public const int DevFlowBrokerPort = 19223; + + /// Default broker port for the Ailoha CLI. + public const int AilohaBrokerPort = 19323; + + /// Standard broker ports we probe when the user accepts the default. + public static readonly IReadOnlyList DefaultBrokerPorts = new[] { DevFlowBrokerPort, AilohaBrokerPort }; + /// /// Fetch agents from the broker at the given host/port. /// public static async Task> GetBrokerAgentsAsync( - string brokerHost = "localhost", int brokerPort = 19223, CancellationToken ct = default) + string brokerHost = "localhost", int brokerPort = DevFlowBrokerPort, CancellationToken ct = default) { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; try @@ -54,7 +87,7 @@ public static async Task> GetBrokerAgentsAsync( /// Check if the broker is healthy. /// public static async Task IsBrokerHealthyAsync( - string brokerHost = "localhost", int brokerPort = 19223, CancellationToken ct = default) + string brokerHost = "localhost", int brokerPort = DevFlowBrokerPort, CancellationToken ct = default) { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(3) }; try @@ -69,11 +102,129 @@ public static async Task IsBrokerHealthyAsync( } } + /// + /// Find the first reachable broker on from the provided + /// candidate ports. Returns null if none are reachable. Probes run in parallel. + /// + public static async Task FindReachableBrokerPortAsync( + string brokerHost, + IEnumerable candidatePorts, + CancellationToken ct = default) + { + var ports = candidatePorts?.Distinct().ToArray() ?? Array.Empty(); + if (ports.Length == 0) return null; + + var tasks = ports + .Select(async port => (port, healthy: await IsBrokerHealthyAsync(brokerHost, port, ct))) + .ToList(); + + var results = await Task.WhenAll(tasks); + // Preserve candidate ordering — pick the first port in the input order that's healthy. + foreach (var port in ports) + { + var match = results.FirstOrDefault(r => r.port == port && r.healthy); + if (match.healthy) return port; + } + return null; + } + // --- Agent API --- public async Task GetStatusAsync(CancellationToken ct = default) { - return await GetAsync("/api/status", ct); + // Two wire shapes exist: + // v1 (/api/v1/agent/status): { agent:{name,version,…}, device:{model,idiom,…}, + // app:{name,packageId,…}, platform, running, … } + // legacy (/api/status): flat { agent, version, platform, deviceType, idiom, + // appName, running, cdpReady, cdpWebViewCount } + // Probe v1 first; if 404/network-error, fall back to legacy. Cache the result so + // every other endpoint dispatches to the matching URL family. + if (!_protocolDetected) + { + try + { + var v1 = await _http.GetAsync($"{BaseUrl}/api/v1/agent/status", ct); + if (v1.IsSuccessStatusCode) + { + _useV1 = true; + _protocolDetected = true; + var body = await v1.Content.ReadAsStringAsync(ct); + return ParseV1Status(body); + } + + if (v1.StatusCode == System.Net.HttpStatusCode.NotFound) + { + var legacy = await _http.GetAsync($"{BaseUrl}/api/status", ct); + if (legacy.IsSuccessStatusCode) + { + _useV1 = false; + _protocolDetected = true; + var body = await legacy.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize(body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + } + } + catch { return null; } + return null; + } + + // Protocol already detected — just call the right endpoint. + try + { + if (_useV1) + { + var json = await _http.GetStringAsync($"{BaseUrl}/api/v1/agent/status", ct); + return ParseV1Status(json); + } + return await GetAsync("/api/status", ct); + } + catch { return null; } + } + + private static DevFlowAgentStatus? ParseV1Status(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + string? readNested(string parent, string child) + => root.TryGetProperty(parent, out var p) + && p.ValueKind == JsonValueKind.Object + && p.TryGetProperty(child, out var c) + && c.ValueKind == JsonValueKind.String + ? c.GetString() + : null; + + string? readString(string name) + => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() : null; + + bool getBool(string name) + => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True; + + int getInt(string name) + => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number + && v.TryGetInt32(out var n) ? n : 0; + + return new DevFlowAgentStatus + { + Agent = readNested("agent", "name") ?? readString("agent"), + Version = readNested("agent", "version") ?? readString("version"), + Platform = readString("platform") ?? readNested("device", "platform"), + DeviceType = readNested("device", "model") ?? readString("deviceType"), + Idiom = readNested("device", "idiom") ?? readString("idiom"), + AppName = readNested("app", "name") ?? readString("appName"), + Running = getBool("running"), + CdpReady = getBool("cdpReady"), + CdpWebViewCount = getInt("cdpWebViewCount"), + }; + } + catch + { + return null; + } } public async Task> GetTreeAsync(int maxDepth = 0, int? window = null, CancellationToken ct = default) @@ -81,13 +232,15 @@ public async Task> GetTreeAsync(int maxDepth = 0, int? var parts = new List(); if (maxDepth > 0) parts.Add($"depth={maxDepth}"); if (window != null) parts.Add($"window={window}"); - var url = parts.Count > 0 ? $"/api/tree?{string.Join("&", parts)}" : "/api/tree"; + var basePath = V("/api/v1/ui/tree", "/api/tree"); + var url = parts.Count > 0 ? $"{basePath}?{string.Join("&", parts)}" : basePath; return await GetAsync>(url, ct) ?? new(); } public async Task GetElementAsync(string id, CancellationToken ct = default) { - return await GetAsync($"/api/element/{Uri.EscapeDataString(id)}", ct); + var path = V($"/api/v1/ui/elements/{Uri.EscapeDataString(id)}", $"/api/element/{Uri.EscapeDataString(id)}"); + return await GetAsync(path, ct); } public async Task> QueryAsync( @@ -98,7 +251,8 @@ public async Task> QueryAsync( if (automationId != null) parts.Add($"automationId={Uri.EscapeDataString(automationId)}"); if (text != null) parts.Add($"text={Uri.EscapeDataString(text)}"); if (selector != null) parts.Add($"selector={Uri.EscapeDataString(selector)}"); - var url = $"/api/query?{string.Join("&", parts)}"; + var basePath = V("/api/v1/ui/elements", "/api/query"); + var url = $"{basePath}?{string.Join("&", parts)}"; return await GetAsync>(url, ct) ?? new(); } @@ -106,7 +260,10 @@ public async Task> QueryAsync( { try { - var json = await _http.GetStringAsync($"{BaseUrl}/api/property/{Uri.EscapeDataString(elementId)}/{Uri.EscapeDataString(propertyName)}", ct); + var path = V( + $"/api/v1/ui/elements/{Uri.EscapeDataString(elementId)}/properties/{Uri.EscapeDataString(propertyName)}", + $"/api/property/{Uri.EscapeDataString(elementId)}/{Uri.EscapeDataString(propertyName)}"); + var json = await _http.GetStringAsync($"{BaseUrl}{path}", ct); var result = JsonSerializer.Deserialize(json); if (result.TryGetProperty("value", out var val)) return val.GetString(); @@ -121,9 +278,10 @@ public async Task SetPropertyAsync(string elementId, string propertyName, { var json = JsonSerializer.Serialize(new { value }); var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await _http.PostAsync( - $"{BaseUrl}/api/property/{Uri.EscapeDataString(elementId)}/{Uri.EscapeDataString(propertyName)}", - content, ct); + var path = V( + $"/api/v1/ui/elements/{Uri.EscapeDataString(elementId)}/properties/{Uri.EscapeDataString(propertyName)}", + $"/api/property/{Uri.EscapeDataString(elementId)}/{Uri.EscapeDataString(propertyName)}"); + var response = await _http.PostAsync($"{BaseUrl}{path}", content, ct); return response.IsSuccessStatusCode; } catch { return false; } @@ -131,29 +289,55 @@ public async Task SetPropertyAsync(string elementId, string propertyName, public async Task GetScreenshotAsync(int? window = null, string? elementId = null, CancellationToken ct = default) { - try + var parts = new List(); + if (_useV1) + { + // v1 query parameter is `elementId` and there is no `window` parameter. + if (elementId != null) parts.Add($"elementId={Uri.EscapeDataString(elementId)}"); + } + else { - var parts = new List(); if (window != null) parts.Add($"window={window}"); if (elementId != null) parts.Add($"id={Uri.EscapeDataString(elementId)}"); - var url = parts.Count > 0 - ? $"{BaseUrl}/api/screenshot?{string.Join("&", parts)}" - : $"{BaseUrl}/api/screenshot"; - var response = await _http.GetAsync(url, ct); - if (!response.IsSuccessStatusCode) return null; - return await response.Content.ReadAsByteArrayAsync(ct); } - catch { return null; } + var basePath = V("/api/v1/ui/screenshot", "/api/screenshot"); + var url = parts.Count > 0 + ? $"{BaseUrl}{basePath}?{string.Join("&", parts)}" + : $"{BaseUrl}{basePath}"; + var response = await _http.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(ct); + throw new DevFlowAgentException( + (int)response.StatusCode, + ExtractError(body) ?? $"Screenshot request failed ({(int)response.StatusCode} {response.StatusCode})."); + } + return await response.Content.ReadAsByteArrayAsync(ct); + } + + private static string? ExtractError(string body) + { + if (string.IsNullOrWhiteSpace(body)) return null; + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty("error", out var err) + && err.ValueKind == JsonValueKind.String) + return err.GetString(); + } + catch { } + return body.Length > 200 ? body.Substring(0, 200) + "…" : body; } public async Task TapAsync(string elementId, CancellationToken ct = default) - => await PostActionAsync("/api/action/tap", new { elementId }, ct); + => await PostActionAsync(V("/api/v1/ui/actions/tap", "/api/action/tap"), new { elementId }, ct); public async Task FillAsync(string elementId, string text, CancellationToken ct = default) - => await PostActionAsync("/api/action/fill", new { elementId, text }, ct); + => await PostActionAsync(V("/api/v1/ui/actions/fill", "/api/action/fill"), new { elementId, text }, ct); public async Task FocusAsync(string elementId, CancellationToken ct = default) - => await PostActionAsync("/api/action/focus", new { elementId }, ct); + => await PostActionAsync(V("/api/v1/ui/actions/focus", "/api/action/focus"), new { elementId }, ct); // --- Hit Test --- @@ -161,7 +345,8 @@ public async Task FocusAsync(string elementId, CancellationToken ct = defa { var parts = new List { $"x={x}", $"y={y}" }; if (window != null) parts.Add($"window={window}"); - var url = $"/api/hittest?{string.Join("&", parts)}"; + var basePath = V("/api/v1/ui/hit-test", "/api/hittest"); + var url = $"{basePath}?{string.Join("&", parts)}"; return await GetAsync(url, ct); } @@ -172,7 +357,8 @@ public async Task> GetLogsAsync(int limit = 100, int skip var parts = new List { $"limit={limit}" }; if (skip > 0) parts.Add($"skip={skip}"); if (source != null) parts.Add($"source={Uri.EscapeDataString(source)}"); - var url = $"/api/logs?{string.Join("&", parts)}"; + var basePath = V("/api/v1/logs", "/api/logs"); + var url = $"{basePath}?{string.Join("&", parts)}"; return await GetAsync>(url, ct) ?? new(); } @@ -180,20 +366,85 @@ public async Task> GetLogsAsync(int limit = 100, int skip public async Task GetProfilerCapabilitiesAsync(CancellationToken ct = default) { - return await GetAsync("/api/profiler/capabilities", ct); + return await GetAsync( + V("/api/v1/profiler/capabilities", "/api/profiler/capabilities"), ct); } public async Task StartProfilerAsync(int? sampleIntervalMs = null, CancellationToken ct = default) { - if (sampleIntervalMs.HasValue) - return await PostAsync("/api/profiler/start", new { sampleIntervalMs = sampleIntervalMs.Value }, ct); + // Legacy: POST /api/profiler/start returns { session, capabilities }. + // v1: POST /api/v1/profiler/sessions returns the session directly. We track the + // session id internally so stop/samples can target it. + if (!_useV1) + { + object body = sampleIntervalMs.HasValue ? new { sampleIntervalMs = sampleIntervalMs.Value } : (object)new { }; + var legacyResp = await PostAsync("/api/profiler/start", body, ct); + if (legacyResp?.Session?.SessionId is { Length: > 0 } id) + _currentProfilerSessionId = id; + return legacyResp; + } + + try + { + object? body = sampleIntervalMs.HasValue ? new { sampleIntervalMs = sampleIntervalMs.Value } : new { }; + var json = JsonSerializer.Serialize(body); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync($"{BaseUrl}/api/v1/profiler/sessions", content, ct); + if (!response.IsSuccessStatusCode) return null; + + var responseBody = await response.Content.ReadAsStringAsync(ct); + var session = JsonSerializer.Deserialize(responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (session != null && !string.IsNullOrEmpty(session.SessionId)) + _currentProfilerSessionId = session.SessionId; - return await PostAsync("/api/profiler/start", new { }, ct); + var caps = await GetProfilerCapabilitiesAsync(ct); + + return new DevFlowProfilerStartResponse + { + Session = session, + Capabilities = caps, + }; + } + catch { return null; } } public async Task StopProfilerAsync(CancellationToken ct = default) { - return await PostAsync("/api/profiler/stop", new { }, ct); + if (!_useV1) + { + var resp = await PostAsync("/api/profiler/stop", new { }, ct); + _currentProfilerSessionId = null; + return resp; + } + + var sessionId = _currentProfilerSessionId; + if (string.IsNullOrEmpty(sessionId)) return null; + + try + { + var response = await _http.DeleteAsync($"{BaseUrl}/api/v1/profiler/sessions/{Uri.EscapeDataString(sessionId)}", ct); + if (!response.IsSuccessStatusCode) return null; + + DevFlowProfilerSessionInfo? session = null; + var responseBody = await response.Content.ReadAsStringAsync(ct); + if (!string.IsNullOrWhiteSpace(responseBody)) + { + try + { + session = JsonSerializer.Deserialize(responseBody, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch { } + } + + _currentProfilerSessionId = null; + + return new DevFlowProfilerStopResponse + { + Session = session ?? new DevFlowProfilerSessionInfo { SessionId = sessionId, IsActive = false }, + StoppedAtUtc = DateTimeOffset.UtcNow, + }; + } + catch { return null; } } public async Task GetProfilerSamplesAsync( @@ -204,7 +455,17 @@ public async Task> GetLogsAsync(int limit = 100, int skip CancellationToken ct = default) { var safeLimit = Math.Clamp(limit, 1, 5000); - var url = $"/api/profiler/samples?sampleCursor={sampleCursor}&markerCursor={markerCursor}&spanCursor={spanCursor}&limit={safeLimit}"; + string url; + if (_useV1) + { + var sessionId = _currentProfilerSessionId; + if (string.IsNullOrEmpty(sessionId)) return null; + url = $"/api/v1/profiler/sessions/{Uri.EscapeDataString(sessionId)}/samples?sampleCursor={sampleCursor}&markerCursor={markerCursor}&spanCursor={spanCursor}&limit={safeLimit}"; + } + else + { + url = $"/api/profiler/samples?sampleCursor={sampleCursor}&markerCursor={markerCursor}&spanCursor={spanCursor}&limit={safeLimit}"; + } return await GetAsync(url, ct); } @@ -216,7 +477,8 @@ public async Task> GetProfilerHotspotsAsync( { limit = Math.Clamp(limit, 1, 200); minDurationMs = Math.Clamp(minDurationMs, 0, 60_000); - var url = $"/api/profiler/hotspots?limit={limit}&minDurationMs={minDurationMs}"; + var basePath = V("/api/v1/profiler/hotspots", "/api/profiler/hotspots"); + var url = $"{basePath}?limit={limit}&minDurationMs={minDurationMs}"; if (!string.IsNullOrWhiteSpace(kind)) url += $"&kind={Uri.EscapeDataString(kind)}"; return await GetAsync>(url, ct) ?? new(); @@ -231,7 +493,9 @@ public async Task PublishProfilerMarkerAsync( if (string.IsNullOrWhiteSpace(name)) return false; - return await PostActionAsync("/api/profiler/marker", new { name, type, payloadJson }, ct); + return await PostActionAsync( + V("/api/v1/profiler/markers", "/api/profiler/marker"), + new { name, type, payloadJson }, ct); } // --- CDP --- @@ -251,7 +515,8 @@ public async Task PublishProfilerMarkerAsync( var json = JsonSerializer.Serialize(bodyObj); var content = new StringContent(json, Encoding.UTF8, "application/json"); - var response = await _http.PostAsync($"{BaseUrl}/api/cdp", content, ct); + var path = V("/api/v1/webview/evaluate", "/api/cdp"); + var response = await _http.PostAsync($"{BaseUrl}{path}", content, ct); var responseBody = await response.Content.ReadAsStringAsync(ct); return JsonSerializer.Deserialize(responseBody); } @@ -260,26 +525,30 @@ public async Task PublishProfilerMarkerAsync( public async Task> GetCdpTargetsAsync(CancellationToken ct = default) { - return await GetAsync>("/api/cdp/targets", ct) ?? new(); + return await GetAsync>(V("/api/v1/webview/contexts", "/api/cdp/targets"), ct) ?? new(); } // --- Network --- public async Task> GetNetworkRequestsAsync(CancellationToken ct = default) { - return await GetAsync>("/api/network", ct) ?? new(); + return await GetAsync>(V("/api/v1/network/requests", "/api/network"), ct) ?? new(); } public async Task GetNetworkRequestDetailAsync(string id, CancellationToken ct = default) { - return await GetAsync($"/api/network/{Uri.EscapeDataString(id)}", ct); + var path = V($"/api/v1/network/requests/{Uri.EscapeDataString(id)}", $"/api/network/{Uri.EscapeDataString(id)}"); + return await GetAsync(path, ct); } public async Task ClearNetworkRequestsAsync(CancellationToken ct = default) { try { - var response = await _http.PostAsync($"{BaseUrl}/api/network/clear", null, ct); + // v1: DELETE /api/v1/network/requests | legacy: POST /api/network/clear + HttpResponseMessage response = _useV1 + ? await _http.DeleteAsync($"{BaseUrl}/api/v1/network/requests", ct) + : await _http.PostAsync($"{BaseUrl}/api/network/clear", null, ct); return response.IsSuccessStatusCode; } catch { return false; } @@ -301,7 +570,9 @@ public async Task StreamNetworkRequestsAsync(Action onReq try { - var wsUrl = $"ws://{AgentHost}:{AgentPort}/ws/network"; + var wsUrl = _useV1 + ? $"ws://{AgentHost}:{AgentPort}/ws/v1/network" + : $"ws://{AgentHost}:{AgentPort}/ws/network"; await _networkWs.ConnectAsync(new Uri(wsUrl), token); var buffer = new byte[64 * 1024]; @@ -371,7 +642,10 @@ public async Task StreamLogsAsync( if (source != null) parts.Add($"source={Uri.EscapeDataString(source)}"); parts.Add($"replay={replay}"); var query = string.Join("&", parts); - var wsUrl = $"ws://{AgentHost}:{AgentPort}/ws/logs?{query}"; + var wsBase = _useV1 + ? $"ws://{AgentHost}:{AgentPort}/ws/v1/logs" + : $"ws://{AgentHost}:{AgentPort}/ws/logs"; + var wsUrl = $"{wsBase}?{query}"; await _logsWs.ConnectAsync(new Uri(wsUrl), token); var buffer = new byte[64 * 1024]; @@ -431,62 +705,111 @@ public void StopLogStream() // --- Platform Info --- public async Task GetAppInfoAsync(CancellationToken ct = default) - => await GetAsync("/api/platform/app-info", ct); + => await GetAsync(V("/api/v1/device/app", "/api/platform/app-info"), ct); public async Task GetDeviceInfoAsync(CancellationToken ct = default) - => await GetAsync("/api/platform/device-info", ct); + => await GetAsync(V("/api/v1/device/info", "/api/platform/device-info"), ct); public async Task GetDisplayInfoAsync(CancellationToken ct = default) - => await GetAsync("/api/platform/device-display", ct); + => await GetAsync(V("/api/v1/device/display", "/api/platform/device-display"), ct); public async Task GetBatteryInfoAsync(CancellationToken ct = default) - => await GetAsync("/api/platform/battery", ct); + => await GetAsync(V("/api/v1/device/battery", "/api/platform/battery"), ct); public async Task GetConnectivityAsync(CancellationToken ct = default) - => await GetAsync("/api/platform/connectivity", ct); + => await GetAsync(V("/api/v1/device/connectivity", "/api/platform/connectivity"), ct); public async Task GetVersionTrackingAsync(CancellationToken ct = default) - => await GetAsync("/api/platform/version-tracking", ct); + => await GetAsync(V("/api/v1/device/version-tracking", "/api/platform/version-tracking"), ct); public async Task> GetPermissionsAsync(CancellationToken ct = default) { - var result = await GetAsync("/api/platform/permissions", ct); + var result = await GetAsync(V("/api/v1/device/permissions", "/api/platform/permissions"), ct); return result?.Permissions ?? new(); } public async Task CheckPermissionAsync(string permission, CancellationToken ct = default) - => await GetAsync($"/api/platform/permissions/{Uri.EscapeDataString(permission)}", ct); + => await GetAsync( + V($"/api/v1/device/permissions/{Uri.EscapeDataString(permission)}", + $"/api/platform/permissions/{Uri.EscapeDataString(permission)}"), ct); public async Task GetGeolocationAsync(string accuracy = "Medium", int timeoutSeconds = 10, CancellationToken ct = default) - => await GetAsync($"/api/platform/geolocation?accuracy={Uri.EscapeDataString(accuracy)}&timeout={timeoutSeconds}", ct); + { + var basePath = V("/api/v1/device/geolocation", "/api/platform/geolocation"); + return await GetAsync($"{basePath}?accuracy={Uri.EscapeDataString(accuracy)}&timeout={timeoutSeconds}", ct); + } // --- Preferences --- public async Task> GetPreferencesAsync(string? sharedName = null, CancellationToken ct = default) { var query = sharedName != null ? $"?sharedName={Uri.EscapeDataString(sharedName)}" : ""; - var result = await GetAsync($"/api/preferences{query}", ct); - return result?.Keys ?? new(); + var basePath = V("/api/v1/storage/preferences", "/api/preferences"); + var response = await _http.GetAsync($"{BaseUrl}{basePath}{query}", ct); + var json = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) + { + throw new DevFlowAgentException( + (int)response.StatusCode, + ExtractError(json) ?? $"Preferences request failed ({(int)response.StatusCode} {response.StatusCode})."); + } + if (string.IsNullOrWhiteSpace(json)) return new(); + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + // Bare array (DevFlow v1 reference shape) + if (root.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(root.GetRawText(), opts) ?? new(); + + // Wrapped: {keys:[...]} (legacy MAUI DevFlow) or {preferences:[...]} (Ailoha) + if (root.ValueKind == JsonValueKind.Object) + { + foreach (var name in new[] { "keys", "preferences", "items", "entries" }) + { + if (root.TryGetProperty(name, out var arr) && arr.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(arr.GetRawText(), opts) ?? new(); + } + } + return new(); + } + catch (JsonException ex) + { + throw new DevFlowAgentException(0, $"Failed to parse preferences response: {ex.Message}"); + } } public async Task GetPreferenceAsync(string key, string type = "string", string? sharedName = null, CancellationToken ct = default) { var query = $"?type={Uri.EscapeDataString(type)}"; if (sharedName != null) query += $"&sharedName={Uri.EscapeDataString(sharedName)}"; - return await GetAsync($"/api/preferences/{Uri.EscapeDataString(key)}{query}", ct); + var path = V( + $"/api/v1/storage/preferences/{Uri.EscapeDataString(key)}", + $"/api/preferences/{Uri.EscapeDataString(key)}"); + return await GetAsync($"{path}{query}", ct); } public async Task SetPreferenceAsync(string key, object? value, string? type = null, string? sharedName = null, CancellationToken ct = default) { var body = new DevFlowPreferenceSetRequest { Value = value, Type = type ?? "string", SharedName = sharedName }; - var result = await PostAsync($"/api/preferences/{Uri.EscapeDataString(key)}", body, ct); - return result != null; + var path = V( + $"/api/v1/storage/preferences/{Uri.EscapeDataString(key)}", + $"/api/preferences/{Uri.EscapeDataString(key)}"); + // v1 uses PUT for set; legacy uses POST. + return _useV1 + ? await PutAsync(path, body, ct) + : await PostAsync(path, body, ct) != null; } public async Task DeletePreferenceAsync(string key, string? sharedName = null, CancellationToken ct = default) { var query = sharedName != null ? $"?sharedName={Uri.EscapeDataString(sharedName)}" : ""; - return await DeleteAsync($"/api/preferences/{Uri.EscapeDataString(key)}{query}", ct); + var path = V( + $"/api/v1/storage/preferences/{Uri.EscapeDataString(key)}", + $"/api/preferences/{Uri.EscapeDataString(key)}"); + return await DeleteAsync($"{path}{query}", ct); } public async Task ClearPreferencesAsync(string? sharedName = null, CancellationToken ct = default) @@ -494,7 +817,10 @@ public async Task ClearPreferencesAsync(string? sharedName = null, Cancell var query = sharedName != null ? $"?sharedName={Uri.EscapeDataString(sharedName)}" : ""; try { - var response = await _http.PostAsync($"{BaseUrl}/api/preferences/clear{query}", null, ct); + // v1: DELETE collection. Legacy: POST /api/preferences/clear. + HttpResponseMessage response = _useV1 + ? await _http.DeleteAsync($"{BaseUrl}/api/v1/storage/preferences{query}", ct) + : await _http.PostAsync($"{BaseUrl}/api/preferences/clear{query}", null, ct); return response.IsSuccessStatusCode; } catch { return false; } @@ -503,23 +829,34 @@ public async Task ClearPreferencesAsync(string? sharedName = null, Cancell // --- Secure Storage --- public async Task GetSecureStorageAsync(string key, CancellationToken ct = default) - => await GetAsync($"/api/secure-storage/{Uri.EscapeDataString(key)}", ct); + => await GetAsync( + V($"/api/v1/storage/secure/{Uri.EscapeDataString(key)}", + $"/api/secure-storage/{Uri.EscapeDataString(key)}"), ct); public async Task SetSecureStorageAsync(string key, string value, CancellationToken ct = default) { var body = new { value }; - var result = await PostAsync($"/api/secure-storage/{Uri.EscapeDataString(key)}", body, ct); - return result != null; + var path = V( + $"/api/v1/storage/secure/{Uri.EscapeDataString(key)}", + $"/api/secure-storage/{Uri.EscapeDataString(key)}"); + // v1 uses PUT for set; legacy uses POST. + return _useV1 + ? await PutAsync(path, body, ct) + : await PostAsync(path, body, ct) != null; } public async Task DeleteSecureStorageAsync(string key, CancellationToken ct = default) - => await DeleteAsync($"/api/secure-storage/{Uri.EscapeDataString(key)}", ct); + => await DeleteAsync( + V($"/api/v1/storage/secure/{Uri.EscapeDataString(key)}", + $"/api/secure-storage/{Uri.EscapeDataString(key)}"), ct); public async Task ClearSecureStorageAsync(CancellationToken ct = default) { try { - var response = await _http.PostAsync($"{BaseUrl}/api/secure-storage/clear", null, ct); + HttpResponseMessage response = _useV1 + ? await _http.DeleteAsync($"{BaseUrl}/api/v1/storage/secure", ct) + : await _http.PostAsync($"{BaseUrl}/api/secure-storage/clear", null, ct); return response.IsSuccessStatusCode; } catch { return false; } @@ -528,13 +865,38 @@ public async Task ClearSecureStorageAsync(CancellationToken ct = default) // --- Sensors --- public async Task> GetSensorsAsync(CancellationToken ct = default) - => await GetAsync>("/api/sensors", ct) ?? new(); + { + var basePath = V("/api/v1/device/sensors", "/api/sensors"); + try + { + var json = await _http.GetStringAsync($"{BaseUrl}{basePath}", ct); + if (string.IsNullOrWhiteSpace(json)) return new(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + if (root.ValueKind == JsonValueKind.Array) + return JsonSerializer.Deserialize>(root.GetRawText(), opts) ?? new(); + + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("sensors", out var arr) && + arr.ValueKind == JsonValueKind.Array) + { + return JsonSerializer.Deserialize>(arr.GetRawText(), opts) ?? new(); + } + return new(); + } + catch { return new(); } + } public async Task StartSensorAsync(string sensor, string speed = "UI", CancellationToken ct = default) { try { - var response = await _http.PostAsync($"{BaseUrl}/api/sensors/{Uri.EscapeDataString(sensor)}/start?speed={Uri.EscapeDataString(speed)}", null, ct); + var path = V( + $"/api/v1/device/sensors/{Uri.EscapeDataString(sensor)}/start", + $"/api/sensors/{Uri.EscapeDataString(sensor)}/start"); + var response = await _http.PostAsync($"{BaseUrl}{path}?speed={Uri.EscapeDataString(speed)}", null, ct); return response.IsSuccessStatusCode; } catch { return false; } @@ -544,7 +906,10 @@ public async Task StopSensorAsync(string sensor, CancellationToken ct = de { try { - var response = await _http.PostAsync($"{BaseUrl}/api/sensors/{Uri.EscapeDataString(sensor)}/stop", null, ct); + var path = V( + $"/api/v1/device/sensors/{Uri.EscapeDataString(sensor)}/stop", + $"/api/sensors/{Uri.EscapeDataString(sensor)}/stop"); + var response = await _http.PostAsync($"{BaseUrl}{path}", null, ct); return response.IsSuccessStatusCode; } catch { return false; } @@ -561,7 +926,10 @@ public async Task StreamSensorAsync(string sensor, Action var token = cts.Token; try { - var wsUrl = $"ws://{AgentHost}:{AgentPort}/ws/sensors?sensor={Uri.EscapeDataString(sensor)}&speed={Uri.EscapeDataString(speed)}&throttleMs={throttleMs}"; + var wsBase = _useV1 + ? $"ws://{AgentHost}:{AgentPort}/ws/v1/device/sensors" + : $"ws://{AgentHost}:{AgentPort}/ws/sensors"; + var wsUrl = $"{wsBase}?sensor={Uri.EscapeDataString(sensor)}&speed={Uri.EscapeDataString(speed)}&throttleMs={throttleMs}"; await ws.ConnectAsync(new Uri(wsUrl), token); var buffer = new byte[16 * 1024]; @@ -580,8 +948,26 @@ public async Task StreamSensorAsync(string sensor, Action sb.Clear(); try { - var reading = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - if (reading != null) onReading(reading); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + // v1 envelope: {type:"reading", timestamp, reading:{sensor, timestamp, values}} + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("type", out var typeProp) && + typeProp.GetString() == "reading" && + root.TryGetProperty("reading", out var readingProp)) + { + var reading = JsonSerializer.Deserialize(readingProp.GetRawText(), opts); + if (reading != null) onReading(reading); + } + else + { + // Legacy: bare reading object + var reading = JsonSerializer.Deserialize(json, opts); + if (reading != null && (!string.IsNullOrEmpty(reading.Sensor) || reading.Data.ValueKind != JsonValueKind.Undefined)) + onReading(reading); + } } catch { } } @@ -637,6 +1023,9 @@ public int StreamingSensorCount // --- Helpers --- + /// Pick the v1 or legacy URL based on the detected protocol. + private string V(string v1Path, string legacyPath) => _useV1 ? v1Path : legacyPath; + private async Task GetAsync(string path, CancellationToken ct = default) where T : class { try @@ -688,6 +1077,18 @@ private async Task DeleteAsync(string path, CancellationToken ct = default catch { return false; } } + private async Task PutAsync(string path, object body, CancellationToken ct = default) + { + try + { + var json = JsonSerializer.Serialize(body); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _http.PutAsync($"{BaseUrl}{path}", content, ct); + return response.IsSuccessStatusCode; + } + catch { return false; } + } + public void Dispose() { if (_disposed) return; @@ -700,3 +1101,12 @@ public void Dispose() _http.Dispose(); } } + +/// Protocol exposed by a MAUI DevFlow / Ailoha agent. +public enum DevFlowAgentProtocol +{ + /// Modern /api/v1/* + /ws/v1/* surface (DevFlow preview.7+, Ailoha). + V1, + /// Legacy /api/* + /ws/* surface (DevFlow preview.6 and earlier). + Legacy, +} diff --git a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj index e42f96d5..d33aaa17 100644 --- a/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj +++ b/src/MauiSherpa.MacOS/MauiSherpa.MacOS.csproj @@ -68,6 +68,12 @@ + + + + + diff --git a/src/MauiSherpa.MacOS/Resources/PrivacyInfo.plist b/src/MauiSherpa.MacOS/Resources/PrivacyInfo.plist new file mode 100644 index 00000000..5dd4715e --- /dev/null +++ b/src/MauiSherpa.MacOS/Resources/PrivacyInfo.plist @@ -0,0 +1,12 @@ + + + + + NSBluetoothAlwaysUsageDescription + MAUI Sherpa uses Bluetooth to inspect Bluetooth sensors on connected devices via the MAUI DevFlow agent. + NSBluetoothPeripheralUsageDescription + MAUI Sherpa uses Bluetooth to inspect Bluetooth sensors on connected devices via the MAUI DevFlow agent. + NSLocalNetworkUsageDescription + MAUI Sherpa connects to MAUI DevFlow and Ailoha agents running on your local network to inspect MAUI apps. + + diff --git a/src/MauiSherpa/Pages/DevFlow.razor b/src/MauiSherpa/Pages/DevFlow.razor index 1451e202..f4abc604 100644 --- a/src/MauiSherpa/Pages/DevFlow.razor +++ b/src/MauiSherpa/Pages/DevFlow.razor @@ -34,7 +34,7 @@ } else if (brokerConnected == false) { - Broker not reachable at @brokerHost:@brokerPort + No broker reachable on @brokerHost (tried @brokerPort, 19223, 19323) } @@ -171,12 +171,25 @@ try { - var healthy = await DevFlowAgentClient.IsBrokerHealthyAsync(brokerHost, brokerPort); - brokerConnected = healthy; + // Probe the user-entered port plus both standard broker ports (DevFlow 19223, + // Ailoha 19323) so users don't have to change the field for either toolchain. + var portsToTry = new List { brokerPort }; + foreach (var p in DevFlowAgentClient.DefaultBrokerPorts) + if (!portsToTry.Contains(p)) portsToTry.Add(p); - if (healthy) + var probes = portsToTry + .Select(async port => (port, healthy: await DevFlowAgentClient.IsBrokerHealthyAsync(brokerHost, port))) + .ToList(); + var probeResults = await Task.WhenAll(probes); + var healthyPorts = probeResults.Where(r => r.healthy).Select(r => r.port).ToList(); + + brokerConnected = healthyPorts.Count > 0; + + if (healthyPorts.Count > 0) { - agents = await DevFlowAgentClient.GetBrokerAgentsAsync(brokerHost, brokerPort); + var agentLists = await Task.WhenAll(healthyPorts.Select(p => + DevFlowAgentClient.GetBrokerAgentsAsync(brokerHost, p))); + agents = agentLists.SelectMany(a => a).ToList(); // Fetch version from each agent's status endpoint (parallel) var tasks = agents.Select(async agent => diff --git a/src/MauiSherpa/Pages/Inspector/DevFlowTreeTab.razor b/src/MauiSherpa/Pages/Inspector/DevFlowTreeTab.razor index 089d956c..e650c079 100644 --- a/src/MauiSherpa/Pages/Inspector/DevFlowTreeTab.razor +++ b/src/MauiSherpa/Pages/Inspector/DevFlowTreeTab.razor @@ -42,6 +42,13 @@ } } + else if (!string.IsNullOrEmpty(screenshotError)) + { +
+ + @screenshotError +
+ } else {
@@ -269,6 +276,7 @@ private HashSet expandedNodes = new(); private string searchQuery = ""; private string? screenshotData; + private string? screenshotError; private bool isLoadingTree; private bool isLoadingProps; private int autoRefreshInterval; @@ -504,12 +512,26 @@ { try { + screenshotError = null; var bytes = await Client.GetScreenshotAsync(window: window ?? selectedWindowIndex); screenshotData = bytes != null ? Convert.ToBase64String(bytes) : null; StateHasChanged(); await UpdateOverlayAsync(); } - catch { } + catch (DevFlowAgentException ex) + { + screenshotData = null; + screenshotError = ex.StatusCode > 0 + ? $"Agent returned {ex.StatusCode}: {ex.Message}" + : ex.Message; + StateHasChanged(); + } + catch (Exception ex) + { + screenshotData = null; + screenshotError = $"Screenshot failed: {ex.Message}"; + StateHasChanged(); + } // Keep tree in sync with screenshot (avoid recursion via flag) if (!_syncingRefresh)