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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions documentation/specs/dotnet-watch-for-maui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# `dotnet watch` for .NET MAUI Scenarios

## Overview

This spec describes how `dotnet watch` provides 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 will 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 ([dotnet/sdk#52581](https://github.com/dotnet/sdk/pull/52581))

### WebSocket Details

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

- Hosts via Kestrel on `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) |

The mobile transport (`WebSocketClientTransport`) composes a sealed `KestrelWebSocketServer` and speaks the same binary protocol as the named pipe transport, just over WebSocket instead.

### 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 Base64-encoded (URL-safe: `-` for `+`, `_` for `/`, no padding) and sent as the WebSocket subprotocol header
5. **Server validates:** `WebSocketClientTransport.HandleRequestAsync` decrypts the subprotocol value and accepts the connection only if decryption succeeds

This ensures only processes that received the public key via the environment variable can connect. The URL-safe Base64 encoding is required because WebSocket subprotocol tokens cannot contain `+`, `/`, or `=` characters.

**Environment variables:**
- `DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT` — WebSocket URL (e.g., `ws://127.0.0.1:5432`)
- `DOTNET_WATCH_HOTRELOAD_WEBSOCKET_KEY` — RSA public key (Base64-encoded)

### 1. WebSocket Capability Detection

[ProjectGraphUtilities.cs](../../src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs) checks for the `HotReloadWebSockets` capability.

### 2. MobileAppModel

Creates a `DefaultHotReloadClient` with a `WebSocketClientTransport` instead of the default `NamedPipeClientTransport`.

### 3. 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.

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

## Android Workload Changes (Example Integration)

### [dotnet/android#10770](https://github.com/dotnet/android/pull/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](https://github.com/dotnet/android/pull/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:
- 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](https://github.com/dotnet/runtime/pull/123964):** [mono] read `$DOTNET_STARTUP_HOOKS` — needed for Mono runtime to honor startup hooks (temporary workaround via `RuntimeHostConfigurationOption`)
12 changes: 12 additions & 0 deletions src/BuiltInTools/HotReloadAgent.Data/AgentEnvironmentVariables.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ internal static class AgentEnvironmentVariables
/// </summary>
public const string DotNetWatchHotReloadNamedPipeName = "DOTNET_WATCH_HOTRELOAD_NAMEDPIPE_NAME";

/// <summary>
/// WebSocket endpoint for hot reload communication. Used for mobile platforms (Android, iOS)
/// where named pipes don't work over the network.
/// </summary>
public const string DotNetWatchHotReloadWebSocketEndpoint = "DOTNET_WATCH_HOTRELOAD_WEBSOCKET_ENDPOINT";

/// <summary>
/// RSA public key (Base64-encoded X.509 SubjectPublicKeyInfo) for WebSocket connection authentication.
/// The client encrypts a random secret with this key and sends it as the WebSocket subprotocol.
/// </summary>
public const string DotNetWatchHotReloadWebSocketKey = "DOTNET_WATCH_HOTRELOAD_WEBSOCKET_KEY";

/// <summary>
/// Enables logging from the client delta applier agent.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@
#nullable enable

using System;
using System.Diagnostics;
using System.IO.Pipes;
using System.Reflection;
using System.Runtime.Loader;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.DotNet.HotReload;

internal sealed class PipeListener(string pipeName, IHotReloadAgent agent, Action<string> log, int connectionTimeoutMS = 5000)
internal sealed class Listener(Transport transport, IHotReloadAgent agent, Action<string> log)
{
/// <summary>
/// Messages to the client sent after the initial <see cref="ClientInitializationResponse"/> is sent
Expand All @@ -23,9 +20,6 @@ internal sealed class PipeListener(string pipeName, IHotReloadAgent agent, Actio
/// </summary>
private readonly SemaphoreSlim _messageToClientLock = new(initialCount: 1);

// Not-null once initialized:
private NamedPipeClientStream? _pipeClient;

public Task Listen(CancellationToken cancellationToken)
{
// Connect to the pipe synchronously.
Expand All @@ -36,20 +30,7 @@ public Task Listen(CancellationToken cancellationToken)
//
// Updates made before the process is launched need to be applied before loading the affected modules.

log($"Connecting to hot-reload server via pipe {pipeName}");

_pipeClient = new NamedPipeClientStream(serverName: ".", pipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous);
try
{
_pipeClient.Connect(connectionTimeoutMS);
log("Connected.");
}
catch (TimeoutException)
{
log($"Failed to connect in {connectionTimeoutMS}ms.");
_pipeClient.Dispose();
return Task.CompletedTask;
}
log($"Connecting to Hot Reload server via {transport.DisplayName}.");

try
{
Expand All @@ -63,7 +44,7 @@ public Task Listen(CancellationToken cancellationToken)
log(e.Message);
}

_pipeClient.Dispose();
transport.Dispose();
agent.Dispose();

return Task.CompletedTask;
Expand All @@ -81,20 +62,17 @@ public Task Listen(CancellationToken cancellationToken)
}
finally
{
_pipeClient.Dispose();
transport.Dispose();
agent.Dispose();
}
}, cancellationToken);
}

private async Task InitializeAsync(CancellationToken cancellationToken)
{
Debug.Assert(_pipeClient != null);

agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose);

var initPayload = new ClientInitializationResponse(agent.Capabilities);
await initPayload.WriteAsync(_pipeClient, cancellationToken);
await transport.SendAsync(new ClientInitializationResponse(agent.Capabilities), cancellationToken);

// Apply updates made before this process was launched to avoid executing unupdated versions of the affected modules.

Expand All @@ -106,19 +84,23 @@ private async Task InitializeAsync(CancellationToken cancellationToken)

private async Task ReceiveAndApplyUpdatesAsync(bool initialUpdates, CancellationToken cancellationToken)
{
Debug.Assert(_pipeClient != null);

while (_pipeClient.IsConnected)
while (!cancellationToken.IsCancellationRequested)
{
var payloadType = (RequestType)await _pipeClient.ReadByteAsync(cancellationToken);
using var request = await transport.ReceiveAsync(cancellationToken);
if (request.Stream == null)
{
break;
}

var payloadType = (RequestType)await request.Stream.ReadByteAsync(cancellationToken);
switch (payloadType)
{
case RequestType.ManagedCodeUpdate:
await ReadAndApplyManagedCodeUpdateAsync(cancellationToken);
await ReadAndApplyManagedCodeUpdateAsync(request.Stream, cancellationToken);
break;

case RequestType.StaticAssetUpdate:
await ReadAndApplyStaticAssetUpdateAsync(cancellationToken);
await ReadAndApplyStaticAssetUpdateAsync(request.Stream, cancellationToken);
break;

case RequestType.InitialUpdatesCompleted when initialUpdates:
Expand All @@ -131,11 +113,9 @@ private async Task ReceiveAndApplyUpdatesAsync(bool initialUpdates, Cancellation
}
}

private async ValueTask ReadAndApplyManagedCodeUpdateAsync(CancellationToken cancellationToken)
private async ValueTask ReadAndApplyManagedCodeUpdateAsync(Stream stream, CancellationToken cancellationToken)
{
Debug.Assert(_pipeClient != null);

var request = await ManagedCodeUpdateRequest.ReadAsync(_pipeClient, cancellationToken);
var request = await ManagedCodeUpdateRequest.ReadAsync(stream, cancellationToken);

bool success;
try
Expand All @@ -155,11 +135,9 @@ private async ValueTask ReadAndApplyManagedCodeUpdateAsync(CancellationToken can
await SendResponseAsync(new UpdateResponse(logEntries, success), cancellationToken);
}

private async ValueTask ReadAndApplyStaticAssetUpdateAsync(CancellationToken cancellationToken)
private async ValueTask ReadAndApplyStaticAssetUpdateAsync(Stream stream, CancellationToken cancellationToken)
{
Debug.Assert(_pipeClient != null);

var request = await StaticAssetUpdateRequest.ReadAsync(_pipeClient, cancellationToken);
var request = await StaticAssetUpdateRequest.ReadAsync(stream, cancellationToken);

try
{
Expand All @@ -181,12 +159,10 @@ private async ValueTask ReadAndApplyStaticAssetUpdateAsync(CancellationToken can
internal async ValueTask SendResponseAsync<T>(T response, CancellationToken cancellationToken)
where T : IResponse
{
Debug.Assert(_pipeClient != null);
try
{
await _messageToClientLock.WaitAsync(cancellationToken);
await _pipeClient.WriteAsync((byte)response.Type, cancellationToken);
await response.WriteAsync(_pipeClient, cancellationToken);
await transport.SendAsync(response, cancellationToken);
}
finally
{
Expand Down
43 changes: 43 additions & 0 deletions src/BuiltInTools/HotReloadAgent.Host/NamedPipeTransport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable enable

using System;
using System.IO.Pipes;
using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.DotNet.HotReload;

internal sealed class NamedPipeTransport(string pipeName, Action<string> log, int timeoutMS) : Transport(log)
{
private readonly NamedPipeClientStream _pipeClient = new(serverName: ".", pipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous);

public override void Dispose()
=> _pipeClient.Dispose();

public override string DisplayName
=> $"pipe {pipeName}";

public override async ValueTask SendAsync(IResponse response, CancellationToken cancellationToken)
{
if (response.Type == ResponseType.InitializationResponse)
{
try
{
_pipeClient.Connect(timeoutMS);
}
catch (TimeoutException)
{
throw new TimeoutException($"Failed to connect in {timeoutMS}ms.");
}
}

await _pipeClient.WriteAsync((byte)response.Type, cancellationToken);
await response.WriteAsync(_pipeClient, cancellationToken);
}

public override ValueTask<RequestStream> ReceiveAsync(CancellationToken cancellationToken)
=> new(new RequestStream(_pipeClient.IsConnected ? _pipeClient : null, disposeOnCompletion: false));
}
13 changes: 6 additions & 7 deletions src/BuiltInTools/HotReloadAgent.Host/StartupHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Runtime.Loader;
using System.Threading;
Expand All @@ -21,7 +19,6 @@
internal sealed class StartupHook
{
private static readonly string? s_standardOutputLogPrefix = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.HotReloadDeltaClientLogMessages);
private static readonly string? s_namedPipeName = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName);
private static readonly bool s_supportsConsoleColor = !OperatingSystem.IsAndroid()
&& !OperatingSystem.IsIOS()
&& !OperatingSystem.IsTvOS()
Expand All @@ -42,17 +39,19 @@ public static void Initialize()

Log($"Loaded into process: {processPath} ({typeof(StartupHook).Assembly.Location})");

var transport = Transport.TryCreate(Log);

HotReloadAgent.ClearHotReloadEnvironmentVariables(typeof(StartupHook));

if (string.IsNullOrEmpty(s_namedPipeName))
if (transport == null)
{
Log($"Environment variable {AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName} has no value");
Log($"No hot reload endpoint configured. Set {AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName} or {AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint}");
return;
}

RegisterSignalHandlers();

PipeListener? listener = null;
Listener? listener = null;

var agent = new HotReloadAgent(
assemblyResolvingHandler: (_, args) =>
Expand Down Expand Up @@ -94,7 +93,7 @@ async Task SendAndForgetAsync()
}
});

listener = new PipeListener(s_namedPipeName, agent, Log);
listener = new Listener(transport, agent, Log);

// fire and forget:
_ = listener.Listen(CancellationToken.None);
Expand Down
Loading
Loading