diff --git a/.claude/skills/maui-ai-debugging/SKILL.md b/.claude/skills/maui-ai-debugging/SKILL.md index 4a720ca..af22fd0 100644 --- a/.claude/skills/maui-ai-debugging/SKILL.md +++ b/.claude/skills/maui-ai-debugging/SKILL.md @@ -296,6 +296,73 @@ 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. +### 8. App Storage (Preferences & Secure Storage) + +Read, write, and delete app preferences and secure storage entries remotely. Useful for +debugging state, resetting app configuration, or injecting test values. + +```bash +# Preferences (typed key-value store) +maui-devflow MAUI preferences list # list all known keys +maui-devflow MAUI preferences get theme_mode # get a string value +maui-devflow MAUI preferences get counter --type int # get a typed value +maui-devflow MAUI preferences set api_url "https://dev.example.com" +maui-devflow MAUI preferences set dark_mode true --type bool +maui-devflow MAUI preferences delete temp_key +maui-devflow MAUI preferences clear # clear all + +# Shared preferences containers +maui-devflow MAUI preferences list --sharedName settings +maui-devflow MAUI preferences set key val --sharedName settings + +# Secure Storage (encrypted, string values only) +maui-devflow MAUI secure-storage get auth_token +maui-devflow MAUI secure-storage set auth_token "eyJhbGc..." +maui-devflow MAUI secure-storage delete auth_token +maui-devflow MAUI secure-storage clear +``` + +**Note:** Preference key listing uses an internal registry (keys set via the agent are tracked). +Keys set directly in app code won't appear in `list` unless also set via the agent. + +### 9. Platform Info & Device Features + +Query read-only device and app state. These are one-shot snapshot reads. + +```bash +maui-devflow MAUI platform app-info # app name, version, build, theme +maui-devflow MAUI platform device-info # manufacturer, model, OS, idiom +maui-devflow MAUI platform display # screen density, size, orientation +maui-devflow MAUI platform battery # charge level, state, power source +maui-devflow MAUI platform connectivity # WiFi/Cellular/Ethernet, network access +maui-devflow MAUI platform version-tracking # version history, first launch detection +maui-devflow MAUI platform permissions # check all common permission statuses +maui-devflow MAUI platform permissions camera # check a specific permission +maui-devflow MAUI platform geolocation # current GPS coordinates +maui-devflow MAUI platform geolocation --accuracy High --timeout 15 +``` + +### 10. Device Sensors + +Start, stop, and stream real-time sensor data. Sensors auto-start when streaming. + +```bash +maui-devflow MAUI sensors list # list sensors + status +maui-devflow MAUI sensors start accelerometer # start a sensor +maui-devflow MAUI sensors stop accelerometer + +# Stream readings to stdout (JSONL) +maui-devflow MAUI sensors stream accelerometer # Ctrl+C to stop +maui-devflow MAUI sensors stream gyroscope --speed Game # higher frequency +maui-devflow MAUI sensors stream compass --duration 10 # stop after 10 seconds +``` + +Available sensors: `accelerometer`, `barometer`, `compass`, `gyroscope`, `magnetometer`, `orientation`. +Speed options: `UI` (default), `Game`, `Fastest`, `Default`. + +**WebSocket streaming:** Sensor data uses WebSocket (`/ws/sensors?sensor=`) for +real-time push. Each reading is a JSON object with `sensor`, `timestamp`, and `data` fields. + ## Command Reference ### maui-devflow MAUI (Native Agent) @@ -340,6 +407,27 @@ resolution options are provided. | `MAUI network list [--host H] [--method M]` | One-shot: dump recent captured HTTP requests | | `MAUI network detail ` | Full request/response details: headers, body, timing | | `MAUI network clear` | Clear the captured request buffer | +| `MAUI preferences list [--sharedName N]` | List all known preference keys and values | +| `MAUI preferences get [--type T] [--sharedName N]` | Get a preference value. Types: string, int, bool, double, float, long, datetime | +| `MAUI preferences set [--type T] [--sharedName N]` | Set a preference value | +| `MAUI preferences delete [--sharedName N]` | Remove a preference | +| `MAUI preferences clear [--sharedName N]` | Clear all preferences | +| `MAUI secure-storage get ` | Get a secure storage value | +| `MAUI secure-storage set ` | Set a secure storage value | +| `MAUI secure-storage delete ` | Remove a secure storage entry | +| `MAUI secure-storage clear` | Clear all secure storage entries | +| `MAUI platform app-info` | App name, version, build, package, theme | +| `MAUI platform device-info` | Device manufacturer, model, OS, idiom | +| `MAUI platform display` | Screen density, size, orientation, refresh rate | +| `MAUI platform battery` | Battery level, state, power source | +| `MAUI platform connectivity` | Network access and connection profiles | +| `MAUI platform version-tracking` | Current/previous/first version, build history, isFirstLaunch | +| `MAUI platform permissions [name]` | Check permission status. Omit name to check all common permissions | +| `MAUI platform geolocation [--accuracy A] [--timeout N]` | Get current GPS coordinates. Accuracy: Lowest, Low, Medium (default), High, Best | +| `MAUI sensors list` | List available sensors and their current state (started/stopped) | +| `MAUI sensors start [--speed S]` | Start a sensor. Sensors: accelerometer, barometer, compass, gyroscope, magnetometer, orientation. Speed: UI (default), Game, Fastest, Default | +| `MAUI sensors stop ` | Stop a sensor | +| `MAUI sensors stream [--speed S] [--duration N]` | Stream sensor readings via WebSocket. Duration 0 = indefinite (Ctrl+C to stop) | | `commands [--json]` | List all available commands with descriptions. `--json` returns machine-readable schema with command names, descriptions, and whether they mutate state | Element IDs come from `MAUI tree` or `MAUI query`. AutomationId-based elements use their diff --git a/AGENTS.md b/AGENTS.md index b96d8eb..4f56dcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Agent architecture: ``` - **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 +- **WebSocket support** — `/ws/network` streams captured HTTP requests in real-time; `/ws/logs` streams log entries; `/ws/sensors?sensor=` streams device sensor readings; CDP still uses HTTP POST via Chobitsu - **Multi-WebView CDP** — Each `BlazorWebView` registers independently with the agent. CDP commands accept `?webview=` to target by index, AutomationId, or element ID. The `BlazorWebViewDebugServiceBase` manages per-WebView state via inner `WebViewBridge` instances - **Blazor→Agent wiring** uses reflection to avoid a direct package dependency between the two NuGet packages @@ -112,6 +112,16 @@ The CLI command `maui-devflow update-skill` downloads the latest skill files fro - **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 +## Platform Features (Preferences, SecureStorage, Device Info, Sensors) + +- **Preferences CRUD**: `/api/preferences` endpoints for list/get/set/delete/clear. MAUI's `Preferences` API has no "list all" method — the agent tracks known keys via an internal registry key (`__devflow_known_keys`) stored as a unit-separator-delimited string. Only keys set via the agent are tracked. +- **SecureStorage CRUD**: `/api/secure-storage` endpoints for get/set/delete/clear via `SecureStorage` static API. +- **Platform Info**: Read-only endpoints under `/api/platform/` for app-info, device-info, device-display, battery, connectivity, version-tracking, permissions, geolocation. All use MAUI's static API classes (`AppInfo.Current`, `DeviceInfo.Current`, `DeviceDisplay.MainDisplayInfo`, `Battery.Default`, `Connectivity.Current`, `VersionTracking.Default`, `Geolocation.GetLocationAsync()`). +- **Permissions**: The agent maintains a `KnownPermissions` dictionary mapping friendly names to `Permissions.BasePermission` factories. Check one or all permissions via `/api/platform/permissions[/{name}]`. +- **Sensor Streaming**: `SensorManager` class manages sensor lifecycle (start/stop) and WebSocket broadcasting. Supports: accelerometer, barometer, compass, gyroscope, magnetometer, orientation. WebSocket at `/ws/sensors?sensor=` auto-starts the sensor on connect and broadcasts JSON readings to all subscribers. REST endpoints for list/start/stop at `/api/sensors`. +- **DELETE routes**: `AgentHttpServer` supports DELETE method via `_deleteRoutes` dictionary and `MapDelete()`, used by preferences and secure-storage delete endpoints. +- **CLI**: `MAUI preferences`, `MAUI secure-storage`, `MAUI platform`, `MAUI sensors` command groups with full subcommands. + ## 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 0f44adf..462c7dd 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ manually check the simulator. - **Blazor WebView Debugging** — CDP bridge using Chobitsu for JavaScript evaluation, DOM manipulation, page navigation. Supports multiple BlazorWebViews per app with independent targeting - **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 +- **App Storage Management** — Read, write, and delete Preferences (typed key-value) and SecureStorage entries remotely +- **Platform Features** — Query device info, battery, connectivity, display, permissions, version tracking, geolocation, and app info +- **Sensor Streaming** — Start/stop device sensors (accelerometer, gyroscope, compass, barometer, magnetometer, orientation) with real-time WebSocket streaming - **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 @@ -172,6 +175,22 @@ 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 +# App storage +maui-devflow MAUI preferences list # list known keys +maui-devflow MAUI preferences get theme_mode # get a value +maui-devflow MAUI preferences set api_url "https://dev.example.com" +maui-devflow MAUI secure-storage get auth_token # encrypted storage + +# Platform info & sensors +maui-devflow MAUI platform app-info # app name, version, theme +maui-devflow MAUI platform device-info # manufacturer, model, OS +maui-devflow MAUI platform battery # charge level, state +maui-devflow MAUI platform connectivity # network access, profiles +maui-devflow MAUI platform permissions # check all permission statuses +maui-devflow MAUI platform geolocation # GPS coordinates +maui-devflow MAUI sensors list # list sensors + status +maui-devflow MAUI sensors stream accelerometer # live WebSocket stream + # Live edit native properties (no rebuild) maui-devflow MAUI set-property HeaderLabel TextColor "Tomato" maui-devflow MAUI set-property HeaderLabel FontSize "32" @@ -281,6 +300,28 @@ auto-assigned by the broker (range 10223–10899), or configurable via `.mauidev | `/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/preferences` | GET | List all known preference keys and values. `?sharedName=N` for shared container | +| `/api/preferences/{key}` | GET | Get preference value. `?type=int\|bool\|double\|...` `?sharedName=N` | +| `/api/preferences/{key}` | POST | Set preference `{"value":"...","type":"string","sharedName":null}` | +| `/api/preferences/{key}` | DELETE | Remove a preference key | +| `/api/preferences/clear` | POST | Clear all preferences (optionally `?sharedName=N`) | +| `/api/secure-storage/{key}` | GET | Get secure storage value | +| `/api/secure-storage/{key}` | POST | Set secure storage `{"value":"..."}` | +| `/api/secure-storage/{key}` | DELETE | Remove secure storage entry | +| `/api/secure-storage/clear` | POST | Clear all secure storage entries | +| `/api/platform/app-info` | GET | App name, version, build, theme, layout direction | +| `/api/platform/device-info` | GET | Manufacturer, model, platform, idiom, OS version | +| `/api/platform/device-display` | GET | Screen width, height, density, orientation, refresh rate | +| `/api/platform/battery` | GET | Charge level, state, power source, energy saver status | +| `/api/platform/connectivity` | GET | Network access level and connection profiles | +| `/api/platform/version-tracking` | GET | Version/build history, first launch info | +| `/api/platform/permissions` | GET | Check status of all known permissions | +| `/api/platform/permissions/{name}` | GET | Check a specific permission status | +| `/api/platform/geolocation` | GET | GPS coordinates. `?accuracy=Medium` `?timeout=10` | +| `/api/sensors` | GET | List all sensors with support/active/subscriber status | +| `/api/sensors/{sensor}/start` | POST | Start sensor. `?speed=UI\|Game\|Fastest\|Default` | +| `/api/sensors/{sensor}/stop` | POST | Stop sensor | +| `/ws/sensors` | WS | Stream sensor readings. `?sensor=accelerometer` `?speed=UI` | | `/api/cdp` | POST | Forward CDP command to Blazor WebView. Use `?webview=` to target a specific WebView | | `/api/cdp/webviews` | GET | List registered CDP WebViews (index, AutomationId, elementId, ready status) | | `/api/cdp/source` | GET | Get page HTML source. Use `?webview=` to target a specific WebView | diff --git a/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs b/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs index 1e8daf7..b33a861 100644 --- a/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs +++ b/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs @@ -19,6 +19,7 @@ public class AgentHttpServer : IDisposable private readonly int _port; private readonly Dictionary>> _getRoutes = new(); private readonly Dictionary>> _postRoutes = new(); + private readonly Dictionary>> _deleteRoutes = new(); private readonly Dictionary> _wsRoutes = new(); public int Port => _port; @@ -35,6 +36,9 @@ public void MapGet(string path, Func> handler) public void MapPost(string path, Func> handler) => _postRoutes[path.TrimEnd('/')] = handler; + public void MapDelete(string path, Func> handler) + => _deleteRoutes[path.TrimEnd('/')] = handler; + public void MapWebSocket(string path, Func handler) => _wsRoutes[path.TrimEnd('/')] = handler; @@ -223,7 +227,9 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken ct) private async Task RouteRequestAsync(HttpRequest request) { - var routes = request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase) ? _postRoutes : _getRoutes; + var routes = request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase) ? _postRoutes + : request.Method.Equals("DELETE", StringComparison.OrdinalIgnoreCase) ? _deleteRoutes + : _getRoutes; // Try exact match first if (routes.TryGetValue(request.Path, out var handler)) diff --git a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs index 721f77e..86a0076 100644 --- a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs +++ b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs @@ -2,9 +2,14 @@ using System.Reflection; using System.Runtime.CompilerServices; using Microsoft.Maui; +using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.Controls; using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Devices; +using Microsoft.Maui.Devices.Sensors; using Microsoft.Maui.Dispatching; +using Microsoft.Maui.Networking; +using Microsoft.Maui.Storage; using MauiDevFlow.Agent.Core.Profiling; using MauiDevFlow.Logging; using MauiDevFlow.Agent.Core.Network; @@ -31,6 +36,11 @@ public class DevFlowAgentService : IDisposable, IMarkerPublisher /// public NetworkRequestStore NetworkStore { get; } + /// + /// Manages sensor subscriptions and broadcasts readings to WebSocket clients. + /// + public SensorManager Sensors { get; } + private readonly IProfilerCollector _profilerCollector; private readonly ProfilerSessionStore _profilerSessions; private readonly SemaphoreSlim _profilerStateGate = new(1, 1); @@ -201,6 +211,7 @@ public DevFlowAgentService(AgentOptions? options = null) _server = new AgentHttpServer(_options.Port); _treeWalker = CreateTreeWalker(); NetworkStore = new NetworkRequestStore(_options.MaxNetworkBufferSize); + Sensors = new SensorManager(); _profilerCollector = CreateProfilerCollector(); _profilerSessions = new ProfilerSessionStore( Math.Max(1, _options.MaxProfilerSamples), @@ -366,6 +377,36 @@ private void RegisterRoutes() // WebSocket: live log streaming _server.MapWebSocket("/ws/logs", HandleLogsWebSocket); + + // Preferences (CRUD) + _server.MapGet("/api/preferences", HandlePreferencesList); + _server.MapGet("/api/preferences/{key}", HandlePreferencesGet); + _server.MapPost("/api/preferences/{key}", HandlePreferencesSet); + _server.MapDelete("/api/preferences/{key}", HandlePreferencesDelete); + _server.MapPost("/api/preferences/clear", HandlePreferencesClear); + + // Secure Storage (CRUD) + _server.MapGet("/api/secure-storage/{key}", HandleSecureStorageGet); + _server.MapPost("/api/secure-storage/{key}", HandleSecureStorageSet); + _server.MapDelete("/api/secure-storage/{key}", HandleSecureStorageDelete); + _server.MapPost("/api/secure-storage/clear", HandleSecureStorageClear); + + // Platform info (read-only) + _server.MapGet("/api/platform/app-info", HandlePlatformAppInfo); + _server.MapGet("/api/platform/device-info", HandlePlatformDeviceInfo); + _server.MapGet("/api/platform/device-display", HandlePlatformDeviceDisplay); + _server.MapGet("/api/platform/battery", HandlePlatformBattery); + _server.MapGet("/api/platform/connectivity", HandlePlatformConnectivity); + _server.MapGet("/api/platform/version-tracking", HandlePlatformVersionTracking); + _server.MapGet("/api/platform/permissions", HandlePlatformPermissions); + _server.MapGet("/api/platform/permissions/{permission}", HandlePlatformPermissionCheck); + _server.MapGet("/api/platform/geolocation", HandlePlatformGeolocation); + + // Sensors + _server.MapGet("/api/sensors", HandleSensorsList); + _server.MapPost("/api/sensors/{sensor}/start", HandleSensorStart); + _server.MapPost("/api/sensors/{sensor}/stop", HandleSensorStop); + _server.MapWebSocket("/ws/sensors", HandleSensorWebSocket); } private async Task HandleStatus(HttpRequest request) @@ -2987,6 +3028,7 @@ public void Dispose() _disposed = true; NetworkStore.OnRequestCaptured -= HandleCapturedNetworkRequest; StopAutoUiHooks(); + Sensors.Dispose(); var cts = _profilerLoopCts; var loopTask = _profilerLoopTask; @@ -3324,6 +3366,643 @@ private async Task HandleCdpSource(HttpRequest request) return HttpResponse.Error($"Failed to get page source: {ex.Message}"); } } + + // ── Preferences endpoints ── + + private const string PreferencesKeyRegistryKey = "__devflow_known_keys"; + private const string PreferencesKeyRegistrySeparator = "\x1F"; // unit separator + + private HashSet GetKnownPreferenceKeys(string? sharedName) + { + try + { + var raw = sharedName != null + ? Preferences.Get(PreferencesKeyRegistryKey, "", sharedName) + : Preferences.Get(PreferencesKeyRegistryKey, ""); + if (string.IsNullOrEmpty(raw)) return new HashSet(); + return new HashSet(raw.Split(PreferencesKeyRegistrySeparator, StringSplitOptions.RemoveEmptyEntries)); + } + catch { return new HashSet(); } + } + + private void SaveKnownPreferenceKeys(HashSet keys, string? sharedName) + { + var raw = string.Join(PreferencesKeyRegistrySeparator, keys); + if (sharedName != null) + Preferences.Set(PreferencesKeyRegistryKey, raw, sharedName); + else + Preferences.Set(PreferencesKeyRegistryKey, raw); + } + + private void TrackPreferenceKey(string key, string? sharedName) + { + var keys = GetKnownPreferenceKeys(sharedName); + if (keys.Add(key)) + SaveKnownPreferenceKeys(keys, sharedName); + } + + private void UntrackPreferenceKey(string key, string? sharedName) + { + var keys = GetKnownPreferenceKeys(sharedName); + if (keys.Remove(key)) + SaveKnownPreferenceKeys(keys, sharedName); + } + + private Task HandlePreferencesList(HttpRequest request) + { + try + { + request.QueryParams.TryGetValue("sharedName", out var sharedName); + var keys = GetKnownPreferenceKeys(sharedName); + var entries = new List(); + foreach (var key in keys.OrderBy(k => k)) + { + var value = sharedName != null + ? Preferences.Get(key, (string?)null, sharedName) + : Preferences.Get(key, (string?)null); + entries.Add(new { key, value, sharedName }); + } + return Task.FromResult(HttpResponse.Json(new { keys = entries })); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to list preferences: {ex.Message}")); + } + } + + private Task HandlePreferencesGet(HttpRequest request) + { + try + { + if (!request.RouteParams.TryGetValue("key", out var key)) + return Task.FromResult(HttpResponse.Error("key is required")); + + request.QueryParams.TryGetValue("sharedName", out var sharedName); + var type = request.QueryParams.GetValueOrDefault("type", "string"); + + object? value = type.ToLowerInvariant() switch + { + "int" or "integer" => sharedName != null ? Preferences.Get(key, 0, sharedName) : Preferences.Get(key, 0), + "bool" or "boolean" => sharedName != null ? Preferences.Get(key, false, sharedName) : Preferences.Get(key, false), + "double" => sharedName != null ? Preferences.Get(key, 0.0, sharedName) : Preferences.Get(key, 0.0), + "float" => sharedName != null ? Preferences.Get(key, 0f, sharedName) : Preferences.Get(key, 0f), + "long" => sharedName != null ? Preferences.Get(key, 0L, sharedName) : Preferences.Get(key, 0L), + "datetime" => sharedName != null ? Preferences.Get(key, DateTime.MinValue, sharedName) : Preferences.Get(key, DateTime.MinValue), + _ => sharedName != null ? Preferences.Get(key, (string?)null, sharedName) : Preferences.Get(key, (string?)null), + }; + + var exists = sharedName != null ? Preferences.ContainsKey(key, sharedName) : Preferences.ContainsKey(key); + return Task.FromResult(HttpResponse.Json(new { key, value, type, exists, sharedName })); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to get preference: {ex.Message}")); + } + } + + private Task HandlePreferencesSet(HttpRequest request) + { + try + { + if (!request.RouteParams.TryGetValue("key", out var key)) + return Task.FromResult(HttpResponse.Error("key is required")); + + var body = request.BodyAs(); + if (body == null) + return Task.FromResult(HttpResponse.Error("Request body is required")); + + var type = body.Type ?? "string"; + var sharedName = body.SharedName; + + // STJ deserializes object? properties as JsonElement — extract the raw string for parsing + var rawValue = body.Value is JsonElement je ? je.ToString() : body.Value?.ToString() ?? ""; + + switch (type.ToLowerInvariant()) + { + case "int" or "integer": + var intVal = int.Parse(rawValue); + if (sharedName != null) Preferences.Set(key, intVal, sharedName); + else Preferences.Set(key, intVal); + break; + case "bool" or "boolean": + var boolVal = bool.Parse(rawValue); + if (sharedName != null) Preferences.Set(key, boolVal, sharedName); + else Preferences.Set(key, boolVal); + break; + case "double": + var doubleVal = double.Parse(rawValue); + if (sharedName != null) Preferences.Set(key, doubleVal, sharedName); + else Preferences.Set(key, doubleVal); + break; + case "float": + var floatVal = float.Parse(rawValue); + if (sharedName != null) Preferences.Set(key, floatVal, sharedName); + else Preferences.Set(key, floatVal); + break; + case "long": + var longVal = long.Parse(rawValue); + if (sharedName != null) Preferences.Set(key, longVal, sharedName); + else Preferences.Set(key, longVal); + break; + case "datetime": + var dtVal = DateTime.Parse(rawValue); + if (sharedName != null) Preferences.Set(key, dtVal, sharedName); + else Preferences.Set(key, dtVal); + break; + default: + var strVal = rawValue; + if (sharedName != null) Preferences.Set(key, strVal, sharedName); + else Preferences.Set(key, strVal); + break; + } + + TrackPreferenceKey(key, sharedName); + return Task.FromResult(HttpResponse.Json(new { key, value = body.Value, type, sharedName })); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to set preference: {ex.Message}")); + } + } + + private Task HandlePreferencesDelete(HttpRequest request) + { + try + { + if (!request.RouteParams.TryGetValue("key", out var key)) + return Task.FromResult(HttpResponse.Error("key is required")); + + request.QueryParams.TryGetValue("sharedName", out var sharedName); + + if (sharedName != null) + Preferences.Remove(key, sharedName); + else + Preferences.Remove(key); + + UntrackPreferenceKey(key, sharedName); + return Task.FromResult(HttpResponse.Ok($"Preference '{key}' removed")); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to remove preference: {ex.Message}")); + } + } + + private Task HandlePreferencesClear(HttpRequest request) + { + try + { + request.QueryParams.TryGetValue("sharedName", out var sharedName); + + if (sharedName != null) + Preferences.Clear(sharedName); + else + Preferences.Clear(); + + // Clear the key registry too + SaveKnownPreferenceKeys(new HashSet(), sharedName); + return Task.FromResult(HttpResponse.Ok("All preferences cleared")); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to clear preferences: {ex.Message}")); + } + } + + // ── Secure Storage endpoints ── + + private async Task HandleSecureStorageGet(HttpRequest request) + { + try + { + if (!request.RouteParams.TryGetValue("key", out var key)) + return HttpResponse.Error("key is required"); + + var value = await SecureStorage.GetAsync(key); + return HttpResponse.Json(new { key, value, exists = value != null }); + } + catch (Exception ex) + { + return HttpResponse.Error($"Failed to get secure storage value: {ex.Message}"); + } + } + + private async Task HandleSecureStorageSet(HttpRequest request) + { + try + { + if (!request.RouteParams.TryGetValue("key", out var key)) + return HttpResponse.Error("key is required"); + + var body = request.BodyAs(); + if (body?.Value == null) + return HttpResponse.Error("value is required"); + + await SecureStorage.SetAsync(key, body.Value); + return HttpResponse.Json(new { key, value = body.Value }); + } + catch (Exception ex) + { + return HttpResponse.Error($"Failed to set secure storage value: {ex.Message}"); + } + } + + private Task HandleSecureStorageDelete(HttpRequest request) + { + try + { + if (!request.RouteParams.TryGetValue("key", out var key)) + return Task.FromResult(HttpResponse.Error("key is required")); + + var removed = SecureStorage.Remove(key); + return Task.FromResult(HttpResponse.Json(new { key, removed })); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to remove secure storage value: {ex.Message}")); + } + } + + private Task HandleSecureStorageClear(HttpRequest request) + { + try + { + SecureStorage.RemoveAll(); + return Task.FromResult(HttpResponse.Ok("All secure storage entries cleared")); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to clear secure storage: {ex.Message}")); + } + } + + // ── Platform info endpoints ── + + private Task HandlePlatformAppInfo(HttpRequest request) + { + try + { + var info = AppInfo.Current; + return Task.FromResult(HttpResponse.Json(new + { + name = info.Name, + packageName = info.PackageName, + version = info.VersionString, + buildNumber = info.BuildString, + requestedTheme = info.RequestedTheme.ToString(), + requestedLayoutDirection = info.RequestedLayoutDirection.ToString(), + })); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to get app info: {ex.Message}")); + } + } + + private Task HandlePlatformDeviceInfo(HttpRequest request) + { + try + { + var info = DeviceInfo.Current; + return Task.FromResult(HttpResponse.Json(new + { + manufacturer = info.Manufacturer, + model = info.Model, + name = info.Name, + platform = info.Platform.ToString(), + idiom = info.Idiom.ToString(), + deviceType = info.DeviceType.ToString(), + osVersion = info.VersionString, + })); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to get device info: {ex.Message}")); + } + } + + private Task HandlePlatformDeviceDisplay(HttpRequest request) + { + try + { + var display = DeviceDisplay.MainDisplayInfo; + return Task.FromResult(HttpResponse.Json(new + { + width = display.Width, + height = display.Height, + density = display.Density, + orientation = display.Orientation.ToString(), + rotation = display.Rotation.ToString(), + refreshRate = display.RefreshRate, + })); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to get display info: {ex.Message}")); + } + } + + private Task HandlePlatformBattery(HttpRequest request) + { + try + { + var battery = Battery.Default; + return Task.FromResult(HttpResponse.Json(new + { + chargeLevel = battery.ChargeLevel, + state = battery.State.ToString(), + powerSource = battery.PowerSource.ToString(), + energySaverStatus = battery.EnergySaverStatus.ToString(), + })); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to get battery info: {ex.Message}")); + } + } + + private Task HandlePlatformConnectivity(HttpRequest request) + { + try + { + var connectivity = Connectivity.Current; + return Task.FromResult(HttpResponse.Json(new + { + networkAccess = connectivity.NetworkAccess.ToString(), + connectionProfiles = connectivity.ConnectionProfiles.Select(p => p.ToString()).ToList(), + })); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to get connectivity info: {ex.Message}")); + } + } + + private Task HandlePlatformVersionTracking(HttpRequest request) + { + try + { + var vt = VersionTracking.Default; + return Task.FromResult(HttpResponse.Json(new + { + currentVersion = vt.CurrentVersion, + currentBuild = vt.CurrentBuild, + previousVersion = vt.PreviousVersion, + previousBuild = vt.PreviousBuild, + firstInstalledVersion = vt.FirstInstalledVersion, + firstInstalledBuild = vt.FirstInstalledBuild, + isFirstLaunchEver = vt.IsFirstLaunchEver, + isFirstLaunchForCurrentVersion = vt.IsFirstLaunchForCurrentVersion, + isFirstLaunchForCurrentBuild = vt.IsFirstLaunchForCurrentBuild, + versionHistory = vt.VersionHistory.ToList(), + buildHistory = vt.BuildHistory.ToList(), + })); + } + catch (Exception ex) + { + return Task.FromResult(HttpResponse.Error($"Failed to get version tracking info: {ex.Message}")); + } + } + + private static readonly Dictionary> KnownPermissions = new(StringComparer.OrdinalIgnoreCase) + { + ["camera"] = () => new Permissions.Camera(), + ["locationWhenInUse"] = () => new Permissions.LocationWhenInUse(), + ["locationAlways"] = () => new Permissions.LocationAlways(), + ["microphone"] = () => new Permissions.Microphone(), + ["photos"] = () => new Permissions.Photos(), + ["sensors"] = () => new Permissions.Sensors(), + ["speech"] = () => new Permissions.Speech(), + ["storageRead"] = () => new Permissions.StorageRead(), + ["storageWrite"] = () => new Permissions.StorageWrite(), + ["calendar"] = () => new Permissions.CalendarRead(), + ["calendarRead"] = () => new Permissions.CalendarRead(), + ["calendarWrite"] = () => new Permissions.CalendarWrite(), + ["contacts"] = () => new Permissions.ContactsRead(), + ["contactsRead"] = () => new Permissions.ContactsRead(), + ["contactsWrite"] = () => new Permissions.ContactsWrite(), + ["flashlight"] = () => new Permissions.Flashlight(), + ["networkState"] = () => new Permissions.NetworkState(), + ["battery"] = () => new Permissions.Battery(), + ["vibrate"] = () => new Permissions.Vibrate(), + }; + + private async Task HandlePlatformPermissions(HttpRequest request) + { + try + { + var results = new List(); + foreach (var (name, factory) in KnownPermissions) + { + try + { + var perm = factory(); + var status = await perm.CheckStatusAsync(); + results.Add(new { permission = name, status = status.ToString() }); + } + catch + { + results.Add(new { permission = name, status = "unavailable" }); + } + } + return HttpResponse.Json(new { permissions = results }); + } + catch (Exception ex) + { + return HttpResponse.Error($"Failed to check permissions: {ex.Message}"); + } + } + + private async Task HandlePlatformPermissionCheck(HttpRequest request) + { + try + { + if (!request.RouteParams.TryGetValue("permission", out var permName)) + return HttpResponse.Error("permission name is required"); + + if (!KnownPermissions.TryGetValue(permName, out var factory)) + return HttpResponse.Error($"Unknown permission: {permName}. Valid: {string.Join(", ", KnownPermissions.Keys)}"); + + var perm = factory(); + var status = await perm.CheckStatusAsync(); + return HttpResponse.Json(new { permission = permName, status = status.ToString() }); + } + catch (Exception ex) + { + return HttpResponse.Error($"Failed to check permission: {ex.Message}"); + } + } + + private async Task HandlePlatformGeolocation(HttpRequest request) + { + try + { + var accuracyStr = request.QueryParams.GetValueOrDefault("accuracy", "Medium"); + var accuracy = accuracyStr.ToLowerInvariant() switch + { + "lowest" => GeolocationAccuracy.Lowest, + "low" => GeolocationAccuracy.Low, + "high" => GeolocationAccuracy.High, + "best" => GeolocationAccuracy.Best, + _ => GeolocationAccuracy.Medium, + }; + + var timeoutStr = request.QueryParams.GetValueOrDefault("timeout", "10"); + if (!int.TryParse(timeoutStr, out var timeoutSec)) timeoutSec = 10; + + var location = await Geolocation.GetLocationAsync(new GeolocationRequest(accuracy, TimeSpan.FromSeconds(timeoutSec))); + + if (location == null) + return HttpResponse.Error("Could not determine location"); + + return HttpResponse.Json(new + { + latitude = location.Latitude, + longitude = location.Longitude, + altitude = location.Altitude, + accuracy = location.Accuracy, + speed = location.Speed, + course = location.Course, + timestamp = location.Timestamp, + isFromMockProvider = location.IsFromMockProvider, + }); + } + catch (PermissionException) + { + return HttpResponse.Error("Location permission not granted", 403); + } + catch (FeatureNotEnabledException) + { + return HttpResponse.Error("Location services not enabled on device"); + } + catch (Exception ex) + { + return HttpResponse.Error($"Failed to get location: {ex.Message}"); + } + } + + // ── Sensor endpoints ── + + private Task HandleSensorsList(HttpRequest request) + { + return Task.FromResult(HttpResponse.Json(Sensors.GetStatus())); + } + + private Task HandleSensorStart(HttpRequest request) + { + if (!request.RouteParams.TryGetValue("sensor", out var sensorName)) + return Task.FromResult(HttpResponse.Error("sensor name is required")); + + var speedStr = request.QueryParams.GetValueOrDefault("speed", "UI"); + var speed = SensorManager.ParseSpeed(speedStr); + + var error = Sensors.Start(sensorName, speed); + return Task.FromResult(error != null + ? HttpResponse.Error(error) + : HttpResponse.Ok($"Sensor '{sensorName}' started with speed {speed}")); + } + + private Task HandleSensorStop(HttpRequest request) + { + if (!request.RouteParams.TryGetValue("sensor", out var sensorName)) + return Task.FromResult(HttpResponse.Error("sensor name is required")); + + var error = Sensors.Stop(sensorName); + return Task.FromResult(error != null + ? HttpResponse.Error(error) + : HttpResponse.Ok($"Sensor '{sensorName}' stopped")); + } + + private async Task HandleSensorWebSocket( + System.Net.Sockets.TcpClient client, + System.Net.Sockets.NetworkStream stream, + HttpRequest request, + CancellationToken ct) + { + // Parse sensor name from query param since WS routes don't support path params + var sensorName = request.QueryParams.GetValueOrDefault("sensor"); + if (string.IsNullOrEmpty(sensorName)) + { + await AgentHttpServer.WebSocketSendTextAsync(stream, + JsonSerializer.Serialize(new { error = "sensor query parameter is required (e.g., ?sensor=accelerometer)" }), ct); + return; + } + + sensorName = sensorName.ToLowerInvariant(); + + // Auto-start the sensor if not already running + var speedStr = request.QueryParams.GetValueOrDefault("speed", "UI"); + var speed = SensorManager.ParseSpeed(speedStr); + + // Allow clients to override throttle interval (default 100ms) + if (request.QueryParams.TryGetValue("throttleMs", out var throttleStr) && + int.TryParse(throttleStr, out var throttleMs) && throttleMs >= 0) + { + Sensors.ThrottleMs = throttleMs; + } + + var startError = Sensors.Start(sensorName, speed); + if (startError != null) + { + await AgentHttpServer.WebSocketSendTextAsync(stream, + JsonSerializer.Serialize(new { error = startError }), ct); + return; + } + + // Subscribe to sensor readings + var queue = Sensors.Subscribe(sensorName); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + + try + { + // Confirm subscription + await AgentHttpServer.WebSocketSendTextAsync(stream, + JsonSerializer.Serialize(new { type = "subscribed", sensor = sensorName, speed = speed.ToString(), throttleMs = Sensors.ThrottleMs }), ct); + + // Read loop (detects disconnection) + var readTask = Task.Run(async () => + { + while (!cts.Token.IsCancellationRequested) + { + var msg = await AgentHttpServer.WebSocketReadTextAsync(stream, cts.Token); + if (msg == null) { await cts.CancelAsync(); break; } + } + }, cts.Token); + + // Send loop — drain queue and send pings + var lastPing = DateTime.UtcNow; + while (!cts.Token.IsCancellationRequested) + { + while (queue.TryDequeue(out var reading)) + { + try + { + await AgentHttpServer.WebSocketSendTextAsync(stream, reading, cts.Token); + } + catch { await cts.CancelAsync(); break; } + } + + if ((DateTime.UtcNow - lastPing).TotalSeconds >= 15) + { + try + { + await AgentHttpServer.WebSocketSendPingAsync(stream, cts.Token); + lastPing = DateTime.UtcNow; + } + catch { await cts.CancelAsync(); break; } + } + + try { await Task.Delay(20, cts.Token); } + catch { break; } + } + + await readTask; + } + finally + { + Sensors.Unsubscribe(sensorName, queue); + } + } } // Request DTOs @@ -3358,3 +4037,15 @@ public class ScrollRequest public int? GroupIndex { get; set; } public string? ScrollToPosition { get; set; } } + +public class PreferenceSetRequest +{ + public object? Value { get; set; } + public string? Type { get; set; } + public string? SharedName { get; set; } +} + +public class SecureStorageSetRequest +{ + public string? Value { get; set; } +} diff --git a/src/MauiDevFlow.Agent.Core/SensorManager.cs b/src/MauiDevFlow.Agent.Core/SensorManager.cs new file mode 100644 index 0000000..d402e37 --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/SensorManager.cs @@ -0,0 +1,244 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Maui.Devices.Sensors; + +namespace MauiDevFlow.Agent.Core; + +/// +/// Manages MAUI sensor subscriptions and broadcasts readings to connected WebSocket clients. +/// +public class SensorManager : IDisposable +{ + private readonly HashSet _activeSensors = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary>> _subscribers = new(); + private readonly ConcurrentDictionary _lastBroadcast = new(); + private readonly object _gate = new(); + private bool _disposed; + + /// + /// Minimum interval between broadcasts per sensor. Readings arriving faster are dropped. + /// Default 100ms (~10 readings/sec). Configurable via Start() or the throttleMs query param. + /// + public int ThrottleMs { get; set; } = 100; + + private static readonly string[] AllSensorNames = + ["accelerometer", "barometer", "compass", "gyroscope", "magnetometer", "orientation"]; + + public IReadOnlyCollection SupportedSensors => AllSensorNames; + + public object GetStatus() + { + lock (_gate) + { + return AllSensorNames.Select(name => new + { + sensor = name, + active = _activeSensors.Contains(name), + supported = IsSensorSupported(name), + subscribers = _subscribers.TryGetValue(name, out var subs) ? subs.Count : 0 + }).ToList(); + } + } + + public bool IsActive(string sensorName) + { + lock (_gate) return _activeSensors.Contains(sensorName); + } + + public string? Start(string sensorName, SensorSpeed speed = SensorSpeed.UI) + { + sensorName = sensorName.ToLowerInvariant(); + lock (_gate) + { + if (_activeSensors.Contains(sensorName)) + return null; // already running + + try + { + switch (sensorName) + { + case "accelerometer": + if (!Accelerometer.IsSupported) return "Accelerometer not supported on this device"; + Accelerometer.ReadingChanged += OnAccelerometerReading; + Accelerometer.Start(speed); + break; + case "barometer": + if (!Barometer.IsSupported) return "Barometer not supported on this device"; + Barometer.ReadingChanged += OnBarometerReading; + Barometer.Start(speed); + break; + case "compass": + if (!Compass.IsSupported) return "Compass not supported on this device"; + Compass.ReadingChanged += OnCompassReading; + Compass.Start(speed); + break; + case "gyroscope": + if (!Gyroscope.IsSupported) return "Gyroscope not supported on this device"; + Gyroscope.ReadingChanged += OnGyroscopeReading; + Gyroscope.Start(speed); + break; + case "magnetometer": + if (!Magnetometer.IsSupported) return "Magnetometer not supported on this device"; + Magnetometer.ReadingChanged += OnMagnetometerReading; + Magnetometer.Start(speed); + break; + case "orientation": + if (!OrientationSensor.IsSupported) return "Orientation sensor not supported on this device"; + OrientationSensor.ReadingChanged += OnOrientationReading; + OrientationSensor.Start(speed); + break; + default: + return $"Unknown sensor: {sensorName}. Valid: {string.Join(", ", AllSensorNames)}"; + } + _activeSensors.Add(sensorName); + return null; // success + } + catch (Exception ex) + { + return $"Failed to start {sensorName}: {ex.Message}"; + } + } + } + + public string? Stop(string sensorName) + { + sensorName = sensorName.ToLowerInvariant(); + lock (_gate) + { + if (!_activeSensors.Contains(sensorName)) + return null; // already stopped + + try + { + switch (sensorName) + { + case "accelerometer": + Accelerometer.Stop(); + Accelerometer.ReadingChanged -= OnAccelerometerReading; + break; + case "barometer": + Barometer.Stop(); + Barometer.ReadingChanged -= OnBarometerReading; + break; + case "compass": + Compass.Stop(); + Compass.ReadingChanged -= OnCompassReading; + break; + case "gyroscope": + Gyroscope.Stop(); + Gyroscope.ReadingChanged -= OnGyroscopeReading; + break; + case "magnetometer": + Magnetometer.Stop(); + Magnetometer.ReadingChanged -= OnMagnetometerReading; + break; + case "orientation": + OrientationSensor.Stop(); + OrientationSensor.ReadingChanged -= OnOrientationReading; + break; + } + _activeSensors.Remove(sensorName); + return null; + } + catch (Exception ex) + { + return $"Failed to stop {sensorName}: {ex.Message}"; + } + } + } + + /// + /// Subscribe a WebSocket client's queue to a sensor's readings. + /// Returns the queue that will receive serialized JSON readings. + /// + public ConcurrentQueue Subscribe(string sensorName) + { + sensorName = sensorName.ToLowerInvariant(); + var queue = new ConcurrentQueue(); + var subs = _subscribers.GetOrAdd(sensorName, _ => new List>()); + lock (subs) { subs.Add(queue); } + return queue; + } + + public void Unsubscribe(string sensorName, ConcurrentQueue queue) + { + sensorName = sensorName.ToLowerInvariant(); + if (_subscribers.TryGetValue(sensorName, out var subs)) + { + lock (subs) { subs.Remove(queue); } + } + } + + private void Broadcast(string sensorName, object data) + { + // Throttle: drop readings that arrive faster than ThrottleMs + var now = DateTime.UtcNow; + var last = _lastBroadcast.GetOrAdd(sensorName, DateTime.MinValue); + if ((now - last).TotalMilliseconds < ThrottleMs) + return; + _lastBroadcast[sensorName] = now; + + var json = JsonSerializer.Serialize(new + { + sensor = sensorName, + timestamp = now.ToString("O"), + data + }); + + if (_subscribers.TryGetValue(sensorName, out var subs)) + { + List> snapshot; + lock (subs) { snapshot = new List>(subs); } + foreach (var q in snapshot) + q.Enqueue(json); + } + } + + private static bool IsSensorSupported(string name) => name.ToLowerInvariant() switch + { + "accelerometer" => Accelerometer.IsSupported, + "barometer" => Barometer.IsSupported, + "compass" => Compass.IsSupported, + "gyroscope" => Gyroscope.IsSupported, + "magnetometer" => Magnetometer.IsSupported, + "orientation" => OrientationSensor.IsSupported, + _ => false + }; + + public static SensorSpeed ParseSpeed(string? speed) => speed?.ToLowerInvariant() switch + { + "game" => SensorSpeed.Game, + "fastest" => SensorSpeed.Fastest, + "default" => SensorSpeed.Default, + _ => SensorSpeed.UI + }; + + // ── Sensor event handlers ── + + private void OnAccelerometerReading(object? sender, AccelerometerChangedEventArgs e) + => Broadcast("accelerometer", new { e.Reading.Acceleration.X, e.Reading.Acceleration.Y, e.Reading.Acceleration.Z }); + + private void OnBarometerReading(object? sender, BarometerChangedEventArgs e) + => Broadcast("barometer", new { pressureInHectopascals = e.Reading.PressureInHectopascals }); + + private void OnCompassReading(object? sender, CompassChangedEventArgs e) + => Broadcast("compass", new { headingMagneticNorth = e.Reading.HeadingMagneticNorth }); + + private void OnGyroscopeReading(object? sender, GyroscopeChangedEventArgs e) + => Broadcast("gyroscope", new { e.Reading.AngularVelocity.X, e.Reading.AngularVelocity.Y, e.Reading.AngularVelocity.Z }); + + private void OnMagnetometerReading(object? sender, MagnetometerChangedEventArgs e) + => Broadcast("magnetometer", new { e.Reading.MagneticField.X, e.Reading.MagneticField.Y, e.Reading.MagneticField.Z }); + + private void OnOrientationReading(object? sender, OrientationSensorChangedEventArgs e) + => Broadcast("orientation", new { e.Reading.Orientation.X, e.Reading.Orientation.Y, e.Reading.Orientation.Z, e.Reading.Orientation.W }); + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + foreach (var name in _activeSensors.ToList()) + Stop(name); + } +} diff --git a/src/MauiDevFlow.CLI/Program.cs b/src/MauiDevFlow.CLI/Program.cs index 1e6e046..dc70b89 100644 --- a/src/MauiDevFlow.CLI/Program.cs +++ b/src/MauiDevFlow.CLI/Program.cs @@ -564,6 +564,214 @@ await MauiNetworkDetailAsync(host, port, OutputWriter.ResolveJsonMode(json, noJs mauiCommand.Add(networkCommand); + // ===== MAUI preferences subcommands ===== + var prefsCommand = new Command("preferences", "Manage app preferences (key-value store)"); + + var prefsSharedNameOption = new Option("--sharedName", "Shared preferences container name"); + + var prefsListCmd = new Command("list", "List all known preference keys") { prefsSharedNameOption }; + prefsListCmd.SetHandler(async (host, port, json, noJson, sharedName) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + var qs = sharedName != null ? $"?sharedName={Uri.EscapeDataString(sharedName)}" : ""; + await SimpleGetAsync(host, port, $"/api/preferences{qs}", isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, prefsSharedNameOption); + prefsCommand.Add(prefsListCmd); + + var prefsGetKeyArg = new Argument("key", "Preference key"); + var prefsGetTypeOption = new Option("--type", () => "string", "Value type (string|int|bool|double|float|long|datetime)"); + var prefsGetCmd = new Command("get", "Get a preference value") { prefsGetKeyArg, prefsGetTypeOption, prefsSharedNameOption }; + prefsGetCmd.SetHandler(async (host, port, json, noJson, key, type, sharedName) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + var qs = $"?type={Uri.EscapeDataString(type)}"; + if (sharedName != null) qs += $"&sharedName={Uri.EscapeDataString(sharedName)}"; + await SimpleGetAsync(host, port, $"/api/preferences/{Uri.EscapeDataString(key)}{qs}", isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, prefsGetKeyArg, prefsGetTypeOption, prefsSharedNameOption); + prefsCommand.Add(prefsGetCmd); + + var prefsSetKeyArg = new Argument("key", "Preference key"); + var prefsSetValueArg = new Argument("value", "Value to set"); + var prefsSetTypeOption = new Option("--type", () => "string", "Value type (string|int|bool|double|float|long|datetime)"); + var prefsSetSharedNameOption = new Option("--sharedName", "Shared preferences container name"); + var prefsSetCmd = new Command("set", "Set a preference value") { prefsSetKeyArg, prefsSetValueArg, prefsSetTypeOption, prefsSetSharedNameOption }; + prefsSetCmd.SetHandler(async (host, port, json, noJson, key, value, type, sharedName) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + var body = new { value, type, sharedName }; + await SimplePostAsync(host, port, $"/api/preferences/{Uri.EscapeDataString(key)}", body, isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, prefsSetKeyArg, prefsSetValueArg, prefsSetTypeOption, prefsSetSharedNameOption); + prefsCommand.Add(prefsSetCmd); + + var prefsDeleteKeyArg = new Argument("key", "Preference key to remove"); + var prefsDeleteSharedNameOption = new Option("--sharedName", "Shared preferences container name"); + var prefsDeleteCmd = new Command("delete", "Remove a preference") { prefsDeleteKeyArg, prefsDeleteSharedNameOption }; + prefsDeleteCmd.SetHandler(async (host, port, json, noJson, key, sharedName) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + var qs = sharedName != null ? $"?sharedName={Uri.EscapeDataString(sharedName)}" : ""; + await SimpleDeleteAsync(host, port, $"/api/preferences/{Uri.EscapeDataString(key)}{qs}", isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, prefsDeleteKeyArg, prefsDeleteSharedNameOption); + prefsCommand.Add(prefsDeleteCmd); + + var prefsClearSharedNameOption = new Option("--sharedName", "Shared preferences container name"); + var prefsClearCmd = new Command("clear", "Clear all preferences") { prefsClearSharedNameOption }; + prefsClearCmd.SetHandler(async (host, port, json, noJson, sharedName) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + var qs = sharedName != null ? $"?sharedName={Uri.EscapeDataString(sharedName)}" : ""; + await SimplePostAsync(host, port, $"/api/preferences/clear{qs}", null, isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, prefsClearSharedNameOption); + prefsCommand.Add(prefsClearCmd); + + mauiCommand.Add(prefsCommand); + + // ===== MAUI secure-storage subcommands ===== + var secureCommand = new Command("secure-storage", "Manage secure storage (encrypted key-value store)"); + + var secureGetKeyArg = new Argument("key", "Secure storage key"); + var secureGetCmd = new Command("get", "Get a secure storage value") { secureGetKeyArg }; + secureGetCmd.SetHandler(async (host, port, json, noJson, key) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + await SimpleGetAsync(host, port, $"/api/secure-storage/{Uri.EscapeDataString(key)}", isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, secureGetKeyArg); + secureCommand.Add(secureGetCmd); + + var secureSetKeyArg = new Argument("key", "Secure storage key"); + var secureSetValueArg = new Argument("value", "Value to store"); + var secureSetCmd = new Command("set", "Set a secure storage value") { secureSetKeyArg, secureSetValueArg }; + secureSetCmd.SetHandler(async (host, port, json, noJson, key, value) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + await SimplePostAsync(host, port, $"/api/secure-storage/{Uri.EscapeDataString(key)}", new { value }, isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, secureSetKeyArg, secureSetValueArg); + secureCommand.Add(secureSetCmd); + + var secureDeleteKeyArg = new Argument("key", "Secure storage key to remove"); + var secureDeleteCmd = new Command("delete", "Remove a secure storage entry") { secureDeleteKeyArg }; + secureDeleteCmd.SetHandler(async (host, port, json, noJson, key) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + await SimpleDeleteAsync(host, port, $"/api/secure-storage/{Uri.EscapeDataString(key)}", isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, secureDeleteKeyArg); + secureCommand.Add(secureDeleteCmd); + + var secureClearCmd = new Command("clear", "Clear all secure storage entries"); + secureClearCmd.SetHandler(async (host, port, json, noJson) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + await SimplePostAsync(host, port, "/api/secure-storage/clear", null, isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption); + secureCommand.Add(secureClearCmd); + + mauiCommand.Add(secureCommand); + + // ===== MAUI platform subcommands (read-only) ===== + var platformCommand = new Command("platform", "Query platform features and device info"); + + var platformAppInfoCmd = new Command("app-info", "Get app name, version, package name, theme"); + platformAppInfoCmd.SetHandler(async (host, port, json, noJson) => + await SimpleGetAsync(host, port, "/api/platform/app-info", OutputWriter.ResolveJsonMode(json, noJson)), + agentHostOption, agentPortOption, jsonOption, noJsonOption); + platformCommand.Add(platformAppInfoCmd); + + var platformDeviceInfoCmd = new Command("device-info", "Get device manufacturer, model, OS version"); + platformDeviceInfoCmd.SetHandler(async (host, port, json, noJson) => + await SimpleGetAsync(host, port, "/api/platform/device-info", OutputWriter.ResolveJsonMode(json, noJson)), + agentHostOption, agentPortOption, jsonOption, noJsonOption); + platformCommand.Add(platformDeviceInfoCmd); + + var platformDisplayCmd = new Command("display", "Get screen density, size, orientation"); + platformDisplayCmd.SetHandler(async (host, port, json, noJson) => + await SimpleGetAsync(host, port, "/api/platform/device-display", OutputWriter.ResolveJsonMode(json, noJson)), + agentHostOption, agentPortOption, jsonOption, noJsonOption); + platformCommand.Add(platformDisplayCmd); + + var platformBatteryCmd = new Command("battery", "Get battery level, state, power source"); + platformBatteryCmd.SetHandler(async (host, port, json, noJson) => + await SimpleGetAsync(host, port, "/api/platform/battery", OutputWriter.ResolveJsonMode(json, noJson)), + agentHostOption, agentPortOption, jsonOption, noJsonOption); + platformCommand.Add(platformBatteryCmd); + + var platformConnectivityCmd = new Command("connectivity", "Get network access and connection profiles"); + platformConnectivityCmd.SetHandler(async (host, port, json, noJson) => + await SimpleGetAsync(host, port, "/api/platform/connectivity", OutputWriter.ResolveJsonMode(json, noJson)), + agentHostOption, agentPortOption, jsonOption, noJsonOption); + platformCommand.Add(platformConnectivityCmd); + + var platformVersionTrackingCmd = new Command("version-tracking", "Get version history and first launch info"); + platformVersionTrackingCmd.SetHandler(async (host, port, json, noJson) => + await SimpleGetAsync(host, port, "/api/platform/version-tracking", OutputWriter.ResolveJsonMode(json, noJson)), + agentHostOption, agentPortOption, jsonOption, noJsonOption); + platformCommand.Add(platformVersionTrackingCmd); + + var platformPermsNameArg = new Argument("permission", () => null, "Permission name (e.g., camera, locationWhenInUse). Omit to check all."); + var platformPermsCmd = new Command("permissions", "Check permission status") { platformPermsNameArg }; + platformPermsCmd.SetHandler(async (host, port, json, noJson, permName) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + var path = permName != null + ? $"/api/platform/permissions/{Uri.EscapeDataString(permName)}" + : "/api/platform/permissions"; + await SimpleGetAsync(host, port, path, isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, platformPermsNameArg); + platformCommand.Add(platformPermsCmd); + + var platformGeoAccuracyOption = new Option("--accuracy", () => "Medium", "Accuracy (Lowest|Low|Medium|High|Best)"); + var platformGeoTimeoutOption = new Option("--timeout", () => 10, "Timeout in seconds"); + var platformGeoCmd = new Command("geolocation", "Get current GPS coordinates") { platformGeoAccuracyOption, platformGeoTimeoutOption }; + platformGeoCmd.SetHandler(async (host, port, json, noJson, accuracy, timeout) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + await SimpleGetAsync(host, port, $"/api/platform/geolocation?accuracy={Uri.EscapeDataString(accuracy)}&timeout={timeout}", isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, platformGeoAccuracyOption, platformGeoTimeoutOption); + platformCommand.Add(platformGeoCmd); + + mauiCommand.Add(platformCommand); + + // ===== MAUI sensors subcommands ===== + var sensorsCommand = new Command("sensors", "Monitor device sensors"); + + var sensorsListCmd = new Command("list", "List available sensors and their status"); + sensorsListCmd.SetHandler(async (host, port, json, noJson) => + await SimpleGetAsync(host, port, "/api/sensors", OutputWriter.ResolveJsonMode(json, noJson)), + agentHostOption, agentPortOption, jsonOption, noJsonOption); + sensorsCommand.Add(sensorsListCmd); + + var sensorsStartSensorArg = new Argument("sensor", "Sensor name (accelerometer, barometer, compass, gyroscope, magnetometer, orientation)"); + var sensorsStartSpeedOption = new Option("--speed", () => "UI", "Sensor speed (UI|Game|Fastest|Default)"); + var sensorsStartCmd = new Command("start", "Start a sensor") { sensorsStartSensorArg, sensorsStartSpeedOption }; + sensorsStartCmd.SetHandler(async (host, port, json, noJson, sensor, speed) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + await SimplePostAsync(host, port, $"/api/sensors/{Uri.EscapeDataString(sensor)}/start?speed={Uri.EscapeDataString(speed)}", null, isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, sensorsStartSensorArg, sensorsStartSpeedOption); + sensorsCommand.Add(sensorsStartCmd); + + var sensorsStopSensorArg = new Argument("sensor", "Sensor name"); + var sensorsStopCmd = new Command("stop", "Stop a sensor") { sensorsStopSensorArg }; + sensorsStopCmd.SetHandler(async (host, port, json, noJson, sensor) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + await SimplePostAsync(host, port, $"/api/sensors/{Uri.EscapeDataString(sensor)}/stop", null, isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, sensorsStopSensorArg); + sensorsCommand.Add(sensorsStopCmd); + + var sensorsStreamSensorArg = new Argument("sensor", "Sensor name to stream"); + var sensorsStreamSpeedOption = new Option("--speed", () => "UI", "Sensor speed (UI|Game|Fastest|Default)"); + var sensorsStreamDurationOption = new Option("--duration", () => 0, "Duration in seconds (0 = indefinite, Ctrl+C to stop)"); + var sensorsStreamThrottleOption = new Option("--throttle", () => 100, "Minimum ms between readings (default 100 = ~10/sec, 0 = no throttle)"); + var sensorsStreamCmd = new Command("stream", "Stream sensor readings via WebSocket") { sensorsStreamSensorArg, sensorsStreamSpeedOption, sensorsStreamDurationOption, sensorsStreamThrottleOption }; + sensorsStreamCmd.SetHandler(async (host, port, json, noJson, sensor, speed, duration, throttle) => + { + var isJson = OutputWriter.ResolveJsonMode(json, noJson); + await SensorStreamAsync(host, port, sensor, speed, duration, throttle, isJson); + }, agentHostOption, agentPortOption, jsonOption, noJsonOption, sensorsStreamSensorArg, sensorsStreamSpeedOption, sensorsStreamDurationOption, sensorsStreamThrottleOption); + sensorsCommand.Add(sensorsStreamCmd); + + mauiCommand.Add(sensorsCommand); + rootCommand.Add(mauiCommand); // ===== update-skill command ===== @@ -1104,6 +1312,125 @@ private static string FormatJson(JsonElement element) return JsonSerializer.Serialize(element, new JsonSerializerOptions { WriteIndented = true }); } + // ===== Generic agent HTTP helpers (for preferences, platform, sensors, etc.) ===== + + private static async Task SimpleGetAsync(string host, int port, string path, bool json) + { + try + { + using var http = new HttpClient(); + http.Timeout = TimeSpan.FromSeconds(30); + var response = await http.GetAsync($"http://{host}:{port}{path}"); + var body = await response.Content.ReadAsStringAsync(); + if (json || !response.IsSuccessStatusCode) + { + Console.WriteLine(body); + } + else + { + try + { + var doc = JsonDocument.Parse(body); + Console.WriteLine(JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true })); + } + catch + { + Console.WriteLine(body); + } + } + if (!response.IsSuccessStatusCode) _errorOccurred = true; + } + catch (Exception ex) + { + OutputWriter.WriteError(ex.Message, json); + _errorOccurred = true; + } + } + + private static async Task SimplePostAsync(string host, int port, string path, object? bodyObj, bool json) + { + try + { + using var http = new HttpClient(); + http.Timeout = TimeSpan.FromSeconds(30); + HttpResponseMessage response; + if (bodyObj != null) + { + var content = new StringContent( + JsonSerializer.Serialize(bodyObj), + Encoding.UTF8, + "application/json"); + response = await http.PostAsync($"http://{host}:{port}{path}", content); + } + else + { + response = await http.PostAsync($"http://{host}:{port}{path}", null); + } + var body = await response.Content.ReadAsStringAsync(); + Console.WriteLine(body); + if (!response.IsSuccessStatusCode) _errorOccurred = true; + } + catch (Exception ex) + { + OutputWriter.WriteError(ex.Message, json); + _errorOccurred = true; + } + } + + private static async Task SimpleDeleteAsync(string host, int port, string path, bool json) + { + try + { + using var http = new HttpClient(); + http.Timeout = TimeSpan.FromSeconds(30); + var response = await http.DeleteAsync($"http://{host}:{port}{path}"); + var body = await response.Content.ReadAsStringAsync(); + Console.WriteLine(body); + if (!response.IsSuccessStatusCode) _errorOccurred = true; + } + catch (Exception ex) + { + OutputWriter.WriteError(ex.Message, json); + _errorOccurred = true; + } + } + + private static async Task SensorStreamAsync(string host, int port, string sensor, string speed, int duration, int throttleMs, bool json) + { + try + { + using var client = new System.Net.WebSockets.ClientWebSocket(); + var uri = new Uri($"ws://{host}:{port}/ws/sensors?sensor={Uri.EscapeDataString(sensor)}&speed={Uri.EscapeDataString(speed)}&throttleMs={throttleMs}"); + using var cts = duration > 0 + ? new CancellationTokenSource(TimeSpan.FromSeconds(duration)) + : new CancellationTokenSource(); + + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + await client.ConnectAsync(uri, cts.Token); + var buffer = new byte[4096]; + + while (!cts.Token.IsCancellationRequested && client.State == System.Net.WebSockets.WebSocketState.Open) + { + var result = await client.ReceiveAsync(buffer, cts.Token); + if (result.MessageType == System.Net.WebSockets.WebSocketMessageType.Close) + break; + + var message = Encoding.UTF8.GetString(buffer, 0, result.Count); + Console.WriteLine(message); + } + } + catch (OperationCanceledException) + { + // Normal exit via Ctrl+C or duration timeout + } + catch (Exception ex) + { + OutputWriter.WriteError(ex.Message, json); + _errorOccurred = true; + } + } + // ===== Element Resolution Helper ===== /// @@ -1272,6 +1599,27 @@ private record CommandDescription(string Command, string Description, bool Mutat new("MAUI network list", "List recent network requests", false), new("MAUI network detail", "Show full network request details", false), new("MAUI network clear", "Clear network request buffer", true), + new("MAUI preferences list", "List all known preference keys", false), + new("MAUI preferences get", "Get a preference value by key", false), + new("MAUI preferences set", "Set a preference value", true), + new("MAUI preferences delete", "Remove a preference", true), + new("MAUI preferences clear", "Clear all preferences", true), + new("MAUI secure-storage get", "Get a secure storage value", false), + new("MAUI secure-storage set", "Set a secure storage value", true), + new("MAUI secure-storage delete", "Remove a secure storage entry", true), + new("MAUI secure-storage clear", "Clear all secure storage", true), + new("MAUI platform app-info", "Get app name, version, theme", false), + new("MAUI platform device-info", "Get device manufacturer, model, OS", false), + new("MAUI platform display", "Get screen density, size, orientation", false), + new("MAUI platform battery", "Get battery level, state, power source", false), + new("MAUI platform connectivity", "Get network access and profiles", false), + new("MAUI platform version-tracking", "Get version history and launch info", false), + new("MAUI platform permissions", "Check permission status", false), + new("MAUI platform geolocation", "Get current GPS coordinates", false), + new("MAUI sensors list", "List available sensors and status", false), + new("MAUI sensors start", "Start a device sensor", true), + new("MAUI sensors stop", "Stop a device sensor", true), + new("MAUI sensors stream", "Stream sensor readings via WebSocket", false), new("cdp webviews", "List available CDP WebViews", false), new("cdp status", "Check CDP connection status", false), new("cdp Browser getVersion", "Get browser version", false),