From 46e1cfee6431d1adf9e6f9f2a4c613cd26662a55 Mon Sep 17 00:00:00 2001 From: redth Date: Sat, 7 Feb 2026 13:35:21 -0500 Subject: [PATCH 1/3] Add DevTunnel, QR, and MauiDevFlow support Add MAUI debugging docs and integrate remote sharing, QR scanning, and devflow tooling. Adds new .claude/maui-ai-debugging skill docs and platform FolderPicker helpers, QR scanner page/service, DevTunnelService, WebSocket bridge (client/server), and bridge message models. Update project refs to include QRCoder, ZXing, and Redth.MauiDevFlow packages. UI changes: Settings and Home pages now expose DevTunnel controls, QR generation, mode selection via PlatformHelper, and auto-start tunnel behavior; SessionSidebar shows session creation errors. Misc: CopilotService, ServerManager and related services updated, AndroidManifest and iOS Info.plist touched, and CSS/layout tweaks for nav spacing. --- .claude/skills/maui-ai-debugging/SKILL.md | 257 ++++++++++ .../maui-ai-debugging/references/android.md | 173 +++++++ .../references/ios-and-mac.md | 177 +++++++ .../maui-ai-debugging/references/setup.md | 197 ++++++++ AutoPilot.App.csproj | 4 + Components/Layout/SessionSidebar.razor | 17 +- Components/Pages/Home.razor | 21 + Components/Pages/Home.razor.css | 6 +- Components/Pages/Settings.razor | 305 +++++++++++- Components/Pages/Settings.razor.css | 188 +++++++- MainPage.xaml.cs | 74 +++ MauiProgram.cs | 21 +- Models/BridgeMessages.cs | 216 +++++++++ Models/ChatMessage.cs | 3 + Models/ConnectionSettings.cs | 24 +- Models/PlatformHelper.cs | 26 + Platforms/Android/AndroidManifest.xml | 1 + Platforms/Android/FolderPickerService.cs | 10 + Platforms/Android/MainActivity.cs | 14 + Platforms/Windows/FolderPickerService.cs | 10 + Platforms/iOS/FolderPickerService.cs | 53 ++ Platforms/iOS/Info.plist | 2 + QrScannerPage.xaml | 34 ++ QrScannerPage.xaml.cs | 106 ++++ Services/CopilotService.cs | 322 ++++++++++++- Services/DevTunnelService.cs | 453 ++++++++++++++++++ Services/QrScannerService.cs | 40 ++ Services/ServerManager.cs | 11 +- Services/WsBridgeClient.cs | 344 +++++++++++++ Services/WsBridgeServer.cs | 429 +++++++++++++++++ wwwroot/app.css | 16 + 31 files changed, 3502 insertions(+), 52 deletions(-) create mode 100644 .claude/skills/maui-ai-debugging/SKILL.md create mode 100644 .claude/skills/maui-ai-debugging/references/android.md create mode 100644 .claude/skills/maui-ai-debugging/references/ios-and-mac.md create mode 100644 .claude/skills/maui-ai-debugging/references/setup.md create mode 100644 Models/BridgeMessages.cs create mode 100644 Models/PlatformHelper.cs create mode 100644 Platforms/Android/FolderPickerService.cs create mode 100644 Platforms/Windows/FolderPickerService.cs create mode 100644 Platforms/iOS/FolderPickerService.cs create mode 100644 QrScannerPage.xaml create mode 100644 QrScannerPage.xaml.cs create mode 100644 Services/DevTunnelService.cs create mode 100644 Services/QrScannerService.cs create mode 100644 Services/WsBridgeClient.cs create mode 100644 Services/WsBridgeServer.cs diff --git a/.claude/skills/maui-ai-debugging/SKILL.md b/.claude/skills/maui-ai-debugging/SKILL.md new file mode 100644 index 0000000000..0bbd7f56d2 --- /dev/null +++ b/.claude/skills/maui-ai-debugging/SKILL.md @@ -0,0 +1,257 @@ +--- +name: maui-ai-debugging +description: > + End-to-end workflow for building, deploying, inspecting, and debugging .NET MAUI and MAUI Blazor Hybrid apps + as an AI agent. Use when: (1) Building or running a MAUI app on iOS simulator, Android emulator, or Mac Catalyst, + (2) Deploying a MAUI app to a device/emulator/simulator, (3) Inspecting or interacting with a running MAUI app's + UI (visual tree, element tapping, filling text, screenshots, property queries), (4) Debugging Blazor WebView + content inside a MAUI app via CDP, (5) Managing iOS simulators or Android emulators (create, boot, list, install), + (6) Setting up the MauiDevFlow agent and CLI in a MAUI project, (7) Completing a build-deploy-inspect-fix feedback + loop for MAUI app development. Covers: maui-devflow CLI, androidsdk.tool (android), appledev.tools (apple), + adb, xcrun simctl, and dotnet build/run for all MAUI target platforms. +--- + +# MAUI AI Debugging + +Build, deploy, inspect, and debug .NET MAUI apps from the terminal. This skill enables a complete +feedback loop: **build → deploy → inspect → fix → rebuild**. + +## Prerequisites + +Install the CLI tool: `dotnet tool install --global Redth.MauiDevFlow.CLI` + +For platform-specific tools: `dotnet tool install --global androidsdk.tool` (Android) +and `dotnet tool install --global appledev.tools` (iOS/Mac). + +## Integrating MauiDevFlow into a MAUI App + +For complete setup instructions including NuGet packages, MauiProgram.cs registration, +Blazor script tag, Mac Catalyst entitlements, and Android port forwarding, see +[references/setup.md](references/setup.md). + +**Quick summary:** +1. Add NuGet packages (`Redth.MauiDevFlow.Agent`, and `Redth.MauiDevFlow.Blazor` for Blazor Hybrid) +2. Register in `MauiProgram.cs` inside `#if DEBUG` +3. For Blazor Hybrid: add `` to `wwwroot/index.html` +4. For Mac Catalyst: ensure `network.server` entitlement +5. For Android: run `adb reverse` for port forwarding + +## Core Workflow + +### 1. Ensure a Device/Simulator/Emulator is Running + +**iOS Simulator:** +```bash +xcrun simctl list devices booted # check booted sims +xcrun simctl boot # boot if needed +apple simulator list --booted # alternative +apple simulator boot # alternative +``` + +**Android Emulator:** +```bash +android avd list # list AVDs +android avd start --name # start emulator +adb devices # verify connected +``` + +**Mac Catalyst:** No device setup needed — runs as desktop app. + +### 2. Build and Deploy + +```bash +# iOS Simulator +dotnet build -f net10.0-ios -t:Run -p:_DeviceName=:v2:udid= + +# Android Emulator +dotnet build -f net10.0-android -t:Run + +# Mac Catalyst +dotnet build -f net10.0-maccatalyst -t:Run +``` + +Adjust TFM version (net9.0, net10.0) to match project. Check the `.csproj` ``. +Build + Run can take 30-120+ seconds. Use `initial_wait: 120` or higher for async monitoring. +The `-t:Run` flag keeps the process alive (--wait-for-exit). Run in background or a separate shell. + +For Android emulators, set up port forwarding after deploy: +```bash +adb reverse tcp:9223 tcp:9223 # Agent +adb reverse tcp:9222 tcp:9222 # CDP (Blazor) +``` + +### 3. Verify Connectivity + +```bash +maui-devflow MAUI status # Agent connection (native) +maui-devflow cdp status # CDP connection (Blazor WebView) +``` + +### 4. Inspect and Interact + +See **Command Reference** below for the full command set. + +**Typical inspection flow:** +1. `maui-devflow MAUI tree` — see the full visual tree with element IDs, types, text, bounds +2. `maui-devflow MAUI query --automationId "MyButton"` — find specific elements +3. `maui-devflow MAUI element ` — get full details (type, bounds, visibility, children) +4. `maui-devflow MAUI property Text` — read any property by name +5. `maui-devflow MAUI screenshot --output screen.png` — visual verification + +**Debugging styling/layout with property inspection:** +Use `property` to verify runtime values without relying solely on screenshots: +```bash +maui-devflow MAUI property BackgroundColor # verify dark mode colors +maui-devflow MAUI property TextColor # check text visibility +maui-devflow MAUI property IsVisible # check element visibility +maui-devflow MAUI property Width # verify layout sizing +maui-devflow MAUI property Opacity # check transparency +``` +Combine tree + property for systematic debugging: get element IDs from `tree`, then inspect +specific properties. This is more reliable than screenshots for verifying exact color values, +font sizes, and layout metrics. + +**Typical interaction flow:** +1. `maui-devflow MAUI fill "text"` — type into Entry/Editor fields +2. `maui-devflow MAUI tap ` — tap buttons, checkboxes, list items +3. `maui-devflow MAUI clear ` — clear text fields +4. Take screenshot to verify result + +**Blazor WebView (if applicable):** +1. `maui-devflow cdp snapshot` — DOM tree as accessible text (best for AI) +2. `maui-devflow cdp Input fill "css-selector" "text"` — fill inputs +3. `maui-devflow cdp Input dispatchClickEvent "css-selector"` — click elements +4. `maui-devflow cdp Runtime evaluate "js-expression"` — run JS + +**Debugging Blazor styling via CDP:** +Use `Runtime evaluate` to inspect computed styles and verify CSS: +```bash +maui-devflow cdp Runtime evaluate "getComputedStyle(document.querySelector('.my-class')).backgroundColor" +maui-devflow cdp Runtime evaluate "window.matchMedia('(prefers-color-scheme: dark)').matches" +maui-devflow cdp Runtime evaluate "document.styleSheets.length" +``` +This enables verifying Blazor dark mode, layout, and styling without relying solely on screenshots. + +### 5. Reading Application Logs + +MauiDevFlow automatically captures all `Microsoft.Extensions.Logging` (`ILogger`) output +to rotating log files on the device. This means any `ILogger` calls in the app's code +(or in libraries) are available for remote retrieval — invaluable for debugging. + +```bash +maui-devflow MAUI logs # fetch 100 most recent log entries +maui-devflow MAUI logs --limit 50 # fetch 50 entries +maui-devflow MAUI logs --skip 100 # skip newest 100, get next batch +``` + +Output is color-coded by level (red=Critical/Error, yellow=Warning, green=Info, gray=Debug/Trace). +Each entry includes timestamp, log level, category (logger name), and message. + +**Debugging workflow with logs:** +1. Reproduce the issue (tap a button, navigate, etc.) +2. `maui-devflow MAUI logs --limit 20` — check recent log entries for errors or warnings +3. If needed, add temporary `ILogger` calls to the app code for more detail: + ```csharp + _logger.LogInformation("Button tapped, item count: {Count}", items.Count); + _logger.LogWarning("Unexpected state: {State}", currentState); + ``` +4. Rebuild, redeploy, reproduce, and fetch logs again + +**Log configuration** (in `AddMauiDevFlowAgent` options): +- `EnableFileLogging` (default: `true`) — toggle file logging +- `MaxLogFileSize` (default: 1 MB) — max size per log file before rotation +- `MaxLogFiles` (default: 5) — number of rotated files to keep + +The agent also exposes logs via REST: `GET /api/logs?limit=N&skip=N` returns a JSON array. + +### 6. Fix and Rebuild + +After identifying issues, edit source, rebuild (`dotnet build`), and redeploy. +The full cycle: edit code → `dotnet build -t:Run ...` → `maui-devflow MAUI status` → inspect. + +## Command Reference + +### maui-devflow MAUI (Native Agent) + +Global options: `--agent-host` (default localhost), `--agent-port` (default 9223), `--platform`. + +| Command | Description | +|---------|-------------| +| `MAUI status` | Agent connection status, platform, app name | +| `MAUI tree [--depth N]` | Visual tree (IDs, types, text, bounds). Depth 0=unlimited | +| `MAUI query --type T --automationId A --text T` | Find elements (any/all filters) | +| `MAUI tap ` | Tap an element | +| `MAUI fill ` | Fill text into Entry/Editor | +| `MAUI clear ` | Clear text from element | +| `MAUI screenshot [--output path.png]` | PNG screenshot | +| `MAUI property ` | Read property (Text, IsVisible, FontSize, etc.) | +| `MAUI element ` | Full element JSON (type, bounds, children, etc.) | +| `MAUI navigate ` | Shell navigation (e.g. `//native`, `//blazor`) | +| `MAUI logs [--limit N] [--skip N]` | Fetch application logs (newest first) | + +Element IDs come from `MAUI tree` or `MAUI query`. AutomationId-based elements use their +AutomationId directly. Others use generated hex IDs. When multiple elements share the same +AutomationId, suffixes are appended: `TodoCheckBox`, `TodoCheckBox_1`, `TodoCheckBox_2`, etc. + +### maui-devflow cdp (Blazor WebView CDP) + +Global option: `--endpoint` (default `ws://localhost:9222/devtools/browser`). + +| Command | Description | +|---------|-------------| +| `cdp status` | CDP connection status | +| `cdp snapshot` | Accessible DOM text (best for AI agents) | +| `cdp Browser getVersion` | Browser/WebView version info | +| `cdp Runtime evaluate ` | Evaluate JavaScript | +| `cdp DOM getDocument` | Full DOM document | +| `cdp DOM querySelector ` | Find first matching element | +| `cdp DOM querySelectorAll ` | Find all matching elements | +| `cdp DOM getOuterHTML ` | Get outer HTML of element | +| `cdp Page navigate ` | Navigate to URL | +| `cdp Page reload` | Reload page | +| `cdp Page captureScreenshot` | Screenshot as base64 | +| `cdp Input dispatchClickEvent ` | Click element by CSS selector | +| `cdp Input insertText ` | Insert text at focused element | +| `cdp Input fill ` | Focus + fill text into element | + +### Agent REST API (Direct HTTP) + +The agent exposes JSON endpoints on port 9223 (configurable): + +| Endpoint | Method | Body | +|----------|--------|------| +| `/api/status` | GET | — | +| `/api/tree?depth=N` | GET | — | +| `/api/element/{id}` | GET | — | +| `/api/query?type=&text=&automationId=` | GET | — | +| `/api/action/tap` | POST | `{"elementId":"..."}` | +| `/api/action/fill` | POST | `{"elementId":"...","text":"..."}` | +| `/api/action/clear` | POST | `{"elementId":"..."}` | +| `/api/action/focus` | POST | `{"elementId":"..."}` | +| `/api/screenshot` | GET | — (returns PNG) | +| `/api/property/{id}/{name}` | GET | — | +| `/api/logs?limit=N&skip=N` | GET | — (returns JSON array of log entries) | + +## Platform Details + +For detailed platform-specific setup, simulator/emulator management, and troubleshooting: + +- **Setup & Installation**: See [references/setup.md](references/setup.md) +- **iOS / Mac Catalyst**: See [references/ios-and-mac.md](references/ios-and-mac.md) +- **Android**: See [references/android.md](references/android.md) + +## Tips + +- Use `AutomationId` on important MAUI controls for stable element references. +- The visual tree only reflects what's currently rendered. Off-screen items in CollectionView + may not appear until scrolled into view. +- **Shell navigation**: Use `maui-devflow MAUI navigate "//route"` for Shell-based apps. + Routes are defined in AppShell.xaml via `Route` property on ShellContent elements. +- For Blazor Hybrid, `cdp snapshot` is the most AI-friendly way to read page state. +- Build times: Mac Catalyst ~5-10s, iOS ~30-60s, Android ~30-90s. Set appropriate timeouts. +- After Android deploy, always run `adb reverse` for port forwarding. +- **Property inspection** is more reliable than screenshots for verifying exact runtime values + (colors, sizes, visibility). Use `tree` → `property` workflow for systematic debugging. +- **Application logs** are captured automatically from `ILogger`. Use `MAUI logs` to fetch + them remotely. Add temporary `ILogger` calls for extra debug output, then fetch logs after + reproducing issues. This is often faster than attaching a debugger. diff --git a/.claude/skills/maui-ai-debugging/references/android.md b/.claude/skills/maui-ai-debugging/references/android.md new file mode 100644 index 0000000000..834df531a8 --- /dev/null +++ b/.claude/skills/maui-ai-debugging/references/android.md @@ -0,0 +1,173 @@ +# Android Reference + +## Table of Contents +- [Emulator Management](#emulator-management) +- [Building and Deploying](#building-and-deploying) +- [Android CLI Tool](#android-cli-tool) +- [ADB Reference](#adb-reference) +- [SDK Management](#sdk-management) +- [Troubleshooting](#troubleshooting) + +## Emulator Management + +### List and start AVDs +```bash +android avd list # list available AVDs +android avd start --name # start emulator +``` + +### Create AVD +```bash +# List available targets and device profiles +android avd targets # system images +android avd devices # device profiles (pixel, etc.) + +android avd create --name "Pixel8API35" \ + --sdk "system-images;android-35;google_apis;arm64-v8a" \ + --device pixel_8 +``` + +### Delete AVD +```bash +android avd delete --name +``` + +### Verify emulator is running +```bash +adb devices # should show "emulator-5554 device" +android device list # formatted list +``` + +## Building and Deploying + +```bash +# Build and deploy to running emulator +dotnet build -f net10.0-android -t:Run + +# Build only (no deploy) +dotnet build -f net10.0-android +``` + +**Critical: Port forwarding after deploy** — the Android emulator runs in its own network. +Forward both the Agent and CDP ports: +```bash +adb reverse tcp:9223 tcp:9223 # MauiDevFlow Agent +adb reverse tcp:9222 tcp:9222 # CDP (Blazor WebView) +``` + +Then verify: `maui-devflow MAUI status` and `maui-devflow cdp status`. + +### Install APK manually +```bash +adb install -r path/to/app.apk # install/reinstall +android device install --package path/to/app.apk +``` + +## Android CLI Tool + +The `android` command (from `androidsdk.tool` NuGet) wraps SDK tools. + +### SDK management +``` +android sdk list # all packages +android sdk list --installed # installed only +android sdk list --available # available for install +android sdk install --package "platforms;android-35" +android sdk install --package "system-images;android-35;google_apis;arm64-v8a" +android sdk install --package "emulator" +android sdk uninstall --package +android sdk info # SDK location, tools versions +android sdk accept-licenses # accept all SDK licenses +android sdk download # download cmdline-tools +``` + +### AVD management +``` +android avd list # available AVDs +android avd targets # available system images +android avd devices # available device profiles +android avd create --name --sdk --device +android avd delete --name +android avd start --name +``` + +### Device/emulator operations +``` +android device list # connected devices/emulators +android device info [--device ] # device properties +android device install --package # install APK +android device uninstall --package # uninstall by package name +``` + +### JDK management +``` +android jdk list # available JDKs +android jdk info # current JDK info +``` + +## ADB Reference + +### Device/emulator basics +```bash +adb devices # list connected devices +adb -s shell # shell into specific device +adb shell pm list packages | grep # find installed packages +adb shell am start -n / # launch activity +adb shell am force-stop # kill app +``` + +### Port forwarding (critical for MauiDevFlow) +```bash +adb reverse tcp:9223 tcp:9223 # Agent +adb reverse tcp:9222 tcp:9222 # CDP +adb reverse --list # verify forwarding +adb reverse --remove-all # clean up +``` + +### File operations +```bash +adb push local/file /sdcard/path # push file to device +adb pull /sdcard/path local/file # pull file from device +``` + +### Logs +```bash +adb logcat -s "DOTNET" --format brief # .NET runtime logs +adb logcat -s "MauiDevFlow" # agent logs +adb logcat --pid=$(adb shell pidof ) # app-specific logs +adb logcat -c # clear log buffer +``` + +### Screenshots and screen recording +```bash +adb shell screencap /sdcard/screen.png && adb pull /sdcard/screen.png +adb shell screenrecord /sdcard/video.mp4 # Ctrl+C to stop +``` + +## SDK Management + +### Typical setup for MAUI Android development +```bash +android sdk accept-licenses +android sdk install --package "platforms;android-35" +android sdk install --package "build-tools;35.0.0" +android sdk install --package "system-images;android-35;google_apis;arm64-v8a" +android sdk install --package "emulator" +android sdk install --package "platform-tools" +``` + +### Environment variables +```bash +export ANDROID_HOME=$HOME/Library/Android/sdk +export ANDROID_SDK_ROOT=$ANDROID_HOME +export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator +``` + +## Troubleshooting + +- **`adb devices` shows "unauthorized"**: Accept the USB debugging prompt on the device/emulator. +- **Agent not connecting on emulator**: Forgot `adb reverse`. Run port forwarding commands. +- **Emulator won't start**: Check available system images with `android avd targets`. May need + to install with `android sdk install --package "system-images;..."`. +- **Build error "No Android devices found"**: Ensure emulator is booted (`adb devices`). +- **Slow emulator**: Use hardware acceleration. Prefer `arm64-v8a` images on Apple Silicon Macs. diff --git a/.claude/skills/maui-ai-debugging/references/ios-and-mac.md b/.claude/skills/maui-ai-debugging/references/ios-and-mac.md new file mode 100644 index 0000000000..b5ca449c80 --- /dev/null +++ b/.claude/skills/maui-ai-debugging/references/ios-and-mac.md @@ -0,0 +1,177 @@ +# iOS & Mac Catalyst Reference + +## Table of Contents +- [Simulator Management](#simulator-management) +- [Building and Deploying](#building-and-deploying) +- [Apple CLI Tool](#apple-cli-tool) +- [xcrun simctl Reference](#xcrun-simctl-reference) +- [Troubleshooting](#troubleshooting) + +## Simulator Management + +### List simulators +```bash +xcrun simctl list devices # all devices by runtime +xcrun simctl list devices booted # only booted +xcrun simctl list devices available # only available +apple simulator list # formatted table +apple simulator list --booted # booted only +``` + +### Create simulator +```bash +# List available device types and runtimes first +xcrun simctl list devicetypes # e.g. "iPhone 16 Pro" +xcrun simctl list runtimes # e.g. "iOS 18.2" + +xcrun simctl create "My iPhone" "iPhone 16 Pro" "iOS 18.2" +apple simulator create "My iPhone" --device-type "iPhone 16 Pro" --runtime "iOS 18.2" +``` + +### Boot / shutdown +```bash +xcrun simctl boot +xcrun simctl shutdown +apple simulator boot +apple simulator shutdown +``` + +### Install and launch app +```bash +xcrun simctl install booted /path/to/App.app +xcrun simctl launch booted com.company.appid +``` + +### Screenshots (native simctl) +```bash +xcrun simctl io booted screenshot output.png +apple simulator screenshot --output output.png +``` + +### Delete / erase +```bash +xcrun simctl erase # factory reset +xcrun simctl delete # permanently remove +xcrun simctl delete unavailable # clean up old sims +``` + +## Building and Deploying + +### Mac Catalyst +```bash +dotnet build -f net10.0-maccatalyst # build only +dotnet build -f net10.0-maccatalyst -t:Run # build + run +open path/to/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/AppName.app # run existing +``` + +### iOS Simulator +```bash +# Find UDID of booted simulator +UDID=$(xcrun simctl list devices booted -j | python3 -c " +import json,sys +d=json.load(sys.stdin) +for r in d['devices'].values(): + for dev in r: + if dev['state']=='Booted': print(dev['udid']); break +" 2>/dev/null | head -1) + +# Build and deploy +dotnet build -f net10.0-ios -t:Run -p:_DeviceName=:v2:udid=$UDID +``` + +The `-t:Run` target uses `--wait-for-exit:true`, keeping the process alive while the app runs. +Run in background or a separate shell. The app process outputs console logs to stdout. + +### Determining the correct TFM +Check the project file for ``: +```bash +grep -i TargetFramework *.csproj +``` +Common values: `net9.0-ios`, `net9.0-maccatalyst`, `net10.0-ios`, `net10.0-maccatalyst`. + +## Apple CLI Tool + +The `apple` command (from `appledev.tools` NuGet) provides higher-level wrappers. + +### Simulator commands +``` +apple simulator list [--booted|--available|--unavailable|--name "..."] +apple simulator create --device-type "..." [--runtime "..."] +apple simulator boot +apple simulator shutdown +apple simulator erase +apple simulator delete +apple simulator screenshot [--output path.png] +apple simulator app install +apple simulator app launch +apple simulator app uninstall +apple simulator open [] +apple simulator open-url +apple simulator logs [--filter "..."] +apple simulator push [--payload "..."] +apple simulator location set --lat --lon +apple simulator privacy grant +``` + +### Device commands +``` +apple device list +apple xcode list # installed Xcode versions +``` + +## xcrun simctl Reference + +Key subcommands beyond the basics: + +| Command | Use | +|---------|-----| +| `simctl addmedia file.jpg` | Add photos/videos to sim | +| `simctl openurl "url"` | Open URL / deep link | +| `simctl push bundle payload.json` | Simulate push notification | +| `simctl privacy grant location bundle` | Grant permissions | +| `simctl location set 37.33,-122.03` | Set GPS location | +| `simctl pbcopy ` | Copy stdin to clipboard | +| `simctl pbpaste ` | Read clipboard | +| `simctl get_app_container bundle` | App container path | +| `simctl listapps ` | Installed apps | + +## Troubleshooting + +- **Mac Catalyst blank/white screen after crash**: macOS shows a "reopen windows" dialog after + a crash, blocking the app from rendering. All MAUI elements appear as `[hidden] [disabled]` + with `-1x-1` sizes. Fix: clear saved state before launch: + ```bash + rm -rf ~/Library/Saved\ Application\ State/.savedState + ``` + Or detect and dismiss via AppleScript: + ```bash + osascript -e 'tell application "System Events" to tell process "AppName" to click button "Reopen" of window 1' + ``` +- **"Unable to lookup in current state: Shutdown"**: Simulator not booted. Run `xcrun simctl boot `. +- **Build error NETSDK1005 "Assets file doesn't have a target"**: Wrong TFM. Check + `` in .csproj and use matching version (e.g. `net10.0-ios` not `net9.0-ios`). +- **Agent not connecting after deploy**: Ensure app finished launching. Wait 3-5 seconds after + `dotnet build -t:Run` starts before checking `maui-devflow MAUI status`. +- **Mac Catalyst app name vs binary name**: The `.app` bundle name may differ from the project + name (e.g. `MauiTodo.app` vs `SampleMauiApp`). Check the `ApplicationTitle` in .csproj. + Find the bundle: `find bin/Debug/net10.0-maccatalyst -name "*.app" -maxdepth 3` + +## Dark Mode Testing + +### Toggle dark mode +```bash +# macOS (affects Mac Catalyst apps) +osascript -e 'tell application "System Events" to tell appearance preferences to set dark mode to true' +osascript -e 'tell application "System Events" to tell appearance preferences to set dark mode to false' + +# iOS Simulator +xcrun simctl ui appearance dark +xcrun simctl ui appearance light +``` + +### Verify dark mode via inspection +Use `maui-devflow` to verify colors without relying on screenshots: +```bash +maui-devflow MAUI property BackgroundColor # check MAUI element colors +maui-devflow cdp Runtime evaluate "window.matchMedia('(prefers-color-scheme: dark)').matches" # Blazor +``` diff --git a/.claude/skills/maui-ai-debugging/references/setup.md b/.claude/skills/maui-ai-debugging/references/setup.md new file mode 100644 index 0000000000..1d12a8837f --- /dev/null +++ b/.claude/skills/maui-ai-debugging/references/setup.md @@ -0,0 +1,197 @@ +# Setup & Installation + +Complete guide for integrating MauiDevFlow into a .NET MAUI app. + +## 1. Install CLI Tools + +```bash +dotnet tool install --global Redth.MauiDevFlow.CLI # maui-devflow +dotnet tool install --global androidsdk.tool # android (Android only) +dotnet tool install --global appledev.tools # apple (iOS/Mac only) +``` + +Verify: `maui-devflow --version` + +## 2. Add NuGet Packages + +Add to your MAUI app's `.csproj`: + +```xml + + + + + +``` + +- `Redth.MauiDevFlow.Agent` — Required for all MAUI apps. Provides the in-app agent + for visual tree inspection, screenshots, tapping, filling text, etc. +- `Redth.MauiDevFlow.Blazor` — Required for Blazor Hybrid apps. Provides the CDP bridge + for DOM inspection, JavaScript evaluation, and Blazor debugging. + +## 3. Register in MauiProgram.cs + +```csharp +using MauiDevFlow.Agent; +using MauiDevFlow.Blazor; // Blazor Hybrid only + +var builder = MauiApp.CreateBuilder(); +// ... your existing setup ... + +#if DEBUG +builder.Services.AddBlazorWebViewDeveloperTools(); // Blazor Hybrid only +builder.AddMauiDevFlowAgent(options => { options.Port = 9223; }); +builder.AddMauiBlazorDevFlowTools(options => { options.Port = 9222; }); // Blazor Hybrid only +#endif +``` + +**Agent options:** +- `Port` — HTTP port for the agent REST API (default: 9223) +- `Enabled` — Enable/disable the agent (default: true) +- `MaxTreeDepth` — Max depth for visual tree queries, 0 = unlimited (default: 0) + +**Blazor options:** +- `Port` — WebSocket port for CDP bridge (default: 9222) +- `Enabled` — Enable/disable CDP bridge (default: true) +- `EnableWebViewInspection` — Enable WebView inspection (default: true) +- `EnableLogging` — Log debug messages (default: true in DEBUG) + +## 4. Blazor Hybrid: Add Script Tag to index.html + +**This step is required for Blazor Hybrid apps.** The `Redth.MauiDevFlow.Blazor` NuGet package +delivers `chobitsu.js` automatically as a static web asset — no file copying or manual downloads +needed. You just need to add one script tag to `wwwroot/index.html`. + +Add this line before ``: + +```html + +``` + +Example: +```html + + + + + My App + + + + +
+ + + + +``` + +### Why is this needed? + +MAUI's `app://` URL scheme blocks dynamic ` +``` + +### How the file gets there + +The `chobitsu.js` file is included in the NuGet package as a static web asset. It is +automatically available at the root of your app's `wwwroot/` — no `.targets` file copying, +no manual downloads. It works in both Debug and Release builds (though MauiDevFlow itself +should only be referenced in Debug configurations). + +## 5. Mac Catalyst: Entitlements + +Mac Catalyst apps need the `com.apple.security.network.server` entitlement to allow the +agent and CDP servers to bind ports. Without this, the app will crash or fail silently. + +### Option A: Sandbox disabled (simpler for development) + +Create or update `Platforms/MacCatalyst/Entitlements.plist` for Debug builds: + +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + +``` + +### Option B: Sandbox enabled (required for App Store) + +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.network.server + + + +``` + +Reference in your `.csproj` (Debug only, so Release uses the default entitlements): + +```xml + + Platforms/MacCatalyst/Entitlements.Debug.plist + +``` + +## 6. Android: Port Forwarding + +After deploying to an Android emulator, set up port forwarding so the CLI can reach the agent: + +```bash +adb reverse tcp:9223 tcp:9223 # Agent +adb reverse tcp:9222 tcp:9222 # CDP (Blazor Hybrid only) +``` + +This is needed because the emulator runs in its own network namespace. Physical devices +connected via USB also need this. + +## 7. Verify Setup + +After building and running the app: + +```bash +maui-devflow MAUI status # Should show agent info, platform, app name +maui-devflow cdp status # Should show "Connected" (Blazor Hybrid only) +``` + +If status commands fail: +- **Mac Catalyst:** Check entitlements (Step 5) +- **Android:** Check port forwarding (Step 6) +- **iOS Simulator:** Should work without extra config +- **All platforms:** Ensure the app is running and the `#if DEBUG` block is active + +## Quick Checklist + +For an AI agent setting up MauiDevFlow in a new project: + +1. [ ] `Redth.MauiDevFlow.Agent` NuGet package added +2. [ ] `Redth.MauiDevFlow.Blazor` NuGet package added (Blazor Hybrid only) +3. [ ] `builder.AddMauiDevFlowAgent(...)` in MauiProgram.cs inside `#if DEBUG` +4. [ ] `builder.AddMauiBlazorDevFlowTools(...)` in MauiProgram.cs (Blazor Hybrid only) +5. [ ] `` in index.html (Blazor Hybrid only) +6. [ ] Mac Catalyst entitlements include `network.server` (Mac Catalyst only) +7. [ ] `adb reverse` port forwarding (Android only) diff --git a/AutoPilot.App.csproj b/AutoPilot.App.csproj index e637f24fb7..48f9af7be8 100644 --- a/AutoPilot.App.csproj +++ b/AutoPilot.App.csproj @@ -74,6 +74,10 @@ + + + + diff --git a/Components/Layout/SessionSidebar.razor b/Components/Layout/SessionSidebar.razor index f3cec441b9..abe45426ee 100644 --- a/Components/Layout/SessionSidebar.razor +++ b/Components/Layout/SessionSidebar.razor @@ -49,6 +49,10 @@ else if (IsFlyoutPanel) } + @if (createError != null) + { +
⚠ @createError
+ } @@ -64,7 +68,7 @@ else

