Skip to content

Comments

[xabt] dotnet watch support, based on env vars#10778

Draft
jonathanpeppers wants to merge 4 commits intomainfrom
dev/peppers/hot-reload-env-vars
Draft

[xabt] dotnet watch support, based on env vars#10778
jonathanpeppers wants to merge 4 commits intomainfrom
dev/peppers/hot-reload-env-vars

Conversation

@jonathanpeppers
Copy link
Member

Context: dotnet/sdk#52492
Context: dotnet/sdk#52581

dotnet-watch now runs Android applications via:

dotnet watch 🚀 [helloandroid (net10.0-android)] Launched 'D:\src\xamarin-android\bin\Debug\dotnet\dotnet.exe' with arguments 'run --no-build -e DOTNET_WATCH=1 -e DOTNET_WATCH_ITERATION=1 -e DOTNET_MODIFIABLE_ASSEMBLIES=debug -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://localhost:9000 -e DOTNET_STARTUP_HOOKS=D:\src\xamarin-android\bin\Debug\dotnet\sdk\10.0.300-dev\DotnetTools\dotnet-watch\10.0.300-dev\tools\net10.0\any\hotreload\net10.0\Microsoft.Extensions.DotNetDeltaApplier.dll -bl': process id 3356

And so the pieces on Android for this to work are:

Startup Hook Assembly

Parse out the value:

<_AndroidHotReloadAgentAssemblyPath>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_STARTUP_HOOKS')->'%(Value)'->Exists())</_AndroidHotReloadAgentAssemblyPath>

And verify this assembly is included in the app:

<ResolvedFileToPublish Include="$(_AndroidHotReloadAgentAssemblyPath)" />

Then, for Android, we need to patch up $DOTNET_STARTUP_HOOKS to be just the assembly name, not the full path:

<_AndroidHotReloadAgentAssemblyName>$([System.IO.Path]::GetFileNameWithoutExtension('$(_AndroidHotReloadAgentAssemblyPath)'))</_AndroidHotReloadAgentAssemblyName>
...
<RuntimeEnvironmentVariable Include="DOTNET_STARTUP_HOOKS" Value="$(_AndroidHotReloadAgentAssemblyName)" />

Port Forwarding

A new _AndroidConfigureAdbReverse target runs after deploying apps, that does:

adb reverse tcp:9000 tcp:9000

I parsed the value out of:

<_AndroidWebSocketEndpoint>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT')->'%(Value)')</_AndroidWebSocketEndpoint>
<_AndroidWebSocketPort>$([System.Text.RegularExpressions.Regex]::Match('$(_AndroidWebSocketEndpoint)', ':(\d+)').Groups[1].Value)</_AndroidWebSocketPort>

Prevent Startup Hooks in Microsoft.Android.Run

When I was implementing this, I keep seeing two clients connect to dotnet-watch and I was pulling my hair to figure out why!

Then I realized that Microsoft.Android.Run was also getting $DOTNET_STARTUP_HOOKS, and so we had a desktop process + mobile process both trying to connect!

Easiest fix, is to disable startup hook support in Microsoft.Android.Run. I reviewed the code in dotnet run, and it doesn't seem correct to try to clear the env vars.

Conclusion

With these changes, everything is working!

dotnet watch 🔥 C# and Razor changes applied in 23ms.

This will depend on getting changes in dotnet/sdk before we merge.

Context: dotnet/sdk#52492
Context: dotnet/sdk#52581

`dotnet-watch` now runs Android applications via:

    dotnet watch 🚀 [helloandroid (net10.0-android)] Launched 'D:\src\xamarin-android\bin\Debug\dotnet\dotnet.exe' with arguments 'run --no-build -e DOTNET_WATCH=1 -e DOTNET_WATCH_ITERATION=1 -e DOTNET_MODIFIABLE_ASSEMBLIES=debug -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://localhost:9000 -e DOTNET_STARTUP_HOOKS=D:\src\xamarin-android\bin\Debug\dotnet\sdk\10.0.300-dev\DotnetTools\dotnet-watch\10.0.300-dev\tools\net10.0\any\hotreload\net10.0\Microsoft.Extensions.DotNetDeltaApplier.dll -bl': process id 3356

And so the pieces on Android for this to work are:

~~ Startup Hook Assembly ~~

Parse out the value:

    <_AndroidHotReloadAgentAssemblyPath>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_STARTUP_HOOKS')->'%(Value)'->Exists())</_AndroidHotReloadAgentAssemblyPath>

And verify this assembly is included in the app:

    <ResolvedFileToPublish Include="$(_AndroidHotReloadAgentAssemblyPath)" />

Then, for Android, we need to patch up `$DOTNET_STARTUP_HOOKS` to be
just the assembly name, not the full path:

    <_AndroidHotReloadAgentAssemblyName>$([System.IO.Path]::GetFileNameWithoutExtension('$(_AndroidHotReloadAgentAssemblyPath)'))</_AndroidHotReloadAgentAssemblyName>
    ...
    <RuntimeEnvironmentVariable Include="DOTNET_STARTUP_HOOKS" Value="$(_AndroidHotReloadAgentAssemblyName)" />

