-
Notifications
You must be signed in to change notification settings - Fork 1.2k
[dotnet-watch] http transport for mobile #52581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jonathanpeppers
merged 14 commits into
dotnet:release/10.0.3xx
from
jonathanpeppers:dev/peppers/watch-http-transport
Feb 20, 2026
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
2366820
Initial implementation / spike
jonathanpeppers 64e7077
Transport abstraction
tmat 81ccc56
MobileAppModel
tmat 2721719
Updated implementation
jonathanpeppers 9af2d80
Add RSA WebSocket auth for mobile hot reload
jonathanpeppers 31f6a44
Address code review comments
jonathanpeppers 3eae972
Code Review Feedback - Part 1
jonathanpeppers 7798648
Unify `MobileHotReloadClient` and `DefaultHotReloadClient` behind `Cl…
jonathanpeppers af2ea2e
Reuse MemoryStream buffers
jonathanpeppers c0f5f7b
Refactor `KestrelWebSocketServer` for composition
jonathanpeppers e45c9e3
Address review: seal KestrelWebSocketServer, merge AgentWebSocketServ…
jonathanpeppers dc55512
No need for `decryptedSecret`
jonathanpeppers 37f14c8
Use WebUtility.UrlEncode/UrlDecode for WebSocket subprotocol secret e…
jonathanpeppers 98e735e
Address review: async factory, invert if, shared GetWebSocketUrl
jonathanpeppers File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>` | ||
jonathanpeppers marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| - 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`) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
43 changes: 43 additions & 0 deletions
43
src/BuiltInTools/HotReloadAgent.Host/NamedPipeTransport.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.