@if (CopilotService.IsInitialized) { - ● @(CopilotService.CurrentMode == ConnectionMode.Persistent ? "Persistent" : "Embedded") + ● @CopilotService.CurrentMode } else { @@ -98,9 +102,13 @@ else - + } + @if (createError != null) + { +

⚠ @createError
+ } @@ -216,6 +224,7 @@ else private string selectedModel = "claude-opus-4.6"; private string sessionFilter = ""; private bool isCreating = false; + private string? createError = null; private bool showPersistedSessions = false; private bool showDirectoryInput = false; private string? renamingSession = null; @@ -332,6 +341,7 @@ else } isCreating = true; + createError = null; try { var sessionInfo = await CopilotService.CreateSessionAsync(name.Trim(), selectedModel, workingDir); @@ -351,7 +361,8 @@ else } catch (Exception ex) { - Console.WriteLine($"Error creating session: {ex.Message}"); + createError = ex.Message; + Console.WriteLine($"Error creating session: {ex}"); } finally { diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index 7f6ae0a35a..c911e093e5 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -5,6 +5,7 @@ @inject CopilotService CopilotService @inject IJSRuntime JS @inject NavigationManager Nav +@inject DevTunnelService DevTunnelService @implements IDisposable
@@ -484,6 +485,15 @@ try { await CopilotService.InitializeAsync(); + + // If no remote URL configured, redirect straight to Settings + if (CopilotService.NeedsConfiguration) + { + _needsRedirect = true; + _redirectTo = "/settings"; + return; + } + // RestorePreviousSessionsAsync is already called inside InitializeAsync var uiState = CopilotService.LoadUiState(); if (uiState != null) @@ -496,6 +506,17 @@ _redirectTo = uiState.CurrentPage; } } + + // Auto-start tunnel if previously configured + var connSettings = ConnectionSettings.Load(); + if (connSettings.AutoStartTunnel && DevTunnelService.State == TunnelState.NotStarted) + { + _ = Task.Run(async () => + { + try { await DevTunnelService.HostAsync(connSettings.Port); } + catch (Exception ex) { Console.WriteLine($"[AutoStart] Tunnel failed: {ex.Message}"); } + }); + } } catch (Exception ex) { diff --git a/Components/Pages/Home.razor.css b/Components/Pages/Home.razor.css index 000f2d3a57..a7eaf36dc8 100644 --- a/Components/Pages/Home.razor.css +++ b/Components/Pages/Home.razor.css @@ -488,6 +488,7 @@ align-items: center; gap: 0.75rem; padding: 0 0.25rem; + padding-bottom: var(--nav-bar-height, 0px); font-size: 0.75rem; color: rgba(255,255,255,0.35); } @@ -523,6 +524,7 @@ flex-direction: column; gap: 0.4rem; padding: 0.5rem 0.75rem; + padding-bottom: calc(0.5rem + var(--nav-bar-height, 0px) + 14px); background: rgba(255,255,255,0.05); border-top: 1px solid rgba(255,255,255,0.1); } @@ -793,10 +795,10 @@ .error-card { font-size: 0.8rem; padding: 0.4rem 0.6rem; } .intent-pill { font-size: 0.7rem; padding: 0.2rem 0.5rem; } - .input-area { padding: 0.4rem 0.5rem 0.75rem; gap: 0.3rem; } + .input-area { padding: 0.4rem 0.5rem; padding-bottom: calc(0.5rem + var(--nav-bar-height, 0px) + 14px); gap: 0.3rem; } .input-area textarea { font-size: 0.85rem; min-height: 32px; padding: 0.35rem 0.5rem; } .input-area .input-row button { padding: 0.35rem 0.6rem; } - .input-status-bar { font-size: 0.65rem; gap: 0.5rem; padding-bottom: 0.5rem; } + .input-status-bar { font-size: 0.65rem; gap: 0.5rem; } .plan-icon-toggle { font-size: 0.65rem; } .queue-bar { padding: 0.3rem 0.5rem; font-size: 0.7rem; } diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index 9b15d4702d..f86d5c575a 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -3,7 +3,10 @@ @using AutoPilot.App.Models @inject CopilotService CopilotService @inject ServerManager ServerManager +@inject DevTunnelService DevTunnelService +@inject QrScannerService QrScanner @inject NavigationManager Nav +@implements IDisposable
@@ -13,18 +16,15 @@

Transport Mode

-
-
🔌
-
Embedded
-
Default. Copilot process dies when app closes.
-
-
-
🏗️
-
Persistent
-
Copilot server survives app restarts. Sessions persist.
-
+ @foreach (var mode in PlatformHelper.AvailableModes) + { +
+
@GetModeIcon(mode)
+
@mode
+
@GetModeDescription(mode)
+
+ }
@@ -59,6 +59,109 @@
+ + @if (serverAlive && PlatformHelper.IsDesktop) + { +
+

📡 Share via DevTunnel

+ @if (!devTunnelAvailable) + { +
+

⚠️ devtunnel CLI not found. Install it to share your server remotely.

+

Run: winget install Microsoft.devtunnel (Windows) or brew install --cask devtunnel (macOS)

+
+ } + else + { +
+
+ + @GetTunnelStatusText() +
+ + @if (DevTunnelService.State == TunnelState.NotStarted || DevTunnelService.State == TunnelState.Error) + { + @if (!tunnelLoggedIn) + { + + } + else + { + + } + + @if (DevTunnelService.State == TunnelState.Error) + { +

@DevTunnelService.ErrorMessage

+ } + } + else if (DevTunnelService.State == TunnelState.Running) + { +
+
+ @DevTunnelService.TunnelUrl + +
+ + @if (!string.IsNullOrEmpty(DevTunnelService.AccessToken)) + { +
+ + @DevTunnelService.AccessToken + + +
+ } + + @if (!string.IsNullOrEmpty(qrCodeDataUri)) + { +
+ QR Code +

Scan with AutoPilot on iOS/Android to connect

+
+ } +
+ + + } + else + { +

@GetTunnelStatusText()

+ } +
+ } +
+ } + } + + @if (settings.Mode == ConnectionMode.Remote) + { +
+

🌐 Remote Server

+
+

Connect to a Copilot server running on another machine (via DevTunnel URL).

+ + @if (PlatformHelper.IsMobile) + { + + } + +
+ + +
+
+ + +
+
+
}
@@ -80,8 +183,12 @@

About Transport Modes

    -
  • Embedded — Simple, default. Copilot process dies when app closes. All sessions are lost.
  • -
  • Persistent — App spawns a detached Copilot server on a port. Survives app restarts — sessions reconnect after relaunching.
  • + @if (PlatformHelper.IsDesktop) + { +
  • Embedded — Simple, default. Copilot process dies when app closes. All sessions are lost.
  • +
  • Persistent — App spawns a detached Copilot server on a port. Survives app restarts — sessions reconnect after relaunching.
  • + } +
  • Remote — Connect to a Copilot server on another machine via a DevTunnel URL. Use this from iOS/Android.
@@ -92,18 +199,175 @@ private string statusClass = ""; private bool serverAlive; private bool starting; + private bool devTunnelAvailable; + private bool tunnelLoggedIn; + private bool tunnelBusy; + private bool showToken; + private string? qrCodeDataUri; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { settings = ConnectionSettings.Load(); serverAlive = ServerManager.CheckServerRunning("localhost", settings.Port); + devTunnelAvailable = DevTunnelService.IsCliAvailable(); + if (devTunnelAvailable) + tunnelLoggedIn = await DevTunnelService.IsLoggedInAsync(); + + DevTunnelService.OnStateChanged += OnTunnelStateChanged; + + if (DevTunnelService.State == TunnelState.Running && DevTunnelService.TunnelUrl != null) + GenerateQrCode(DevTunnelService.TunnelUrl, DevTunnelService.AccessToken); } + public void Dispose() + { + DevTunnelService.OnStateChanged -= OnTunnelStateChanged; + } + + private void OnTunnelStateChanged() + { + InvokeAsync(() => + { + if (DevTunnelService.State == TunnelState.Running && DevTunnelService.TunnelUrl != null) + GenerateQrCode(DevTunnelService.TunnelUrl, DevTunnelService.AccessToken); + else + qrCodeDataUri = null; + StateHasChanged(); + }); + } + + private void GenerateQrCode(string url, string? token) + { + try + { + // Encode URL and token as JSON so the client gets everything from the QR code + var payload = string.IsNullOrEmpty(token) + ? url + : System.Text.Json.JsonSerializer.Serialize(new { url, token }); + + Console.WriteLine($"[QR] Generating QR code: url={url}, hasToken={!string.IsNullOrEmpty(token)}, payloadLen={payload.Length}"); + + using var qrGenerator = new QRCoder.QRCodeGenerator(); + using var qrCodeData = qrGenerator.CreateQrCode(payload, QRCoder.QRCodeGenerator.ECCLevel.L); + using var qrCode = new QRCoder.PngByteQRCode(qrCodeData); + var pngBytes = qrCode.GetGraphic(4, new byte[] { 0, 0, 0 }, new byte[] { 255, 255, 255 }); + qrCodeDataUri = $"data:image/png;base64,{Convert.ToBase64String(pngBytes)}"; + } + catch (Exception ex) + { + Console.WriteLine($"[QR] Error generating QR code: {ex.Message}"); + } + } + + private string GetModeIcon(ConnectionMode mode) => mode switch + { + ConnectionMode.Embedded => "🔌", + ConnectionMode.Persistent => "🏗️", + ConnectionMode.Remote => "🌐", + _ => "❓" + }; + + private string GetModeDescription(ConnectionMode mode) => mode switch + { + ConnectionMode.Embedded => "Default. Copilot process dies when app closes.", + ConnectionMode.Persistent => "Copilot server survives app restarts. Sessions persist.", + ConnectionMode.Remote => "Connect to a remote server via DevTunnel URL.", + _ => "" + }; + + private string GetTunnelStatusText() => DevTunnelService.State switch + { + TunnelState.NotStarted => "Tunnel not started", + TunnelState.Authenticating => "Authenticating with GitHub...", + TunnelState.Starting => "Starting tunnel...", + TunnelState.Running => $"Tunnel active: {DevTunnelService.TunnelUrl}", + TunnelState.Stopping => "Stopping tunnel...", + TunnelState.Error => $"Error: {DevTunnelService.ErrorMessage}", + _ => "Unknown" + }; + private void SetMode(ConnectionMode mode) { settings.Mode = mode; } + private async Task ScanQrCode() + { + var result = await QrScanner.ScanAsync(); + if (string.IsNullOrEmpty(result)) return; + + // QR code contains JSON { url, token } or a plain URL string + string? url = null; + try + { + var doc = System.Text.Json.JsonDocument.Parse(result); + if (doc.RootElement.TryGetProperty("url", out var urlProp)) + url = urlProp.GetString(); + if (doc.RootElement.TryGetProperty("token", out var tokenProp)) + settings.RemoteToken = tokenProp.GetString(); + } + catch + { + url = result; + } + + if (!string.IsNullOrEmpty(url)) + settings.RemoteUrl = url; + + statusMessage = "✅ QR code scanned!"; + statusClass = "success"; + StateHasChanged(); + } + + private async Task TunnelLogin() + { + tunnelBusy = true; + StateHasChanged(); + tunnelLoggedIn = await DevTunnelService.LoginAsync(); + tunnelBusy = false; + StateHasChanged(); + } + + private async Task StartTunnel() + { + tunnelBusy = true; + StateHasChanged(); + await DevTunnelService.HostAsync(settings.Port); + tunnelBusy = false; + StateHasChanged(); + } + + private void StopTunnel() + { + DevTunnelService.Stop(); + settings.AutoStartTunnel = false; + settings.Save(); + qrCodeDataUri = null; + StateHasChanged(); + } + + private async Task CopyTunnelUrl() + { + if (DevTunnelService.TunnelUrl != null) + { + await Microsoft.Maui.ApplicationModel.DataTransfer.Clipboard.SetTextAsync(DevTunnelService.TunnelUrl); + statusMessage = "📋 URL copied!"; + statusClass = "success"; + StateHasChanged(); + } + } + + private async Task CopyTunnelToken() + { + if (DevTunnelService.AccessToken != null) + { + await Microsoft.Maui.ApplicationModel.DataTransfer.Clipboard.SetTextAsync(DevTunnelService.AccessToken); + statusMessage = "📋 Token copied!"; + statusClass = "success"; + StateHasChanged(); + } + } + private async Task StartServer() { starting = true; @@ -123,6 +387,8 @@ private void StopServer() { + if (DevTunnelService.State == TunnelState.Running) + DevTunnelService.Stop(); ServerManager.StopServer(); serverAlive = false; statusMessage = "Server stopped"; @@ -132,7 +398,6 @@ private async Task SaveAndApply() { - // If switching to Persistent mode, ensure server is running if (settings.Mode == ConnectionMode.Persistent && !serverAlive) { statusMessage = "⚠️ Start the persistent server first"; @@ -141,6 +406,14 @@ return; } + if (settings.Mode == ConnectionMode.Remote && string.IsNullOrWhiteSpace(settings.RemoteUrl)) + { + statusMessage = "⚠️ Enter a remote server URL"; + statusClass = "error"; + StateHasChanged(); + return; + } + settings.Save(); statusMessage = "Settings saved. Reconnecting..."; statusClass = ""; diff --git a/Components/Pages/Settings.razor.css b/Components/Pages/Settings.razor.css index c0ca991c00..34ab20097d 100644 --- a/Components/Pages/Settings.razor.css +++ b/Components/Pages/Settings.razor.css @@ -57,7 +57,7 @@ .mode-cards { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; } @@ -330,3 +330,189 @@ .stop-btn:hover { background: #ef4444; } + +.tunnel-warning { + padding: 0.75rem; + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 8px; +} + +.tunnel-warning p { + margin: 0.25rem 0; + color: rgba(255,255,255,0.7); + font-size: 0.9rem; +} + +.tunnel-warning code { + background: rgba(0,0,0,0.3); + padding: 0.15rem 0.4rem; + border-radius: 4px; + font-family: monospace; + color: #fbbf24; +} + +.tunnel-warning .hint { + font-size: 0.8rem; + color: rgba(255,255,255,0.4); +} + +.tunnel-controls { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tunnel-error { + color: #ef4444; + font-size: 0.85rem; + margin: 0; +} + +.tunnel-url-section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.tunnel-url { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(0,0,0,0.3); + border-radius: 6px; +} + +.tunnel-url code { + flex: 1; + font-family: monospace; + color: #60a5fa; + word-break: break-all; +} + +.tunnel-token { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(0,0,0,0.3); + border-radius: 6px; +} + +.tunnel-token label { + color: rgba(255,255,255,0.6); + font-size: 0.85rem; + white-space: nowrap; +} + +.token-value { + flex: 1; + font-family: monospace; + color: rgba(255,255,255,0.4); + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + transition: filter 0.2s ease; +} + +.token-value.blurred { + filter: blur(5px); + user-select: none; +} + +.token-value.blurred:hover { + filter: blur(2px); +} + +.copy-btn { + background: transparent; + border: 1px solid rgba(255,255,255,0.2); + border-radius: 4px; + padding: 0.2rem 0.5rem; + cursor: pointer; + font-size: 0.9rem; + color: rgba(255,255,255,0.6); +} + +.copy-btn:hover { + border-color: rgba(255,255,255,0.4); +} + +.qr-code { + text-align: center; + padding: 1rem; + background: rgba(255,255,255,0.03); + border-radius: 12px; + border: 1px solid rgba(255,255,255,0.08); +} + +.qr-code img { + width: 200px; + height: 200px; + border-radius: 8px; +} + +.qr-hint { + font-size: 0.8rem; + color: rgba(255,255,255,0.4); + margin: 0.5rem 0 0; +} + +.status-dot.error { + background: #ef4444; + box-shadow: 0 0 6px #ef4444; +} + +.remote-controls { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.mode-hint { + color: rgba(255,255,255,0.5); + font-size: 0.9rem; + margin: 0; +} + +.url-input { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.url-input label { + color: rgba(255,255,255,0.7); + white-space: nowrap; +} + +.form-input.wide { + flex: 1; + max-width: none; +} + +.tunnel-status-text { + color: rgba(255,255,255,0.5); + font-size: 0.9rem; + margin: 0; +} + +.scan-btn { + width: 100%; + padding: 0.75rem; + border: 2px dashed rgba(59, 130, 246, 0.5); + border-radius: 10px; + background: rgba(59, 130, 246, 0.08); + color: #60a5fa; + font-size: 1.1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.scan-btn:hover { + background: rgba(59, 130, 246, 0.15); + border-color: #3b82f6; +} diff --git a/MainPage.xaml.cs b/MainPage.xaml.cs index bbc7fa12f4..da994fe80e 100644 --- a/MainPage.xaml.cs +++ b/MainPage.xaml.cs @@ -1,4 +1,5 @@ using AutoPilot.App.Components; +using Microsoft.AspNetCore.Components.WebView; using Microsoft.AspNetCore.Components.WebView.Maui; namespace AutoPilot.App; @@ -14,5 +15,78 @@ public MainPage() Selector = "#app", ComponentType = typeof(Routes) }); + +#if ANDROID + blazorWebView.BlazorWebViewInitialized += OnBlazorWebViewInitialized; +#endif + } + +#if ANDROID + private void OnBlazorWebViewInitialized(object? sender, BlazorWebViewInitializedEventArgs e) + { + var webView = e.WebView; + // Wait for layout so WindowInsets are available + webView.ViewTreeObserver!.GlobalLayout += (s, args) => + { + InjectInsetsJs(webView); + }; + // Also try immediately in case layout already happened + Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(800), () => InjectInsetsJs(webView)); + } + + private bool _insetsInjected; + private void InjectInsetsJs(Android.Webkit.WebView webView) + { + if (_insetsInjected) return; + + var activity = Platform.CurrentActivity; + var rootInsets = activity?.Window?.DecorView?.RootWindowInsets; + if (rootInsets == null) + { + Console.WriteLine("[Insets] RootWindowInsets is null, skipping"); + return; + } + + var density = activity!.Resources?.DisplayMetrics?.Density ?? 1; + double topInset, bottomInset; + + if (Android.OS.Build.VERSION.SdkInt >= Android.OS.BuildVersionCodes.R) + { + var systemBars = rootInsets.GetInsets(Android.Views.WindowInsets.Type.SystemBars()); + topInset = systemBars.Top / density; + bottomInset = systemBars.Bottom / density; + } + else + { + #pragma warning disable CA1422 + topInset = rootInsets.StableInsetTop / density; + bottomInset = rootInsets.StableInsetBottom / density; + #pragma warning restore CA1422 + } + + if (bottomInset <= 0 && topInset <= 0) + { + Console.WriteLine("[Insets] Both insets are 0, skipping"); + return; + } + + _insetsInjected = true; + Console.WriteLine($"[Insets] Injecting: top={topInset:F0}px bottom={bottomInset:F0}px"); + + var js = $@" + document.documentElement.style.setProperty('--status-bar-height', '{topInset:F0}px'); + document.documentElement.style.setProperty('--nav-bar-height', '{bottomInset:F0}px'); + console.log('Insets injected: top={topInset:F0}px bottom={bottomInset:F0}px'); + "; + + try + { + webView.EvaluateJavascript(js, null); + } + catch (Exception ex) + { + Console.WriteLine($"[Insets] JS injection failed: {ex.Message}"); + } } +#endif } diff --git a/MauiProgram.cs b/MauiProgram.cs index e10defd842..75eb691c73 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -1,12 +1,17 @@ using AutoPilot.App.Services; using Microsoft.Extensions.Logging; +using ZXing.Net.Maui.Controls; +using MauiDevFlow.Agent; +using MauiDevFlow.Blazor; namespace AutoPilot.App; public static class MauiProgram { private static readonly string CrashLogPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + string.IsNullOrEmpty(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)) + ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "autopilot-crash.log"); public static MauiApp CreateMauiApp() @@ -26,6 +31,7 @@ public static MauiApp CreateMauiApp() var builder = MauiApp.CreateBuilder(); builder .UseMauiApp() + .UseBarcodeReader() .ConfigureFonts(fonts => { fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); @@ -37,10 +43,23 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); builder.Logging.AddDebug(); +#if MACCATALYST + // Mac server app: Agent=9233, CDP=9232 + builder.AddMauiDevFlowAgent(options => { options.Port = 9233; }); + builder.AddMauiBlazorDevFlowTools(); +#else + // Mobile client apps: Agent=9243, CDP=9242 + builder.AddMauiDevFlowAgent(options => { options.Port = 9243; }); + builder.AddMauiBlazorDevFlowTools(); +#endif #endif return builder.Build(); diff --git a/Models/BridgeMessages.cs b/Models/BridgeMessages.cs new file mode 100644 index 0000000000..cbc83e6fcf --- /dev/null +++ b/Models/BridgeMessages.cs @@ -0,0 +1,216 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AutoPilot.App.Models; + +/// +/// JSON messages for the remote viewer WebSocket protocol. +/// Server pushes state/events to clients; clients send commands back. +/// + +// --- Base envelope --- + +public class BridgeMessage +{ + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("payload")] + public JsonElement? Payload { get; set; } + + public static BridgeMessage Create(string type, T payload) + { + var json = JsonSerializer.SerializeToElement(payload, BridgeJson.Options); + return new BridgeMessage { Type = type, Payload = json }; + } + + public T? GetPayload() => + Payload.HasValue ? JsonSerializer.Deserialize(Payload.Value, BridgeJson.Options) : default; + + public string Serialize() => JsonSerializer.Serialize(this, BridgeJson.Options); + + public static BridgeMessage? Deserialize(string json) + { + try { return JsonSerializer.Deserialize(json, BridgeJson.Options); } + catch { return null; } + } +} + +public static class BridgeJson +{ + public static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + }; +} + +// --- Message type constants --- + +public static class BridgeMessageTypes +{ + // Server → Client + public const string SessionsList = "sessions_list"; + public const string SessionHistory = "session_history"; + public const string PersistedSessionsList = "persisted_sessions"; + public const string ContentDelta = "content_delta"; + public const string ToolStarted = "tool_started"; + public const string ToolCompleted = "tool_completed"; + public const string ReasoningDelta = "reasoning_delta"; + public const string ReasoningComplete = "reasoning_complete"; + public const string IntentChanged = "intent_changed"; + public const string UsageInfo = "usage_info"; + public const string TurnStart = "turn_start"; + public const string TurnEnd = "turn_end"; + public const string SessionComplete = "session_complete"; + public const string ErrorEvent = "error"; + + // Client → Server + public const string GetSessions = "get_sessions"; + public const string GetHistory = "get_history"; + public const string GetPersistedSessions = "get_persisted_sessions"; + public const string SendMessage = "send_message"; + public const string CreateSession = "create_session"; + public const string ResumeSession = "resume_session"; + public const string SwitchSession = "switch_session"; + public const string QueueMessage = "queue_message"; +} + +// --- Server → Client payloads --- + +public class SessionsListPayload +{ + public List Sessions { get; set; } = new(); + public string? ActiveSession { get; set; } +} + +public class SessionSummary +{ + public string Name { get; set; } = ""; + public string Model { get; set; } = ""; + public DateTime CreatedAt { get; set; } + public int MessageCount { get; set; } + public bool IsProcessing { get; set; } + public string? SessionId { get; set; } + public string? WorkingDirectory { get; set; } + public int QueueCount { get; set; } +} + +public class SessionHistoryPayload +{ + public string SessionName { get; set; } = ""; + public List Messages { get; set; } = new(); +} + +public class ContentDeltaPayload +{ + public string SessionName { get; set; } = ""; + public string Content { get; set; } = ""; +} + +public class ToolStartedPayload +{ + public string SessionName { get; set; } = ""; + public string ToolName { get; set; } = ""; + public string CallId { get; set; } = ""; +} + +public class ToolCompletedPayload +{ + public string SessionName { get; set; } = ""; + public string CallId { get; set; } = ""; + public string Result { get; set; } = ""; + public bool Success { get; set; } +} + +public class ReasoningDeltaPayload +{ + public string SessionName { get; set; } = ""; + public string ReasoningId { get; set; } = ""; + public string Content { get; set; } = ""; +} + +public class SessionNamePayload +{ + public string SessionName { get; set; } = ""; +} + +public class IntentChangedPayload +{ + public string SessionName { get; set; } = ""; + public string Intent { get; set; } = ""; +} + +public class UsageInfoPayload +{ + public string SessionName { get; set; } = ""; + public string? Model { get; set; } + public int? CurrentTokens { get; set; } + public int? TokenLimit { get; set; } + public int? InputTokens { get; set; } + public int? OutputTokens { get; set; } +} + +public class SessionCompletePayload +{ + public string SessionName { get; set; } = ""; + public string Summary { get; set; } = ""; +} + +public class ErrorPayload +{ + public string SessionName { get; set; } = ""; + public string Error { get; set; } = ""; +} + +// --- Client → Server payloads --- + +public class GetHistoryPayload +{ + public string SessionName { get; set; } = ""; +} + +public class SendMessagePayload +{ + public string SessionName { get; set; } = ""; + public string Message { get; set; } = ""; +} + +public class CreateSessionPayload +{ + public string Name { get; set; } = ""; + public string? Model { get; set; } + public string? WorkingDirectory { get; set; } +} + +public class SwitchSessionPayload +{ + public string SessionName { get; set; } = ""; +} + +public class QueueMessagePayload +{ + public string SessionName { get; set; } = ""; + public string Message { get; set; } = ""; +} + +public class PersistedSessionsPayload +{ + public List Sessions { get; set; } = new(); +} + +public class PersistedSessionSummary +{ + public string SessionId { get; set; } = ""; + public string? Title { get; set; } + public string? Preview { get; set; } + public string? WorkingDirectory { get; set; } + public DateTime LastModified { get; set; } +} + +public class ResumeSessionPayload +{ + public string SessionId { get; set; } = ""; + public string? DisplayName { get; set; } +} diff --git a/Models/ChatMessage.cs b/Models/ChatMessage.cs index ef61fe9be8..d67e01d150 100644 --- a/Models/ChatMessage.cs +++ b/Models/ChatMessage.cs @@ -12,6 +12,9 @@ public enum ChatMessageType public class ChatMessage { + // Parameterless constructor for JSON deserialization + public ChatMessage() : this("assistant", "", DateTime.Now) { } + public ChatMessage(string role, string content, DateTime timestamp, ChatMessageType messageType = ChatMessageType.User) { Role = role; diff --git a/Models/ConnectionSettings.cs b/Models/ConnectionSettings.cs index 71e8fc9312..fe507ee693 100644 --- a/Models/ConnectionSettings.cs +++ b/Models/ConnectionSettings.cs @@ -6,22 +6,36 @@ namespace AutoPilot.App.Models; public enum ConnectionMode { Embedded, // SDK spawns copilot via stdio (default, dies with app) - Persistent // App spawns detached copilot server; survives app restarts + Persistent, // App spawns detached copilot server; survives app restarts + Remote // Connect to a remote server via URL (e.g. DevTunnel) } public class ConnectionSettings { - public ConnectionMode Mode { get; set; } = ConnectionMode.Embedded; + public ConnectionMode Mode { get; set; } = PlatformHelper.DefaultMode; public string Host { get; set; } = "localhost"; public int Port { get; set; } = 4321; public bool AutoStartServer { get; set; } = false; + public string? RemoteUrl { get; set; } + public string? RemoteToken { get; set; } + public string? TunnelId { get; set; } + public bool AutoStartTunnel { get; set; } = false; [JsonIgnore] - public string CliUrl => $"{Host}:{Port}"; + public string CliUrl => Mode == ConnectionMode.Remote && !string.IsNullOrEmpty(RemoteUrl) + ? RemoteUrl + : $"{Host}:{Port}"; private static readonly string SettingsPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".copilot", "autopilot-settings.json"); + GetCopilotDir(), "autopilot-settings.json"); + + private static string GetCopilotDir() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(home, ".copilot"); + } public static ConnectionSettings Load() { diff --git a/Models/PlatformHelper.cs b/Models/PlatformHelper.cs new file mode 100644 index 0000000000..c7885334ff --- /dev/null +++ b/Models/PlatformHelper.cs @@ -0,0 +1,26 @@ +namespace AutoPilot.App.Models; + +public static class PlatformHelper +{ + public static bool IsDesktop => +#if MACCATALYST || WINDOWS + true; +#else + false; +#endif + + public static bool IsMobile => +#if IOS || ANDROID + true; +#else + false; +#endif + + public static ConnectionMode[] AvailableModes => IsDesktop + ? [ConnectionMode.Embedded, ConnectionMode.Persistent, ConnectionMode.Remote] + : [ConnectionMode.Remote]; + + public static ConnectionMode DefaultMode => IsDesktop + ? ConnectionMode.Embedded + : ConnectionMode.Remote; +} diff --git a/Platforms/Android/AndroidManifest.xml b/Platforms/Android/AndroidManifest.xml index dbf9e7e531..24166ca757 100644 --- a/Platforms/Android/AndroidManifest.xml +++ b/Platforms/Android/AndroidManifest.xml @@ -3,4 +3,5 @@ + \ No newline at end of file diff --git a/Platforms/Android/FolderPickerService.cs b/Platforms/Android/FolderPickerService.cs new file mode 100644 index 0000000000..46a2e6c608 --- /dev/null +++ b/Platforms/Android/FolderPickerService.cs @@ -0,0 +1,10 @@ +namespace AutoPilot.App.Services; + +public static class FolderPickerService +{ + public static Task PickFolderAsync() + { + // Folder picking not supported on Android yet + return Task.FromResult(null); + } +} diff --git a/Platforms/Android/MainActivity.cs b/Platforms/Android/MainActivity.cs index 20f88e9fa5..f3c8d1d547 100644 --- a/Platforms/Android/MainActivity.cs +++ b/Platforms/Android/MainActivity.cs @@ -1,10 +1,24 @@ using Android.App; using Android.Content.PM; using Android.OS; +using Android.Views; +using AndroidX.Core.View; namespace AutoPilot.App; [Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] public class MainActivity : MauiAppCompatActivity { + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + // Draw behind system bars (edge-to-edge) + if (Window != null) + { + WindowCompat.SetDecorFitsSystemWindows(Window, false); + Window.SetStatusBarColor(Android.Graphics.Color.Transparent); + Window.SetNavigationBarColor(Android.Graphics.Color.Transparent); + } + } } diff --git a/Platforms/Windows/FolderPickerService.cs b/Platforms/Windows/FolderPickerService.cs new file mode 100644 index 0000000000..a95aecb0a9 --- /dev/null +++ b/Platforms/Windows/FolderPickerService.cs @@ -0,0 +1,10 @@ +namespace AutoPilot.App.Services; + +public static class FolderPickerService +{ + public static Task PickFolderAsync() + { + // TODO: Implement Windows folder picker + return Task.FromResult(null); + } +} diff --git a/Platforms/iOS/FolderPickerService.cs b/Platforms/iOS/FolderPickerService.cs new file mode 100644 index 0000000000..43ed2c0ab4 --- /dev/null +++ b/Platforms/iOS/FolderPickerService.cs @@ -0,0 +1,53 @@ +using UIKit; +using UniformTypeIdentifiers; + +namespace AutoPilot.App.Services; + +public static class FolderPickerService +{ + public static Task PickFolderAsync() + { + var tcs = new TaskCompletionSource(); + + var picker = new UIDocumentPickerViewController(new[] { UTTypes.Folder }); + picker.AllowsMultipleSelection = false; + + picker.DidPickDocumentAtUrls += (_, e) => + { + var url = e.Urls?.FirstOrDefault(); + if (url != null) + { + url.StartAccessingSecurityScopedResource(); + tcs.TrySetResult(url.Path); + } + else + { + tcs.TrySetResult(null); + } + }; + + picker.WasCancelled += (_, _) => + { + tcs.TrySetResult(null); + }; + + var viewController = GetTopViewController(); + viewController?.PresentViewController(picker, true, null); + + if (viewController == null) + tcs.TrySetResult(null); + + return tcs.Task; + } + + private static UIViewController? GetTopViewController() + { + var scenes = UIApplication.SharedApplication.ConnectedScenes; + var windowScene = scenes.ToArray().OfType().FirstOrDefault(); + var window = windowScene?.Windows.FirstOrDefault(w => w.IsKeyWindow); + var vc = window?.RootViewController; + while (vc?.PresentedViewController != null) + vc = vc.PresentedViewController; + return vc; + } +} diff --git a/Platforms/iOS/Info.plist b/Platforms/iOS/Info.plist index ecb7f719bd..2910079de2 100644 --- a/Platforms/iOS/Info.plist +++ b/Platforms/iOS/Info.plist @@ -28,5 +28,7 @@ XSAppIconAssets Assets.xcassets/appicon.appiconset + NSCameraUsageDescription + AutoPilot uses the camera to scan QR codes for connecting to remote servers. diff --git a/QrScannerPage.xaml b/QrScannerPage.xaml new file mode 100644 index 0000000000..7837f0791e --- /dev/null +++ b/QrScannerPage.xaml @@ -0,0 +1,34 @@ + + + + +