~~ Port Forwarding ~~

A new `_AndroidConfigureAdbReverse` target runs after deploying apps,
that does:

    adb reverse tcp:9000 tcp:9000

I parsed the value out of:

    <_AndroidWebSocketEndpoint>@(RuntimeEnvironmentVariable->WithMetadataValue('Identity', 'DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT')->'%(Value)')</_AndroidWebSocketEndpoint>
    <_AndroidWebSocketPort>$([System.Text.RegularExpressions.Regex]::Match('$(_AndroidWebSocketEndpoint)', ':(\d+)').Groups[1].Value)</_AndroidWebSocketPort>

~~ Prevent Startup Hooks in Microsoft.Android.Run ~~

When I was implementing this, I keep seeing *two* clients connect to
`dotnet-watch` and I was pulling my hair to figure out why!

Then I realized that `Microsoft.Android.Run` was also getting
`$DOTNET_STARTUP_HOOKS`, and so we had a desktop process + mobile
process both trying to connect!

Easiest fix, is to disable startup hook support in
`Microsoft.Android.Run`. I reviewed the code in `dotnet run`, and it
doesn't seem correct to try to clear the env vars.

~~ Conclusion ~~

With these changes, everything is working!

    dotnet watch 🔥 C# and Razor changes applied in 23ms.

This will depend on getting changes in dotnet/sdk before we merge.
Comment on lines +50 to +51
<!-- Set STARTUP_HOOKS via RuntimeHostConfigurationOption for MonoVM (read by Mono runtime) -->
<RuntimeHostConfigurationOption Include="STARTUP_HOOKS" Value="$(_AndroidHotReloadAgentAssemblyName)" Condition=" '$(UseMonoRuntime)' == 'true' " />
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

…Microsoft.Android.Sdk.HotReload.targets

Co-authored-by: Šimon Rozsíval <simon@rozsival.com>
Switch parsing of _AndroidWebSocketEndpoint to use System.UriBuilder so the Port can be read reliably, and normalize a -1 port (meaning no port specified) to an empty value. This ensures the subsequent Condition checking for a non-empty port behaves correctly when the endpoint has no explicit port.
Introduce a HotReloadWebSockets ProjectCapability in Microsoft.Android.Sdk.ProjectCapabilities.targets (conditioned on AndroidApplication). This exposes WebSocket-based hot-reload support to tooling so Android application projects can be detected as supporting hot reload.
jonathanpeppers added a commit to dotnet/sdk that referenced this pull request Feb 20, 2026
Fixes: #52492

# `dotnet watch` for .NET MAUI Scenarios

## Overview

This implements `dotnet watch` / Hot Reload for mobile platforms (Android, iOS), which cannot use the standard named pipe transport. Similar to how web applications already use websockets for reloading CSS and JavaScript, we use the same model for mobile applications.

## Transport Selection

| Platform        | Transport  | Reason                                                                        |
|-----------------|------------|-------------------------------------------------------------------------------|
| Desktop/Console | Named Pipe | Existing implementation, Fast, local IPC                                      |
| Android/iOS     | WebSocket  | Named pipes don't work over the network; `adb reverse` tunnels the connection |

`dotnet-watch` detects WebSocket transport via the `HotReloadWebSockets` capability:

```xml
<ProjectCapability Include="HotReloadWebSockets" />
```

Mobile workloads (Android, iOS) add this capability to their SDK targets. This allows any workload to opt into WebSocket-based hot reload.

## SDK Changes

### Architecture

Both named pipe and WebSocket transports now share a common `ClientTransport` abstraction consumed by `DefaultHotReloadClient`:

- **`NamedPipeClientTransport`** — existing local IPC path
- **`WebSocketClientTransport`** — new path for mobile, composes a sealed `KestrelWebSocketServer`

The agent side mirrors this with a `Transport` base class (`NamedPipeTransport` / `WebSocketTransport`).

### WebSocket Details

`dotnet-watch` already has a WebSocket server for web apps: `BrowserRefreshServer`. This server:

- Hosts via Kestrel on a local HTTP or HTTPS endpoint (e.g., `http://127.0.0.1:<port>` or `https://localhost:<port>`)
- Communicates with JavaScript (`aspnetcore-browser-refresh.js`) injected into web pages
- Sends commands like "refresh CSS", "reload page", "apply Blazor delta"

For mobile, we reuse the Kestrel infrastructure but with a different protocol:

| Server                     | Client                 | Protocol                                   |
|----------------------------|------------------------|--------------------------------------------|
| `BrowserRefreshServer`     | JavaScript in browser  | JSON messages for CSS/page refresh         |
| `WebSocketClientTransport` | Startup hook on device | Binary delta payloads (same as named pipe) |

`WebSocketClientTransport` composes a sealed `KestrelWebSocketServer` (shared with `BrowserRefreshServer`) and speaks the same binary protocol as the named pipe transport, just over WebSocket instead. Static asset updates (CSS) are also supported.

### WebSocket Authentication

To prevent unauthorized processes from connecting to the hot reload server, `WebSocketClientTransport` uses RSA-based authentication identical to `BrowserRefreshServer`:

1. **Server generates RSA key pair:** `SharedSecretProvider` creates a 2048-bit RSA key on startup
2. **Public key exported:** The public key (X.509 SubjectPublicKeyInfo, Base64-encoded) is passed to the app via `DOTNET_WATCH_HOTRELOAD_WEBSOCKET_KEY`
3. **Client encrypts secret:** The startup hook generates a random 32-byte secret, encrypts it with RSA-OAEP-SHA256 using the public key
4. **Secret sent as subprotocol:** The encrypted secret is URL-encoded (`WebUtility.UrlEncode`) and sent as the WebSocket subprotocol header — same encoding as `BrowserRefreshServer`
5. **Server validates:** `WebSocketClientTransport.HandleRequestAsync` decodes and decrypts the subprotocol value, accepting the connection only if decryption succeeds

### HTTPS Support

`KestrelWebSocketServer` supports binding both HTTP and HTTPS ports simultaneously via the `securePort` parameter. When `AgentWebSocketSecurePort` is configured, the server binds a WSS endpoint alongside the WS endpoint.

### Environment Variables

`dotnet-watch` launches the app via:

```dotnetcli
dotnet run --no-build \
  -e DOTNET_WATCH=1 \
  -e DOTNET_MODIFIABLE_ASSEMBLIES=debug \
  -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://127.0.0.1:<port> \
  -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_KEY=<base64-encoded-rsa-public-key> \
  -e DOTNET_STARTUP_HOOKS=<path to DeltaApplier.dll>
```

The port is dynamically assigned (defaults to 0, meaning the OS picks an available port) to avoid conflicts in CI and parallel test scenarios. The `DOTNET_WATCH_AGENT_WEBSOCKET_PORT` environment variable can override this if a specific port is needed. `DOTNET_WATCH_AGENT_WEBSOCKET_SECURE_PORT` optionally enables WSS.

These environment variables are passed as `@(RuntimeEnvironmentVariable)` MSBuild items to the workload. See `dotnet-run-for-maui.md` for details on `dotnet run` and environment variables.

## Android Workload Changes (Example Integration)

### [dotnet/android#10770](dotnet/android#10770) — RuntimeEnvironmentVariable Support

Enables the Android workload to receive env vars from `dotnet run -e`:

- Adds `<ProjectCapability Include="RuntimeEnvironmentVariableSupport" />`
- Adds `<ProjectCapability Include="HotReloadWebSockets" />` to opt into WebSocket-based hot reload
- Configures `@(RuntimeEnvironmentVariable)` items, so they will apply to Android.

### [dotnet/android#10778](dotnet/android#10778) — dotnet-watch Integration

1. **Startup Hook:** Parses `DOTNET_STARTUP_HOOKS`, includes the assembly in the app package, rewrites the path to just the assembly name (since the full path doesn't exist on device)
2. **Port Forwarding:** Runs `adb reverse tcp:<port> tcp:<port>` so the device can reach the host's WebSocket server via `127.0.0.1:<port>` (port is parsed from the endpoint URL)
3. **Prevents Double Connection:** Disables startup hooks in `Microsoft.Android.Run` (the desktop launcher) so only the mobile app connects

## Data Flow

1. **Build:** `dotnet-watch` builds the project, detects `HotReloadWebSockets` capability
2. **Launch:** `dotnet run -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT=ws://127.0.0.1:<port> -e DOTNET_WATCH_HOTRELOAD_WEBSOCKET_KEY=<key> -e DOTNET_STARTUP_HOOKS=...`
3. **Workload:** Android build tasks:a
   - Include the startup hook DLL in the APK
   - Set up ADB port forwarding for the dynamically assigned port
   - Rewrite env vars for on-device paths
4. **Device:** App starts → StartupHook loads → `Transport.TryCreate()` reads env vars → `WebSocketTransport` encrypts secret with RSA public key → connects to `ws://127.0.0.1:<port>` with encrypted secret as subprotocol
5. **Server:** `WebSocketClientTransport` validates the encrypted secret, accepts connection
6. **Hot Reload:** File change → delta compiled → sent over WebSocket → applied on device

## iOS

Similar changes will be made in the iOS workload to opt into WebSocket-based hot reload:

- Add `<ProjectCapability Include="HotReloadWebSockets" />`
- Handle startup hooks and port forwarding similar to Android

## Dependencies

- **[runtime#123964](dotnet/runtime#123964 [mono] read `$DOTNET_STARTUP_HOOKS` — needed for Mono runtime to honor startup hooks (temporary workaround via `RuntimeHostConfigurationOption`)

Co-authored-by: Tomas Matousek <tomat@microsoft.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants