Merge branch 'feature/secure-connection' of https://github.com/ElectronNET/Electron.NET into feature/secure-connection

This commit is contained in:
Florian Rappl
2026-03-02 21:59:19 +01:00
27 changed files with 87 additions and 1720 deletions

View File

@@ -1,450 +0,0 @@
# SignalR Implementation Summary
This document summarizes the completed implementation of SignalR-based bidirectional communication in Electron.NET as an alternative to Socket.IO.
## Overview
The SignalR implementation provides a modern, .NET-native alternative to Socket.IO for communication between the ASP.NET Core host and the Electron process. This new startup mode was designed specifically for **Blazor Server applications** where ASP.NET Core and Electron need tighter integration and lifecycle control.
**Key Innovation**: .NET-first startup with dynamic port assignment - ASP.NET Core starts first, binds to port 0 (letting Kestrel choose an available port), then launches Electron with the actual URL.
## Primary Use Case
Blazor Server applications where:
- ASP.NET Core owns the application lifecycle
- Dynamic port binding is needed (no fixed port configuration)
- Modern SignalR infrastructure is preferred over Socket.IO
- Single process debugging is desired (.NET process controls Electron)
## Implementation Phases (All Complete)
### Phase 1: Core Infrastructure ✅
- Added new `StartupMethod` enum values:
- `UnpackagedDotnetFirstSignalR`
- `PackagedDotnetFirstSignalR`
- Created `ElectronHub` SignalR hub for bidirectional communication
- Registered hub endpoint at `/electron-hub` (separate from Blazor's `/_blazor` hub)
### Phase 2: Runtime Controller ✅
- Created `RuntimeControllerAspNetDotnetFirstSignalR`
- Implemented logic to:
- Bind Kestrel to port 0
- Wait for Kestrel startup and capture actual port via `IServerAddressesFeature`
- Launch Electron with `--electronurl` parameter
- Wait for SignalR connection from Electron
- Transition to Ready state when connected
### Phase 3: Electron/Node.js Side ✅
- Added `@microsoft/signalr` npm package dependency
- Created SignalR connection module (`signalr-bridge.js`)
- Updated `main.js` to detect SignalR modes and connect to `/electron-hub`
- Implemented Socket.IO-compatible interface for API compatibility
### Phase 4: API Bridge Adaptation ✅
- Created `SignalRConnection` implementing `ISocketConnection` interface
- Ensured existing Electron API classes work with SignalR
- Implemented type conversion helper for SignalR's JSON deserialization
- Event routing from both directions (.NET ↔ Electron)
### Phase 5: Configuration & Extensions ✅
- Updated `WebHostBuilderExtensions` for automatic SignalR configuration
- Added startup mode detection via command-line flags
- Configured dynamic port binding (port 0) for SignalR modes
- Integrated with `UseElectron()` API for seamless usage
### Phase 6: Testing & Fixes ✅
- Created sample Blazor Server application
- Fixed multiple critical issues discovered during integration testing
- Cleaned up debug logging
- Added comprehensive code documentation
## Key Components
### 1. SignalRConnection (`src/ElectronNET.AspNet/Bridge/SignalRConnection.cs`)
- Implements `ISocketConnection` interface to match Socket.IO facade API
- Handles bidirectional event routing using `IHubContext<ElectronHub>`
- Includes `ConvertToType<T>` helper for handling SignalR's JSON deserialization quirks
- Critical fix: Handles `JsonElement` and numeric type conversions (long → int)
### 2. ElectronHub (`src/ElectronNET.AspNet/Hubs/ElectronHub.cs`)
- SignalR hub for .NET ↔ Electron communication
- Key methods:
- `RegisterElectronClient()` - Called by Electron on connection
- `ElectronEvent(string, object[])` - Receives events from Electron
- Connection/disconnection handlers notify runtime controller
### 3. RuntimeControllerAspNetDotnetFirstSignalR
- Manages SignalR mode lifecycle
- Critical flow:
1. Wait for ASP.NET server to start
2. Capture dynamic port from `IServerAddressesFeature`
3. Update `ElectronNetRuntime.AspNetWebPort` with actual port
4. Launch Electron with `--electronurl` parameter
5. Wait for `electron-host-ready` signal before calling app ready callback
### 4. SignalRBridge (`src/ElectronNET.Host/api/signalr-bridge.js`)
- JavaScript SignalR client that mimics Socket.IO interface
- Provides `on()` and `emit()` methods for API compatibility
- Critical fix: Event args passed as arrays, spread when calling handlers
- Uses `@microsoft/signalr` npm package
### 5. Main.js Startup (`src/ElectronNET.Host/main.js`)
- Detects SignalR mode via `--unpackeddotnetsignalr` or `--dotnetpackedsignalr` flags
- Creates invisible keep-alive window (destroyed when first real window is created)
- Loads API modules then signals `electron-host-ready` to .NET
## Usage
Enable SignalR mode by passing the appropriate command-line flag:
```bash
# Unpacked mode (development)
dotnet run --unpackeddotnetsignalr
# Packed mode (production)
dotnet run --dotnetpackedsignalr
```
Or set environment variable (deprecated, flags preferred):
```bash
ELECTRON_USE_SIGNALR=true
```
In your ASP.NET Core Program.cs:
```csharp
var builder = WebApplication.CreateBuilder(args);
// Add Electron.NET services
builder.Services.AddElectron();
// Configure Electron with SignalR mode
builder.WebHost.UseElectron(args, async () =>
{
var window = await Electron.WindowManager.CreateWindowAsync();
window.OnReadyToShow += () => window.Show();
});
var app = builder.Build();
app.UseRouting();
// Map SignalR hub for Electron communication
app.MapHub<ElectronHub>("/electron-hub");
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
```
**Note**: `UseElectron()` automatically detects SignalR mode and configures everything. The rest happens automatically:
1. Port 0 binding (dynamic port assignment)
2. Electron launch with actual URL
3. SignalR connection establishment (WebSockets enabled automatically by MapHub)
4. App ready callback execution
2. Electron launch with actual URL
3. SignalR connection establishment (WebSockets enabled automatically by `MapHub`)
4. App ready callback execution
## Architecture Decisions
### Why .NET-First Startup?
SignalR mode uses .NET-first startup (vs. Electron-first in Socket.IO mode) because:
1. **No port scanning needed** - .NET can pass the actual URL to Electron
2. **SignalR hub must be registered** before Electron connects
3. **Simpler lifecycle** - ASP.NET controls when Electron launches
4. **Better for Blazor Server** - Blazor is already running when Electron starts
5. **Single process debugging** - Developer debugs .NET process which owns Electron
### Why ISocketConnection Interface?
Introducing `ISocketConnection` allows `BridgeConnector.Socket` to return either `SocketIOConnection` or `SignalRConnection` based on startup mode, ensuring existing API code works with both transport mechanisms without modification.
### Why Keep-Alive Window?
Electron quits immediately on macOS if no windows exist. The keep-alive window ensures Electron stays running during the connection and API initialization phase. It's automatically destroyed when the first real window is created.
### Why 'electron-host-ready' Signal?
Without this signal, .NET would call the app ready callback before Electron finished loading API modules, causing API calls to fail. The signal ensures proper initialization order:
1. Electron connects to SignalR
2. Electron loads all API modules (browserWindows, dialog, menu, etc.)
3. Electron signals `electron-host-ready`
4. .NET calls app ready callback
5. App code can safely use Electron APIs
## Blazor Server Considerations
### SignalR Hub Coexistence
Blazor Server already uses SignalR for component communication (`/_blazor` hub). Our implementation:
- Uses separate endpoint (`/electron-hub`) to avoid conflicts
- Both hubs coexist on the same Kestrel server without interference
- No impact on Blazor's reconnection logic
- Compatible with hot reload scenarios
### Lifecycle Integration
- Electron window creation happens **after** Blazor app is ready
- `UseElectron()` callback fires when SignalR hub is connected
- Blazor components can inject Electron services to control windows
- Proper disposal when Electron process exits
### Development Experience
- Hot reload works for both Blazor and Electron integration
- F5 debugging works seamlessly
- No need to manually coordinate ports
- Single process to debug (.NET process owns the lifecycle)
## Critical Fixes Applied
### 1. Race Condition: API Module Loading
**Problem**: .NET called app ready callback before Electron finished loading API modules.
**Solution**: Electron signals `electron-host-ready` after loading all API modules. .NET waits for this signal before calling the app ready callback.
### 2. Event Argument Mismatch
**Problem**: SignalR sent event data as nested arrays `[[data]]` instead of `[data]`.
**Solution**:
- C#: Use explicit `object[] args` parameter (not `params`)
- JS: Always pass args as array: `invoke('ElectronEvent', eventName, args)`
- JS: Spread args when calling handlers: `handler(...argsArray)`
### 3. Type Conversion Failures
**Problem**: SignalR deserializes JSON numbers as `JsonElement` or `long`, causing `Once<int>` handlers to fail silently.
**Solution**: `SignalRConnection.ConvertToType<T>` handles JsonElement deserialization and numeric conversions.
### 4. Window Shutdown Not Triggering Exit
**Problem**: Keep-alive window prevented `window-all-closed` event from firing.
**Solution**: Destroy keep-alive window when first real window is created using `app.once('browser-window-created')`.
### 5. Dynamic Port Not Propagated
**Problem**: When using port 0, Kestrel assigns a dynamic port, but `ElectronNetRuntime.AspNetWebPort` was not updated.
**Solution**: Update `AspNetWebPort` after capturing port from `IServerAddressesFeature` in `CapturePortAndLaunchElectron()`.
### 7. Blazor Static Files Not Loading
**Problem**: Blazor CSS and framework files returned 404 errors.
**Solution**:
- Added `app.UseStaticFiles()` to serve wwwroot content
- Fixed middleware order: `UseAntiforgery()` must be between `UseRouting()` and `UseEndpoints()`
- Updated scoped CSS asset reference to use lowercase name matching .NET 9+ convention
## Authentication & Security
### Token-Based Authentication (Multi-User Protection)
SignalR mode includes built-in authentication to prevent unauthorized connections in multi-user scenarios (e.g., Windows Server with Terminal Services/RDP).
**Threat Model**: On shared servers, multiple users can run the same application simultaneously. Without authentication, User A's Electron process could potentially connect to User B's ASP.NET backend.
**Solution**: Token-based authentication with secure cookies.
### Authentication Flow
1. **.NET generates token**: When launching Electron, `RuntimeControllerAspNetDotnetFirstSignalR` generates a cryptographically secure GUID (128-bit entropy)
2. **Token passed via command-line**: Electron receives `--authtoken=<guid>` parameter
3. **Token appended to URLs**:
- Initial page load: `http://localhost:PORT/?token=<guid>`
- SignalR connection: `http://localhost:PORT/electron-hub?token=<guid>`
4. **Middleware validates token**: `ElectronAuthenticationMiddleware` checks every HTTP request
5. **Cookie set on first request**: After successful token validation, secure HttpOnly cookie is set
6. **Subsequent requests use cookie**: No token in URLs after initial authentication
### Security Properties
- **Cookie Settings**:
- `HttpOnly`: true (prevents JavaScript access, XSS protection)
- `SameSite`: Strict (prevents CSRF)
- `Path`: / (applies to all routes)
- `Secure`: false (localhost is HTTP, not HTTPS)
- `IsEssential`: true (required for app to function)
- **Lifetime**: Session scope (expires when Electron closes)
- **Token Validation**:
- Constant-time string comparison (prevents timing attacks)
- Token stored in singleton service (one per .NET instance)
- Never logged in full (only first 8 characters for debugging)
- **Protection Scope**:
- All HTTP endpoints (Blazor pages, static files, API calls)
- SignalR hub connection (negotiate and all hub traffic)
- Both initial request and cookie-based requests validated
### What This Protects Against
**Protected**:
- Cross-user connections (User A → User B's backend)
- Port scanning attacks from other users
- Accidental connections from misconfigured processes
**NOT Protected Against** (By Design):
- Malicious same-user processes with debugger access
- Process memory inspection tools (same privilege level)
- Command-line parameter visibility (same user can see all processes)
**Rationale**: Same-user attacks already have full access to process memory, files, and cookies. Token-based authentication focuses on cross-user isolation, which is the primary threat in multi-user environments.
### Implementation Components
1. **IElectronAuthenticationService** (`src/ElectronNET.AspNet/Services/`)
- Singleton service storing expected token
- Thread-safe with lock-based validation
- Constant-time comparison to prevent timing attacks
2. **ElectronAuthenticationMiddleware** (`src/ElectronNET.AspNet/Middleware/`)
- Validates every HTTP request before routing
- Checks cookie first, then token query parameter
- Sets cookie on first valid token
- Returns 401 for invalid/missing authentication
- Structured logging for security monitoring
3. **Token Generation** (`RuntimeControllerAspNetDotnetFirstSignalR.cs`)
- `Guid.NewGuid().ToString("N")` = 32 hex characters
- Called in `LaunchElectron()` method
- Registered with authentication service immediately
4. **Electron Integration** (`main.js`, `signalr-bridge.js`)
- Extracts token from `--authtoken` parameter
- Stores in `global.authToken` for module access
- Appends to browser window URL and SignalR connection URL
### Usage in Custom Applications
Authentication is **enabled by default** in SignalR mode. No additional configuration required beyond service registration:
```csharp
var builder = WebApplication.CreateBuilder(args);
// Register authentication service (singleton)
builder.Services.AddSingleton<IElectronAuthenticationService, ElectronAuthenticationService>();
builder.Services.AddElectron();
var app = builder.Build();
// Register middleware BEFORE UseRouting()
app.UseMiddleware<ElectronAuthenticationMiddleware>();
app.UseRouting();
app.MapHub<ElectronHub>("/electron-hub");
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
```
The rest is automatic:
- Token generation happens when Electron launches
- Token validation happens on every request
- Cookie management is handled by the middleware
### Logging & Monitoring
Authentication events are logged with structured logging:
**Successful authentication**:
```
[Information] Authentication successful: Setting cookie for path /
```
**Failed authentication**:
```
[Warning] Authentication failed: Invalid token (prefix: a3f8b2c1...) for path / from 127.0.0.1
[Warning] Authentication failed: No cookie or token provided for path /api/data from 127.0.0.1
[Warning] Authentication failed: Invalid cookie for path /_blazor from 127.0.0.1
```
**SignalR connection failures**:
```
[SignalRBridge] Authentication failed: The authentication token is invalid or missing.
[SignalRBridge] Please ensure the --authtoken parameter is correctly passed to Electron.
```
Log failed authentication attempts for security monitoring and troubleshooting.
### Testing Multi-User Scenarios
To test authentication in multi-user environments:
1. **Run as different Windows users**:
```powershell
# User A session
dotnet run
# User B session (different RDP/Terminal Services session)
dotnet run
```
2. **Verify isolation**: User A's Electron cannot access User B's backend
3. **Check logs**: Failed auth attempts should be logged
4. **Monitor tokens**: Each instance generates unique token
For development testing on single-user machines, simulate by running multiple instances and attempting to connect with wrong/missing tokens.
## Backward Compatibility
This is a **new optional startup mode** - all existing modes continue to work unchanged:
- `PackagedElectronFirst` - unchanged
- `PackagedDotnetFirst` - unchanged
- `UnpackedElectronFirst` - unchanged
- `UnpackedDotnetFirst` - unchanged
Existing applications do not need to change. SignalR mode is opt-in via command-line flags.
## File Changes Summary
**New Files**:
- `src/ElectronNET.AspNet/Bridge/SignalRConnection.cs` (225 lines)
- `src/ElectronNET.AspNet/Hubs/ElectronHub.cs` (108 lines)
- `src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirstSignalR.cs` (163 lines)
- `src/ElectronNET.AspNet/Services/IElectronAuthenticationService.cs` (20 lines)
- `src/ElectronNET.AspNet/Services/ElectronAuthenticationService.cs` (65 lines)
- `src/ElectronNET.AspNet/Middleware/ElectronAuthenticationMiddleware.cs` (105 lines)
- `src/ElectronNET.Host/api/signalr-bridge.js` (125 lines)
**Modified Files**:
- `src/ElectronNET.AspNet/API/WebHostBuilderExtensions.cs` - Added SignalR service registration
- `src/ElectronNET.Host/main.js` - Added SignalR startup flow and token extraction
- `src/ElectronNET.Host/api/browserWindows.js` - Token appended to window URLs
- `src/ElectronNET.Host/package.json` - Added `@microsoft/signalr` dependency
- `src/ElectronNET.Samples.BlazorSignalR/Program.cs` - Sample with authentication
**Total Changes**: ~1,220 lines added
## Testing Recommendations
1. Test with dynamic port (port 0) to ensure URL propagation works
2. Verify window-all-closed triggers app exit
3. Test rapid window creation/destruction
4. Verify reconnection behavior if SignalR connection drops
5. Test with both packed and unpacked modes
6. Verify API calls work correctly (especially those returning data)
## Known Limitations
1. **Request-response pattern not yet implemented** - `InvokeElectronApi` is a placeholder. Current API calls use event-based pattern.
2. **TouchBar API not yet supported** on macOS SignalR mode
3. **SignalR automatic reconnection** may cause issues with pending API calls (needs circuit breaker pattern)
## Future Enhancements
1. **Request-response pattern** - Implement proper async/await pattern for API calls that return values
2. **Metrics/diagnostics** - Add SignalR connection health monitoring
3. **Circuit breaker** - Handle reconnection scenarios gracefully
4. **Integration tests** - Comprehensive test suite for SignalR mode
5. **Performance benchmarks** - Compare SignalR vs Socket.IO performance
## Success Metrics
The implementation is considered complete and functional:
- ✅ .NET starts first with dynamic port (port 0)
- ✅ Electron launches with actual URL
- ✅ SignalR connection establishes successfully
- ✅ API modules load before app ready callback
- ✅ Window creation works from .NET
- ✅ Window shutdown triggers app exit
- ✅ Blazor Server pages load with correct styling
- ✅ Both SignalR hubs coexist (Electron + Blazor)
- ✅ Clean codebase with minimal debug logging
- ✅ Comprehensive inline documentation
- ✅ Token-based authentication for multi-user scenarios
- ✅ Secure cookie-based session management
- ✅ Structured logging for security monitoring
- ✅ Protection against cross-user connection attempts

View File

@@ -1,236 +0,0 @@
# SignalR-Based Startup Mode for Electron.NET
## Overview
This feature adds a new startup mode for Electron.NET where:
- **.NET/ASP.NET Core starts first** and binds to port 0 (dynamic port)
- **Kestrel picks an available port** automatically
- **Electron process is launched** with the actual URL
- **SignalR is used for communication** instead of socket.io
- **Blazor Server apps** can coexist with Electron control
## Status
**Phases 1-5 Complete** - Infrastructure ready, basic functionality implemented
⏸️ **Phase 6 Pending** - Full API integration, testing, and documentation
## How It Works
### Startup Sequence
1. ASP.NET Core application starts
2. Kestrel binds to `http://localhost:0` (random available port)
3. `RuntimeControllerAspNetDotnetFirstSignalR` captures the actual port via `IServerAddressesFeature`
4. Electron process is launched with `--electronUrl=http://localhost:XXXXX`
5. Electron's main.js detects SignalR mode (via `--dotnetpackedsignalr` or `--unpackeddotnetsignalr` flag)
6. Electron connects to SignalR hub at `/electron-hub`
7. Hub notifies runtime controller of successful connection
8. Application transitions to "Ready" state
9. `ElectronAppReady` callback is invoked
### Communication Flow
```
.NET/Kestrel (Port 0) ←→ SignalR Hub (/electron-hub) ←→ Electron Process
↓ ↓ ↓
Blazor Server ElectronHub class SignalR Client
(/_blazor hub) (API commands) (main.js + signalr-bridge.js)
```
## Usage
### 1. Enable SignalR Mode
Set the environment variable:
```bash
ELECTRON_USE_SIGNALR=true
```
Or in launchSettings.json:
```json
{
"environmentVariables": {
"ELECTRON_USE_SIGNALR": "true"
}
}
```
### 2. Configure ASP.NET Core
In your `Program.cs`:
```csharp
using ElectronNET.API;
using ElectronNET.API.Entities;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddElectron();
builder.UseElectron(args, async () =>
{
var window = await Electron.WindowManager.CreateWindowAsync(
new BrowserWindowOptions { Show = false });
window.OnReadyToShow += () => window.Show();
});
var app = builder.Build();
// Configure middleware
app.UseStaticFiles();
app.UseRouting();
// Map the Electron SignalR hub
app.MapElectronHub(); // ← Required for SignalR mode
app.MapRazorPages();
app.Run();
```
### 3. Run Your Application
Just press F5 in Visual Studio or run:
```bash
dotnet run
```
The application will:
- Automatically detect SignalR mode via environment variable
- Bind Kestrel to port 0
- Launch Electron with the correct URL
- Establish SignalR connection
## Components
### .NET Side
- **`ElectronHub`** - SignalR hub at `/electron-hub`
- **`SignalRConnection`** - Mimics `SocketIOConnection` interface for compatibility
- **`RuntimeControllerAspNetDotnetFirstSignalR`** - Lifecycle management
- **`StartupMethod.PackagedDotnetFirstSignalR`** - For packaged apps
- **`StartupMethod.UnpackedDotnetFirstSignalR`** - For debugging
### Electron Side
- **`signalr-bridge.js`** - SignalR client wrapper
- **`main.js`** - Detects SignalR mode and connects to hub
- **`@microsoft/signalr`** npm package
## Key Features
**Dynamic Port Assignment** - No hardcoded ports, no conflicts
**Blazor Server Compatible** - Separate hub endpoints (`/electron-hub` vs `/_blazor`)
**Bidirectional Communication** - Both .NET→Electron and Electron→.NET
**Hot Reload Support** - SignalR automatic reconnection
**Multiple Instances** - Each instance gets its own port
## Current Limitations (Phase 6 Work Needed)
⚠️ **Electron API Integration** - Existing Electron APIs (WindowManager, Dialog, etc.) still use SocketIOConnection. Full integration requires:
- Refactoring APIs to work with both facades, or
- Creating an adapter pattern
⚠️ **Request-Response Pattern** - Current hub methods are one-way. Need to implement proper async request-response for API calls.
⚠️ **Event Routing** - Electron events need to be routed through SignalR back to .NET.
⚠️ **Testing** - Integration tests needed to validate end-to-end functionality.
## What's Implemented
### Phase 1: Core Infrastructure ✅
- New `StartupMethod` enum values
- `ElectronHub` SignalR hub
- Hub endpoint registration
### Phase 2: Runtime Controller ✅
- `RuntimeControllerAspNetDotnetFirstSignalR`
- Port 0 binding logic
- Electron launch with URL parameter
- SignalR connection tracking
### Phase 3: Electron/Node.js Side ✅
- `@microsoft/signalr` package integration
- SignalR connection module
- Startup mode detection
- URL parameter handling
### Phase 4: API Bridge ✅ (Basic Structure)
- `SignalRConnection` class
- Event handler system
- Hub connection integration
### Phase 5: Configuration ✅
- Environment variable detection
- Port 0 configuration
- Automatic service registration
## Next Steps (Phase 6)
To fully utilize this feature, the following work is recommended:
1. **API Integration** - Make existing Electron APIs work with SignalR
2. **Sample Application** - Create a Blazor Server demo
3. **Integration Tests** - Validate end-to-end scenarios
4. **Documentation** - Complete user guides and examples
5. **Performance Testing** - Compare with socket.io mode
## Files Changed
### .NET
- `src/ElectronNET.API/Runtime/Data/StartupMethod.cs`
- `src/ElectronNET.AspNet/Hubs/ElectronHub.cs`
- `src/ElectronNET.AspNet/Bridge/SignalRConnection.cs`
- `src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirstSignalR.cs`
- `src/ElectronNET.AspNet/API/ElectronEndpointRouteBuilderExtensions.cs`
- `src/ElectronNET.AspNet/API/WebHostBuilderExtensions.cs`
- `src/ElectronNET.API/Runtime/StartupManager.cs`
### Electron/Node.js
- `src/ElectronNET.Host/package.json`
- `src/ElectronNET.Host/main.js`
- `src/ElectronNET.Host/api/signalr-bridge.js` (new file)
## Commits
```
7f2ea48 - Add PackagedDotnetFirstSignalR and UnpackedDotnetFirstSignalR startup methods
8ee81f6 - Add ElectronHub and SignalR infrastructure for new startup modes
40aed60 - Add RuntimeControllerAspNetDotnetFirstSignalR for SignalR-based startup
c1740b5 - Add SignalR client support to Electron Host for new startup modes
cb7d721 - Add SignalRConnection for SignalR-based API communication
268b9c9 - Update RuntimeControllerAspNetDotnetFirstSignalR to use SignalRConnection
04ec522 - Fix compilation errors - Phase 4 complete (basic structure)
054f5b1 - Complete Phase 5: Add SignalR startup detection and port 0 configuration
```
## Benefits Over Socket.io Mode
- **Better Integration** - Native SignalR is part of ASP.NET Core stack
- **Type Safety** - SignalR has better TypeScript support
- **Performance** - SignalR is optimized for ASP.NET Core
- **Reliability** - Built-in reconnection and error handling
- **Scalability** - Can leverage SignalR's scale-out features
- **Consistency** - Blazor Server already uses SignalR
## Contributing
To contribute to Phase 6 (full API integration):
1. Focus on adapting existing Electron API classes to work with SignalRConnection
2. Implement request-response pattern in ElectronHub
3. Add integration tests
4. Create sample applications
5. Update documentation
## License
MIT - Same as Electron.NET
---
**Created**: January 30, 2026
**Status**: Infrastructure Complete, API Integration Pending
**Contact**: See Electron.NET maintainers

View File

@@ -4,7 +4,7 @@ namespace ElectronNET.API.Bridge
using System.Threading.Tasks;
/// <summary>
/// Common interface for communication facades (SocketIO and SignalR).
/// Common interface for communication facades.
/// Provides methods for bidirectional communication between .NET and Electron.
/// </summary>
internal interface ISocketConnection : IDisposable

View File

@@ -3,11 +3,13 @@
namespace ElectronNET.API;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ElectronNET.API.Bridge;
using ElectronNET.API.Serialization;
using SocketIO.Serializer.SystemTextJson;
using SocketIO = SocketIOClient.SocketIO;
using SocketIOOptions = SocketIOClient.SocketIOOptions;
internal class SocketIOConnection : ISocketConnection
{
@@ -15,9 +17,15 @@ internal class SocketIOConnection : ISocketConnection
private readonly object _lockObj = new object();
private bool _isDisposed;
public SocketIOConnection(string uri)
public SocketIOConnection(string uri, string authorization)
{
_socket = new SocketIO(uri);
_socket = new SocketIO(uri, new SocketIOOptions
{
ExtraHeaders = new Dictionary<string, string>
{
["authorization"] = authorization
},
});
_socket.Serializer = new SystemTextJsonSerializer(ElectronJson.Options);
// Use default System.Text.Json serializer from SocketIOClient.
// Outgoing args are normalized to camelCase via SerializeArg in Emit.

View File

@@ -14,7 +14,6 @@ namespace ElectronNET.Common
{
case StartupMethod.UnpackedElectronFirst:
case StartupMethod.UnpackedDotnetFirst:
case StartupMethod.UnpackedDotnetFirstSignalR:
return true;
default:
return false;

View File

@@ -26,6 +26,8 @@
private readonly StringBuilder stdOut = new StringBuilder(4 * 1024);
private readonly StringBuilder stdErr = new StringBuilder(4 * 1024);
public event EventHandler<string> LineReceived;
private volatile ManualResetEvent stdOutEvent;
private volatile ManualResetEvent stdErrEvent;
private volatile Stopwatch stopwatch;
@@ -571,6 +573,7 @@
if (e.Data != null)
{
Console.WriteLine("|| " + e.Data);
LineReceived?.Invoke(this, e.Data);
}
else
{

View File

@@ -14,7 +14,6 @@
{
private ElectronProcessBase electronProcess;
private SocketBridgeService socketBridge;
private int? port;
public RuntimeControllerDotNetFirst()
{
@@ -42,19 +41,13 @@
var isUnPacked = ElectronNetRuntime.StartupMethod.IsUnpackaged();
var electronBinaryName = ElectronNetRuntime.ElectronExecutable;
var args = string.Format("{0} {1}", ElectronNetRuntime.ElectronExtraArguments, Environment.CommandLine).Trim();
this.port = ElectronNetRuntime.ElectronSocketPort;
if (!this.port.HasValue)
{
this.port = PortHelper.GetFreePort(ElectronNetRuntime.DefaultSocketPort);
ElectronNetRuntime.ElectronSocketPort = this.port;
}
var port = ElectronNetRuntime.ElectronSocketPort ?? 0;
Console.Error.WriteLine("[StartCore]: isUnPacked: {0}", isUnPacked);
Console.Error.WriteLine("[StartCore]: electronBinaryName: {0}", electronBinaryName);
Console.Error.WriteLine("[StartCore]: args: {0}", args);
this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, this.port.Value);
this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, port);
this.electronProcess.Ready += this.ElectronProcess_Ready;
this.electronProcess.Stopped += this.ElectronProcess_Stopped;
@@ -64,8 +57,9 @@
private void ElectronProcess_Ready(object sender, EventArgs e)
{
var port = ElectronNetRuntime.ElectronSocketPort.Value;
this.TransitionState(LifetimeState.Started);
this.socketBridge = new SocketBridgeService(this.port!.Value);
this.socketBridge = new SocketBridgeService(port, "");
this.socketBridge.Ready += this.SocketBridge_Ready;
this.socketBridge.Stopped += this.SocketBridge_Stopped;
this.socketBridge.Start();

View File

@@ -49,7 +49,7 @@
}
this.TransitionState(LifetimeState.Starting);
this.socketBridge = new SocketBridgeService(this.port!.Value);
this.socketBridge = new SocketBridgeService(this.port!.Value, "");
this.socketBridge.Ready += this.SocketBridge_Ready;
this.socketBridge.Stopped += this.SocketBridge_Stopped;
this.socketBridge.Start();

View File

@@ -33,23 +33,5 @@
/// On the command lines, this is "unpackeddotnet"
/// </remarks>
UnpackedDotnetFirst,
/// <summary>Packaged Electron app where DotNet launches Electron and uses SignalR for communication.</summary>
/// <remarks>
/// DotNet starts first on port 0 (dynamic), launches Electron with the actual URL,
/// and uses SignalR instead of socket.io for bidirectional communication.
/// Optimized for Blazor Server scenarios. ASP.NET Core only.
/// On the command lines, this is "dotnetpackedsignalr"
/// </remarks>
PackagedDotnetFirstSignalR,
/// <summary>Unpackaged execution where DotNet launches Electron and uses SignalR for communication.</summary>
/// <remarks>
/// Similar to PackagedDotnetFirstSignalR but for debugging scenarios.
/// DotNet starts first on port 0 (dynamic), launches Electron with the actual URL,
/// and uses SignalR instead of socket.io for bidirectional communication.
/// On the command lines, this is "unpackeddotnetsignalr"
/// </remarks>
UnpackedDotnetFirstSignalR,
}
}

View File

@@ -155,6 +155,26 @@
return Task.CompletedTask;
}
private void Read_SocketIO_Port(object sender, string line)
{
// Look for "Electron Socket: listening on port %s at"
var prefix = "Electron Socket: listening on port ";
if (line.StartsWith(prefix))
{
var start = prefix.Length;
var end = line.IndexOf(' ', start + 1);
var port = line[start..end];
if (int.TryParse(port, out var p))
{
// We got the port, so no more need for reading this
this.process.LineReceived -= this.Read_SocketIO_Port;
ElectronNetRuntime.ElectronSocketPort = p;
}
}
}
private async Task StartInternal(string startCmd, string args, string directoriy)
{
try
@@ -166,6 +186,7 @@
this.process = new ProcessRunner("ElectronRunner");
this.process.ProcessExited += this.Process_Exited;
this.process.LineReceived += this.Read_SocketIO_Port;
this.process.Run(startCmd, args, directoriy);
await Task.Delay(500.ms()).ConfigureAwait(false);

View File

@@ -8,12 +8,14 @@
internal class SocketBridgeService : LifetimeServiceBase
{
private readonly int socketPort;
private readonly string authorization;
private readonly string socketUrl;
private SocketIOConnection socket;
public SocketBridgeService(int socketPort)
public SocketBridgeService(int socketPort, string authorization)
{
this.socketPort = socketPort;
this.authorization = authorization;
this.socketUrl = $"http://localhost:{this.socketPort}";
}
@@ -23,7 +25,7 @@
protected override Task StartCore()
{
this.socket = new SocketIOConnection(this.socketUrl);
this.socket = new SocketIOConnection(this.socketUrl, this.authorization);
this.socket.BridgeConnected += this.Socket_BridgeConnected;
this.socket.BridgeDisconnected += this.Socket_BridgeDisconnected;
Task.Run(this.Connect);

View File

@@ -53,28 +53,14 @@
{
var isLaunchedByDotNet = LaunchOrderDetector.CheckIsLaunchedByDotNet();
var isUnPackaged = UnpackagedDetector.CheckIsUnpackaged();
// Check for SignalR mode via environment variable
var useSignalR = Environment.GetEnvironmentVariable("ELECTRON_USE_SIGNALR");
var isSignalRMode = !string.IsNullOrEmpty(useSignalR) && useSignalR.Equals("true", StringComparison.OrdinalIgnoreCase);
if (isLaunchedByDotNet)
{
if (isUnPackaged)
{
return isSignalRMode ? StartupMethod.UnpackedDotnetFirstSignalR : StartupMethod.UnpackedDotnetFirst;
}
return isSignalRMode ? StartupMethod.PackagedDotnetFirstSignalR : StartupMethod.PackagedDotnetFirst;
return isUnPackaged ? StartupMethod.UnpackedDotnetFirst: StartupMethod.PackagedDotnetFirst;
}
else
{
if (isUnPackaged)
{
return StartupMethod.UnpackedElectronFirst;
}
return StartupMethod.PackagedElectronFirst;
return isUnPackaged ? StartupMethod.UnpackedElectronFirst: StartupMethod.PackagedElectronFirst;
}
}

View File

@@ -1,24 +0,0 @@
namespace ElectronNET.API
{
using ElectronNET.AspNet.Hubs;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
/// <summary>
/// Extension methods for mapping the Electron SignalR hub.
/// </summary>
public static class ElectronEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps the Electron SignalR hub to the /electron-hub endpoint.
/// This is required when using SignalR-based startup modes.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder MapElectronHub(this IEndpointRouteBuilder endpoints)
{
endpoints.MapHub<ElectronHub>("/electron-hub");
return endpoints;
}
}
}

View File

@@ -68,11 +68,7 @@
// work as expected, see issue #952
Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", null);
// For SignalR modes, use port 0 for dynamic port assignment
var usePort0 = ElectronNetRuntime.StartupMethod == StartupMethod.PackagedDotnetFirstSignalR ||
ElectronNetRuntime.StartupMethod == StartupMethod.UnpackedDotnetFirstSignalR;
var webPort = usePort0 ? 0 : PortHelper.GetFreePort(ElectronNetRuntime.AspNetWebPort ?? ElectronNetRuntime.DefaultWebPort);
var webPort = PortHelper.GetFreePort(ElectronNetRuntime.AspNetWebPort ?? ElectronNetRuntime.DefaultWebPort);
ElectronNetRuntime.AspNetWebPort = webPort;
// check for the content folder if its exists in base director otherwise no need to include
@@ -80,7 +76,7 @@
// now we have implemented the live reload if app is run using /watch then we need to use the default project path.
// For port 0 (dynamic port assignment), Kestrel requires binding to specific IP (127.0.0.1) not localhost
var host = usePort0 ? "127.0.0.1" : "localhost";
var host = "localhost";
if (Directory.Exists($"{AppDomain.CurrentDomain.BaseDirectory}\\wwwroot"))
{
@@ -107,17 +103,6 @@
case StartupMethod.UnpackedDotnetFirst:
services.AddSingleton<IElectronNetRuntimeController, RuntimeControllerAspNetDotnetFirst>();
break;
case StartupMethod.PackagedDotnetFirstSignalR:
case StartupMethod.UnpackedDotnetFirstSignalR:
services.AddSignalR(options =>
{
// Enable detailed errors only in development for security
options.EnableDetailedErrors =
Debugger.IsAttached ||
context.HostingEnvironment.IsDevelopment();
});
services.AddSingleton<IElectronNetRuntimeController, RuntimeControllerAspNetDotnetFirstSignalR>();
break;
default:
throw new ArgumentOutOfRangeException();
}

View File

@@ -1,242 +0,0 @@
namespace ElectronNET.API
{
using System;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using ElectronNET.API.Bridge;
using ElectronNET.AspNet.Hubs;
/// <summary>
/// SignalR-based facade that mimics the SocketIOConnection interface
/// for compatibility with existing Electron API code.
///
/// Key implementation details:
/// - Uses IHubContext to send events to Electron via 'event' hub method
/// - Receives events from Electron via ElectronHub.ElectronEvent() method
/// - Includes ConvertToType&lt;T&gt; helper to handle JsonElement and numeric type conversions
/// - Event args are passed as arrays to match SignalR serialization behavior
/// - Connection ID is set by ElectronHub when Electron client connects
/// </summary>
internal class SignalRConnection : ISocketConnection
{
private readonly IHubContext<ElectronHub> _hubContext;
private string _connectionId;
private readonly ConcurrentDictionary<string, Action<object>> _eventHandlers;
private readonly object _lockObj = new object();
public SignalRConnection(IHubContext<ElectronHub> hubContext)
{
_hubContext = hubContext;
_eventHandlers = new ConcurrentDictionary<string, Action<object>>();
}
public event EventHandler BridgeDisconnected;
public event EventHandler BridgeConnected;
/// <summary>
/// SignalR connections are managed by ASP.NET Core, so this is a no-op.
/// Connection establishment happens via the ElectronHub.
/// </summary>
public void Connect()
{
// No-op: SignalR connection is managed by ASP.NET Core
}
public void SetConnectionId(string connectionId)
{
_connectionId = connectionId;
this.BridgeConnected?.Invoke(this, EventArgs.Empty);
}
public void OnDisconnected()
{
this.BridgeDisconnected?.Invoke(this, EventArgs.Empty);
}
public void On(string eventName, Action action)
{
lock (_lockObj)
{
_eventHandlers[eventName] = _ => Task.Run(action);
}
}
public void On<T>(string eventName, Action<T> action)
{
lock (_lockObj)
{
_eventHandlers[eventName] = obj =>
{
var converted = ConvertToType<T>(obj);
if (converted != null)
{
Task.Run(() => action(converted));
}
else
{
Console.Error.WriteLine($"[SignalR] Failed to convert event data to type {typeof(T).Name}");
}
};
}
}
public void Once(string eventName, Action action)
{
lock (_lockObj)
{
_eventHandlers[eventName] = _ =>
{
this.Off(eventName);
Task.Run(action);
};
}
}
public void Once<T>(string eventName, Action<T> action)
{
lock (_lockObj)
{
_eventHandlers[eventName] = obj =>
{
this.Off(eventName);
var converted = ConvertToType<T>(obj);
if (converted != null)
{
Task.Run(() => action(converted));
}
else
{
Console.Error.WriteLine($"[SignalR] Failed to convert event data to type {typeof(T).Name} for event '{eventName}'");
}
};
}
}
public void Off(string eventName)
{
lock (_lockObj)
{
_eventHandlers.TryRemove(eventName, out _);
}
}
public async Task Emit(string eventName, params object[] args)
{
if (string.IsNullOrEmpty(_connectionId))
{
Console.Error.WriteLine($"[SignalR] Cannot emit '{eventName}' - no connection ID");
return;
}
try
{
// Send message to specific Electron client via the 'event' hub method
// This will be received by signalr-bridge.js's connection.on('event', ...)
await _hubContext.Clients.Client(_connectionId).SendAsync("event", eventName, args);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[SignalR] Error emitting '{eventName}': {ex.Message}");
throw;
}
}
public void TriggerEvent(string eventName, params object[] args)
{
if (_eventHandlers.TryGetValue(eventName, out var handler))
{
// If single arg, pass it directly; otherwise pass the array
var data = args.Length == 1 ? args[0] : args;
handler(data);
}
}
/// <summary>
/// Converts an object to the specified type, handling JsonElement and numeric conversions.
/// </summary>
private static T ConvertToType<T>(object obj)
{
if (obj == null)
return default;
// Direct type match
if (obj is T typedValue)
return typedValue;
var targetType = typeof(T);
// Handle JsonElement (common from SignalR deserialization)
if (obj is JsonElement jsonElement)
{
try
{
return JsonSerializer.Deserialize<T>(jsonElement.GetRawText());
}
catch (Exception ex)
{
Console.Error.WriteLine($"[SignalR] JsonElement deserialization failed: {ex.Message}");
return default;
}
}
// Handle numeric conversions (SignalR often sends numbers as long/double)
try
{
if (targetType == typeof(int) || targetType == typeof(int?))
{
return (T)(object)Convert.ToInt32(obj);
}
if (targetType == typeof(long) || targetType == typeof(long?))
{
return (T)(object)Convert.ToInt64(obj);
}
if (targetType == typeof(double) || targetType == typeof(double?))
{
return (T)(object)Convert.ToDouble(obj);
}
if (targetType == typeof(bool) || targetType == typeof(bool?))
{
return (T)(object)Convert.ToBoolean(obj);
}
if (targetType == typeof(string))
{
return (T)(object)obj.ToString();
}
// For arrays, try JSON serialization roundtrip
if (targetType.IsArray && obj is object[] arr)
{
var json = JsonSerializer.Serialize(arr);
return JsonSerializer.Deserialize<T>(json);
}
// Last resort: try to serialize and deserialize
var serialized = JsonSerializer.Serialize(obj);
return JsonSerializer.Deserialize<T>(serialized);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[SignalR] Type conversion failed from {obj.GetType().Name} to {targetType.Name}: {ex.Message}");
return default;
}
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
// SignalR connections are managed by ASP.NET Core
_eventHandlers.Clear();
}
}
}
}

View File

@@ -1,108 +0,0 @@
namespace ElectronNET.AspNet.Hubs
{
using Microsoft.AspNetCore.SignalR;
using System;
using System.Threading.Tasks;
using ElectronNET;
using ElectronNET.API;
using ElectronNET.AspNet.Runtime;
using ElectronNET.Runtime;
/// <summary>
/// SignalR hub for bidirectional communication between ASP.NET Core and Electron.
/// Replaces socket.io for SignalR-based startup modes.
/// </summary>
public class ElectronHub : Hub
{
/// <summary>
/// Called when Electron client connects to the hub.
/// </summary>
public override async Task OnConnectedAsync()
{
// Notify the runtime controller about the connection
var runtimeController = ElectronNetRuntime.RuntimeController as RuntimeControllerAspNetDotnetFirstSignalR;
if (runtimeController != null)
{
runtimeController.OnSignalRConnected(Context.ConnectionId);
}
await base.OnConnectedAsync();
}
/// <summary>
/// Called when Electron client disconnects from the hub.
/// </summary>
public override async Task OnDisconnectedAsync(Exception exception)
{
if (exception != null)
{
Console.Error.WriteLine($"[ElectronHub] Disconnect error: {exception.Message}");
}
// Notify the runtime controller about the disconnection
var runtimeController = ElectronNetRuntime.RuntimeController as RuntimeControllerAspNetDotnetFirstSignalR;
if (runtimeController != null)
{
runtimeController.OnSignalRDisconnected();
}
await base.OnDisconnectedAsync(exception);
}
/// <summary>
/// Registers the Electron client. Called by Electron on connection.
/// </summary>
public async Task RegisterElectronClient()
{
await Task.CompletedTask;
}
/// <summary>
/// Receives events from Electron (e.g., "BrowserWindowCreated", "dialogResult").
/// Called by Electron to send data back to .NET.
/// </summary>
/// <param name="eventName">The event name</param>
/// <param name="args">The event arguments as an array</param>
public async Task ElectronEvent(string eventName, object[] args)
{
// Get the SignalRConnection and trigger the event handlers
var runtimeController = ElectronNetRuntime.RuntimeController as RuntimeControllerAspNetDotnetFirstSignalR;
if (runtimeController?.SignalRSocket is SignalRConnection socket)
{
// Invoke the event handlers registered via On/Once
socket.TriggerEvent(eventName, args ?? Array.Empty<object>());
}
await Task.CompletedTask;
}
/// <summary>
/// Invokes an Electron API method. Called by .NET to control Electron.
/// This is a placeholder for future API invocation patterns.
/// </summary>
/// <param name="method">The API method name</param>
/// <param name="data">The method parameters as JSON</param>
/// <returns>The result of the API call</returns>
public async Task<string> InvokeElectronApi(string method, string data)
{
// Forward to Electron client
await Clients.Caller.SendAsync("electronApiCall", method, data);
// TODO: Implement proper request-response pattern
return null;
}
/// <summary>
/// Handles responses from Electron API calls.
/// This is a placeholder for future API invocation patterns.
/// </summary>
/// <param name="callId">The unique identifier for this API call</param>
/// <param name="result">The result data as JSON</param>
public async Task ElectronApiResponse(string callId, string result)
{
// This will be handled by the SignalR facade to complete pending tasks
await Task.CompletedTask;
}
}
}

View File

@@ -15,7 +15,7 @@ namespace ElectronNET.AspNet.Middleware
/// - First request includes token as query parameter (?token=guid)
/// - Middleware validates token and sets secure HttpOnly cookie
/// - Subsequent requests use cookie (no token in URL)
/// - Both HTTP endpoints and SignalR hub protected
/// - HTTP endpoints protected
/// </summary>
public class ElectronAuthenticationMiddleware
{

View File

@@ -39,9 +39,9 @@
}
}
protected void CreateSocketBridge(int port)
protected void CreateSocketBridge(int port, string authorization)
{
this.socketBridge = new SocketBridgeService(port);
this.socketBridge = new SocketBridgeService(port, authorization);
this.socketBridge.Ready += this.SocketBridge_Ready;
this.socketBridge.Stopped += this.SocketBridge_Stopped;
this.socketBridge.Start();

View File

@@ -2,6 +2,7 @@
{
using System;
using System.Threading.Tasks;
using ElectronNET.AspNet.Services;
using ElectronNET.Common;
using ElectronNET.Runtime.Data;
using ElectronNET.Runtime.Helpers;
@@ -10,10 +11,14 @@
internal class RuntimeControllerAspNetDotnetFirst : RuntimeControllerAspNetBase
{
private ElectronProcessBase electronProcess;
private int? port;
private readonly string authorization;
public RuntimeControllerAspNetDotnetFirst(AspNetLifetimeAdapter aspNetLifetimeAdapter) : base(aspNetLifetimeAdapter)
public RuntimeControllerAspNetDotnetFirst(AspNetLifetimeAdapter aspNetLifetimeAdapter, IElectronAuthenticationService authenticationService = null) : base(aspNetLifetimeAdapter)
{
this.authorization = Guid.NewGuid().ToString("N"); // 32 hex chars, no hyphens
// Only if somebody registered an IElectronAuthenticationService service - otherwise we do not care
authenticationService?.SetExpectedToken(this.authorization);
}
internal override ElectronProcessBase ElectronProcess => this.electronProcess;
@@ -22,16 +27,11 @@
{
var isUnPacked = ElectronNetRuntime.StartupMethod.IsUnpackaged();
var electronBinaryName = ElectronNetRuntime.ElectronExecutable;
var args = Environment.CommandLine;
this.port = ElectronNetRuntime.ElectronSocketPort;
var authToken = this.authorization;
var port = ElectronNetRuntime.ElectronSocketPort ?? 0;
var args = $"{Environment.CommandLine} --authtoken={authToken}";
if (!this.port.HasValue)
{
this.port = PortHelper.GetFreePort(ElectronNetRuntime.DefaultSocketPort);
ElectronNetRuntime.ElectronSocketPort = this.port;
}
this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, this.port.Value);
this.electronProcess = new ElectronProcessActive(isUnPacked, electronBinaryName, args, port);
this.electronProcess.Ready += this.ElectronProcess_Ready;
this.electronProcess.Stopped += this.ElectronProcess_Stopped;
@@ -46,8 +46,9 @@
private void ElectronProcess_Ready(object sender, EventArgs e)
{
var port = ElectronNetRuntime.ElectronSocketPort.Value;
this.TransitionState(LifetimeState.Started);
this.CreateSocketBridge(this.port!.Value);
this.CreateSocketBridge(port, this.authorization);
}
private void ElectronProcess_Stopped(object sender, EventArgs e)

View File

@@ -1,189 +0,0 @@
namespace ElectronNET.AspNet.Runtime
{
using System;
using System.Linq;
using System.Threading.Tasks;
using ElectronNET.API;
using ElectronNET.API.Bridge;
using ElectronNET.Common;
using ElectronNET.Runtime.Data;
using ElectronNET.Runtime.Services.ElectronProcess;
using ElectronNET.Runtime.Services.SocketBridge;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.SignalR;
using ElectronNET.AspNet.Hubs;
using ElectronNET.AspNet.Services;
/// <summary>
/// Runtime controller for SignalR-based .NET-first startup mode.
/// Key differences from Socket.IO mode:
/// - Waits for ASP.NET server to start, then captures the dynamic port
/// - Launches Electron with the actual URL (no port scanning needed)
/// - Uses SignalRConnection instead of SocketIOConnection for bidirectional communication
/// - Waits for 'electron-host-ready' signal to ensure API modules are loaded before calling app callback
/// </summary>
internal class RuntimeControllerAspNetDotnetFirstSignalR : RuntimeControllerAspNetBase
{
private ElectronProcessBase electronProcess;
private readonly IServer server;
private readonly IHubContext<ElectronHub> hubContext;
private readonly IElectronAuthenticationService authenticationService;
private SignalRConnection socket;
private int? port;
private string actualUrl;
private bool electronLaunched;
private string authenticationToken;
public RuntimeControllerAspNetDotnetFirstSignalR(
AspNetLifetimeAdapter aspNetLifetimeAdapter,
IServer server,
IHubContext<ElectronHub> hubContext,
IElectronAuthenticationService authenticationService)
: base(aspNetLifetimeAdapter)
{
this.server = server;
this.hubContext = hubContext;
this.authenticationService = authenticationService;
this.socket = new SignalRConnection(hubContext);
this.electronLaunched = false;
this.socket.BridgeConnected += this.SignalRConnection_Connected;
this.socket.BridgeDisconnected += this.SignalRConnection_Disconnected;
// Subscribe to ASP.NET ready event to launch Electron
aspNetLifetimeAdapter.Ready += this.OnAspNetReady;
}
internal override ElectronProcessBase ElectronProcess => this.electronProcess;
internal override SocketBridgeService SocketBridge => null;
internal override ISocketConnection Socket
{
get
{
if (this.State == LifetimeState.Ready)
{
return this.socket;
}
throw new Exception("Cannot access SignalR facade. Runtime is not in 'Ready' state");
}
}
internal SignalRConnection SignalRSocket => this.socket;
protected override Task StartCore()
{
return Task.CompletedTask;
}
protected override Task StopCore()
{
this.electronProcess?.Stop();
this.socket?.Dispose();
return Task.CompletedTask;
}
private void OnAspNetReady(object sender, EventArgs e)
{
if (!this.electronLaunched)
{
this.CapturePortAndLaunchElectron();
}
}
private void CapturePortAndLaunchElectron()
{
var addresses = this.server.Features.Get<IServerAddressesFeature>();
if (addresses == null || !addresses.Addresses.Any())
{
throw new Exception("Could not retrieve server addresses");
}
this.actualUrl = addresses.Addresses.First();
this.port = new Uri(this.actualUrl).Port;
// Update the runtime port so WindowManager uses the correct URL
ElectronNetRuntime.AspNetWebPort = this.port;
this.LaunchElectron();
this.electronLaunched = true;
}
private void LaunchElectron()
{
// Generate secure authentication token (128-bit cryptographic random GUID)
// This token protects against unauthorized connections from other users on the same machine
this.authenticationToken = Guid.NewGuid().ToString("N"); // 32 hex chars, no hyphens
// Register token with authentication service for validation
// The middleware will validate this token on all HTTP and SignalR requests
this.authenticationService.SetExpectedToken(this.authenticationToken);
var isUnPacked = ElectronNetRuntime.StartupMethod.IsUnpackaged();
var flag = isUnPacked ? "--unpackeddotnetsignalr" : "--dotnetpackedsignalr";
var args = $"{flag} --electronurl={this.actualUrl} --authtoken={this.authenticationToken}";
this.electronProcess = new ElectronProcessActive(isUnPacked, ElectronNetRuntime.ElectronExecutable, args, this.port.Value);
// Note: We do NOT subscribe to electronProcess.Ready in SignalR mode.
// The "ready" signal comes from the SignalR connection, not stdout.
this.electronProcess.Stopped += this.ElectronProcess_Stopped;
_ = this.electronProcess.Start();
}
private async void SignalRConnection_Connected(object sender, EventArgs e)
{
// Register handler for 'electron-host-ready' signal from Electron.
// This ensures API modules are fully loaded before calling the app ready callback.
this.socket.Once("electron-host-ready", () =>
{
this.OnElectronHostReady();
});
}
private async void OnElectronHostReady()
{
this.TransitionState(LifetimeState.Ready);
// Execute the app ready callback
if (ElectronNetRuntime.OnAppReadyCallback != null)
{
try
{
await ElectronNetRuntime.OnAppReadyCallback().ConfigureAwait(false);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Exception in app ready callback: {ex}");
this.Stop();
}
}
}
private void SignalRConnection_Disconnected(object sender, EventArgs e)
{
// IMPORTANT: Do NOT call HandleStopped synchronously here!
// This event fires from within SignalR's OnDisconnectedAsync, and calling
// StopApplication() synchronously causes a deadlock: the host waits for
// OnDisconnectedAsync to complete, but we're waiting for the host to stop.
// Fire and forget to break the deadlock.
_ = Task.Run(() => this.HandleStopped());
}
private void ElectronProcess_Stopped(object sender, EventArgs e)
{
this.HandleStopped();
}
public void OnSignalRConnected(string connectionId)
{
this.socket.SetConnectionId(connectionId);
}
public void OnSignalRDisconnected()
{
this.socket.OnDisconnected();
}
}
}

View File

@@ -30,7 +30,7 @@
throw new Exception("No electronPID has been specified by Electron!");
}
this.CreateSocketBridge(this.port!.Value);
this.CreateSocketBridge(this.port!.Value, "");
this.electronProcess = new ElectronProcessPassive(ElectronNetRuntime.ElectronProcessId.Value);
this.electronProcess.Stopped += this.ElectronProcess_Stopped;

View File

@@ -1,213 +0,0 @@
/**
* SignalR connection module for Electron.NET
*
* This module provides a Socket.IO-compatible interface for SignalR communication.
* Key features:
* - Mimics Socket.IO's on() and emit() methods for compatibility with existing API modules
* - Handles event registration and propagation between Electron and .NET
* - Event args are always passed as arrays to match C# ElectronEvent(string, object[]) signature
* - Spreads args when calling handlers to match Socket.IO behavior
* - Supports automatic reconnection with configurable logging level
*/
const signalR = require("@microsoft/signalr");
const { app } = require("electron");
// Flag to track if we've already initiated shutdown due to EPIPE
let isShuttingDownFromEPIPE = false;
// Handle EPIPE errors at the process stdout/stderr level
// When the pipe breaks (e.g., .NET process terminates), quit Electron gracefully
const handlePipeError = (err) => {
if (err.code === "EPIPE" || err.code === "ERR_STREAM_WRITE_AFTER_END") {
// Pipe is broken - the .NET process has terminated
if (!isShuttingDownFromEPIPE) {
isShuttingDownFromEPIPE = true;
// Give a brief moment for any pending operations, then quit
setImmediate(() => {
if (app && app.quit) {
app.quit();
}
});
}
return;
}
// Re-throw other errors
throw err;
};
// Suppress EPIPE errors at the process stdout/stderr level
if (process.stdout && !process.stdout.listenerCount("error")) {
process.stdout.on("error", handlePipeError);
}
if (process.stderr && !process.stderr.listenerCount("error")) {
process.stderr.on("error", handlePipeError);
}
// Custom logger for SignalR that uses environment-aware logging
class SafeLogger {
constructor(minLevel) {
this.minLevel = minLevel || signalR.LogLevel.Warning;
}
log(logLevel, message) {
// Skip if below minimum level
if (logLevel < this.minLevel) {
return;
}
switch (logLevel) {
case signalR.LogLevel.Critical:
case signalR.LogLevel.Error:
console.error(`[SignalR] ${message}`);
break;
case signalR.LogLevel.Warning:
console.warn(`[SignalR] ${message}`);
break;
case signalR.LogLevel.Information:
console.info(`[SignalR] ${message}`);
break;
case signalR.LogLevel.Debug:
case signalR.LogLevel.Trace:
console.debug(`[SignalR] ${message}`);
break;
}
}
}
class SignalRBridge {
constructor(hubUrl, authToken) {
this.hubUrl = hubUrl;
this.authToken = authToken;
this.connection = null;
this.isConnected = false;
this.eventHandlers = new Map(); // For socket.io-style .on() handlers
this.callIdCounter = 0;
}
async connect() {
// Append authentication token to the SignalR connection URL
const connectionUrl = this.authToken
? `${this.hubUrl}?token=${this.authToken}`
: this.hubUrl;
// Determine SignalR log level based on environment
// Warning level suppresses verbose packet-level logging
const { getLogLevel, LogLevel: AppLogLevel } = require("../logger");
let signalRLogLevel;
if (getLogLevel() <= AppLogLevel.DEBUG) {
// Debug mode: show Info level (connection events without packet details)
signalRLogLevel = signalR.LogLevel.Information;
} else {
// Development/Production: only warnings and errors
signalRLogLevel = signalR.LogLevel.Warning;
}
this.connection = new signalR.HubConnectionBuilder()
.withUrl(connectionUrl)
.withAutomaticReconnect()
.configureLogging(new SafeLogger(signalRLogLevel))
.build();
// Handle reconnection
this.connection.onreconnecting((error) => {
console.error(`[SignalRBridge] Connection lost. Reconnecting...`, error);
this.isConnected = false;
});
this.connection.onreconnected((connectionId) => {
this.isConnected = true;
});
this.connection.onclose((error) => {
if (error) {
console.error(`[SignalRBridge] Connection closed:`, error);
}
this.isConnected = false;
});
// Set up handlers for messages from .NET
this.setupMessageHandlers();
try {
await this.connection.start();
this.isConnected = true;
// Register with the hub
await this.connection.invoke("RegisterElectronClient");
return true;
} catch (err) {
// Check if this is an authentication error
if (err.message && err.message.includes("401")) {
console.error(
`[SignalRBridge] Authentication failed: The authentication token is invalid or missing.`,
);
console.error(
`[SignalRBridge] Please ensure the --authtoken parameter is correctly passed to Electron.`,
);
} else {
console.error(`[SignalRBridge] Connection failed:`, err);
}
this.isConnected = false;
return false;
}
}
setupMessageHandlers() {
// Handle generic events from .NET - this is where .NET's Emit() calls arrive
this.connection.on("event", (eventName, args) => {
// args is an array from .NET - spread it when calling handlers
const argsArray = Array.isArray(args) ? args : [args];
// Check if we have handlers registered for this event
if (this.eventHandlers.has(eventName)) {
const handlers = this.eventHandlers.get(eventName);
handlers.forEach((handler) => {
try {
handler(...argsArray);
} catch (err) {
console.error(
`[SignalRBridge] Error in event handler for ${eventName}:`,
err,
);
}
});
}
});
}
// Socket.io compatibility: register event handler
on(eventName, callback) {
if (!this.eventHandlers.has(eventName)) {
this.eventHandlers.set(eventName, []);
}
this.eventHandlers.get(eventName).push(callback);
}
// Socket.io compatibility: emit event (send to .NET)
async emit(eventName, ...args) {
if (!this.isConnected) {
console.warn(`[SignalRBridge] Cannot emit ${eventName} - not connected`);
return;
}
try {
// Always pass args as an array to match C# method signature
await this.connection.invoke("ElectronEvent", eventName, args);
} catch (err) {
console.error(`[SignalRBridge] Error emitting ${eventName}:`, err);
throw err;
}
}
async disconnect() {
if (this.connection) {
await this.connection.stop();
this.isConnected = false;
}
}
}
module.exports = { SignalRBridge };

View File

@@ -1,9 +1,10 @@
const { app } = require('electron');
const { BrowserWindow } = require('electron');
const { protocol } = require('electron');
const { createServer } = require('http');
const { Server } = require('socket.io');
const path = require('path');
const fs = require('fs');
const cProcess = require('child_process').spawn;
const portscanner = require('portscanner');
const { imageSize } = require('image-size');
let io, server, browserWindows, ipc, apiProcess, loadURL;
@@ -15,19 +16,13 @@ let processInfo;
let splashScreen;
let nativeTheme;
let dock;
let desktopCapturer;
let electronHostHook;
let touchBar;
let launchFile;
let launchUrl;
let processApi;
let manifestJsonFileName = 'package.json';
let unpackedelectron = false;
let unpackeddotnet = false;
let dotnetpacked = false;
let unpackeddotnetsignalr = false;
let dotnetpackedsignalr = false;
let electronforcedport;
let electronUrl;
@@ -35,15 +30,7 @@ if (app.commandLine.hasSwitch('manifest')) {
manifestJsonFileName = app.commandLine.getSwitchValue('manifest');
}
// Check for SignalR modes first (these take precedence)
if (app.commandLine.hasSwitch('unpackeddotnetsignalr')) {
unpackeddotnetsignalr = true;
}
else if (app.commandLine.hasSwitch('dotnetpackedsignalr')) {
dotnetpackedsignalr = true;
}
// Then check legacy modes
else if (app.commandLine.hasSwitch('unpackedelectron')) {
if (app.commandLine.hasSwitch('unpackedelectron')) {
unpackedelectron = true;
}
else if (app.commandLine.hasSwitch('unpackeddotnet')) {
@@ -54,16 +41,19 @@ else if (app.commandLine.hasSwitch('dotnetpacked')) {
}
if (app.commandLine.hasSwitch('electronforcedport')) {
electronforcedport = app.commandLine.getSwitchValue('electronforcedport');
electronforcedport = +app.commandLine.getSwitchValue('electronforcedport');
}
let authToken;
if (app.commandLine.hasSwitch('authtoken')) {
authToken = app.commandLine.getSwitchValue('authtoken');
// Store in global for access by browser windows
global.authToken = authToken;
}
console.log('Started with token', authToken);
if (app.commandLine.hasSwitch('electronurl')) {
electronUrl = app.commandLine.getSwitchValue('electronurl');
}
@@ -71,7 +61,6 @@ if (app.commandLine.hasSwitch('electronurl')) {
// Custom startup hook: look for custom_main.js and invoke its onStartup(host) if present.
// If the hook returns false, abort Electron startup.
try {
const fs = require('fs');
const customMainPath = path.join(__dirname, 'custom_main.js');
if (fs.existsSync(customMainPath)) {
const customMain = require(customMainPath);
@@ -176,55 +165,13 @@ app.on('ready', async () => {
startSplashScreen();
}
// Check if we're using SignalR-based startup
// SignalR mode is activated by --unpackeddotnetsignalr or --dotnetpackedsignalr flags
// .NET passes the actual server URL via --electronurl parameter (no port scanning needed)
if (unpackeddotnetsignalr || dotnetpackedsignalr) {
if (!electronUrl) {
console.error('[Electron] ERROR: SignalR mode requires --electronUrl parameter');
app.quit();
return;
}
// Create a temporary invisible window to keep Electron alive during startup.
// Without any windows, Electron would quit immediately on macOS.
// This will be destroyed once the first real window is created.
const { BrowserWindow } = require('electron');
const keepAliveWindow = new BrowserWindow({
show: false,
width: 1,
height: 1
});
// Destroy the keep-alive window when the first real window is created
app.once('browser-window-created', (event, window) => {
if (keepAliveWindow && !keepAliveWindow.isDestroyed()) {
keepAliveWindow.destroy();
}
});
await startSignalRApiBridge(electronUrl);
return;
}
// Legacy socket.io startup
if (electronforcedport) {
console.info('Electron Socket IO (forced) Port: ' + electronforcedport);
startSocketApiBridge(electronforcedport);
return;
} else {
console.info('Electron Socket dynamic IO Port');
startSocketApiBridge(0);
}
// Added default port as configurable for port restricted environments.
let defaultElectronPort = 8000;
if (manifestJsonFile.electronPort) {
defaultElectronPort = manifestJsonFile.electronPort;
}
// hostname needs to be localhost, otherwise Windows Firewall will be triggered.
portscanner.findAPortNotInUse(defaultElectronPort, 65535, 'localhost', function (error, port) {
console.info('Electron Socket IO Port: ' + port);
startSocketApiBridge(port);
});
});
app.on('quit', async (event, exitCode) => {
@@ -330,8 +277,7 @@ function startSocketApiBridge(port) {
// instead of 'require('socket.io')(port);' we need to use this workaround
// otherwise the Windows Firewall will be triggered
console.debug('Electron Socket: starting...');
server = require('http').createServer();
const { Server } = require('socket.io');
server = createServer();
let hostHook;
io = new Server({
pingTimeout: 60000, // in ms, default is 5000
@@ -341,7 +287,8 @@ function startSocketApiBridge(port) {
server.listen(port, 'localhost');
server.on('listening', function () {
console.info('Electron Socket: listening on port %s at %s', server.address().port, server.address().address);
const addr = server.address();
console.info('Electron Socket: listening on port %s at %s', addr.port, addr.address);
// Now that socket connection is established, we can guarantee port will not be open for portscanner
if (unpackedelectron) {
startAspCoreBackendUnpackaged(port);
@@ -357,6 +304,13 @@ function startSocketApiBridge(port) {
// @ts-ignore
io.on('connection', (socket) => {
console.info('Electron Socket: connected!');
if (authToken && socket.request.headers.authorization !== authToken) {
console.warn('Electron Socket authentication failed!');
socket.disconnect(true);
return;
}
socket.on('disconnect', function (reason) {
console.debug('Got disconnect! Reason: ' + reason);
try {
@@ -443,106 +397,6 @@ function startSocketApiBridge(port) {
});
}
/**
* Starts the SignalR API bridge for .NET-first SignalR mode.
*
* Flow:
* 1. Connect to SignalR hub at /electron-hub endpoint
* 2. Register as Electron client
* 3. Load all API modules (same modules as Socket.IO mode)
* 4. Signal 'electron-host-ready' to .NET to trigger app ready callback
*
* This ensures .NET doesn't call the app ready callback until all API modules
* are loaded and ready to handle requests from .NET code.
*/
async function startSignalRApiBridge(baseUrl) {
const { SignalRBridge } = require('./api/signalr-bridge');
const hubUrl = `${baseUrl}/electron-hub`;
// Pass the authentication token to the SignalR bridge
const signalRBridge = new SignalRBridge(hubUrl, global.authToken);
try {
const connected = await signalRBridge.connect();
if (!connected) {
console.error('[SignalRBridge] Failed to connect to SignalR hub');
app.quit();
return;
}
// Store the bridge globally for API access
global['electronsignalr'] = signalRBridge;
// Define module loaders - each returns the initialized module
const loadModules = () => {
const modules = {};
// Load all modules in parallel using Promise.all
return Promise.all([
// Critical modules (always needed)
Promise.resolve().then(() => modules.appApi = require('./api/app')(signalRBridge, app)),
Promise.resolve().then(() => modules.browserWindows = require('./api/browserWindows')(signalRBridge, app)),
Promise.resolve().then(() => modules.commandLine = require('./api/commandLine')(signalRBridge, app)),
Promise.resolve().then(() => modules.webContents = require('./api/webContents')(signalRBridge)),
Promise.resolve().then(() => modules.ipc = require('./api/ipc')(signalRBridge)),
Promise.resolve().then(() => modules.menu = require('./api/menu')(signalRBridge)),
// Secondary modules (commonly used)
Promise.resolve().then(() => modules.dialogApi = require('./api/dialog')(signalRBridge)),
Promise.resolve().then(() => modules.notification = require('./api/notification')(signalRBridge)),
Promise.resolve().then(() => modules.shellApi = require('./api/shell')(signalRBridge)),
Promise.resolve().then(() => modules.clipboard = require('./api/clipboard')(signalRBridge)),
Promise.resolve().then(() => modules.screen = require('./api/screen')(signalRBridge)),
// Utility modules (less frequently used)
Promise.resolve().then(() => modules.autoUpdater = require('./api/autoUpdater')(signalRBridge)),
Promise.resolve().then(() => modules.tray = require('./api/tray')(signalRBridge)),
Promise.resolve().then(() => modules.globalShortcut = require('./api/globalShortcut')(signalRBridge)),
Promise.resolve().then(() => modules.nativeTheme = require('./api/nativeTheme')(signalRBridge)),
Promise.resolve().then(() => modules.powerMonitor = require('./api/powerMonitor')(signalRBridge)),
Promise.resolve().then(() => modules.processApi = require('./api/process')(signalRBridge)),
// Platform-specific modules
Promise.resolve().then(() => {
if (process.platform === 'darwin') {
modules.dock = require('./api/dock')(signalRBridge, app);
}
})
]).then(() => modules);
};
const modules = await loadModules();
// Assign to global variables (for backward compatibility)
if (appApi === undefined) appApi = modules.appApi;
if (browserWindows === undefined) browserWindows = modules.browserWindows;
if (commandLine === undefined) commandLine = modules.commandLine;
if (autoUpdater === undefined) autoUpdater = modules.autoUpdater;
if (ipc === undefined) ipc = modules.ipc;
if (menu === undefined) menu = modules.menu;
if (dialogApi === undefined) dialogApi = modules.dialogApi;
if (notification === undefined) notification = modules.notification;
if (tray === undefined) tray = modules.tray;
if (webContents === undefined) webContents = modules.webContents;
if (globalShortcut === undefined) globalShortcut = modules.globalShortcut;
if (clipboard === undefined) clipboard = modules.clipboard;
if (screen === undefined) screen = modules.screen;
if (shellApi === undefined) shellApi = modules.shellApi;
if (nativeTheme === undefined) nativeTheme = modules.nativeTheme;
if (powerMonitor === undefined) powerMonitor = modules.powerMonitor;
if (dock === undefined && modules.dock) dock = modules.dock;
if (processApi === undefined) processApi = modules.processApi;
await signalRBridge.emit('electron-host-ready');
} catch (error) {
console.error('[SignalRBridge] Error during startup:', error);
console.error('[SignalRBridge] Stack:', error.stack);
app.quit();
}
}
function startAspCoreBackend(electronPort) {
startBackend();

View File

@@ -16,11 +16,9 @@
"author": "Gregor Biswanger, Florian Rappl",
"license": "MIT",
"dependencies": {
"@microsoft/signalr": "^8.0.7",
"dasherize": "^2.0.0",
"electron-host-hook": "file:./ElectronHostHook",
"image-size": "^1.2.1",
"portscanner": "^2.2.0",
"electron-updater": "^6.6.2",
"socket.io": "^4.8.1"
},

View File

@@ -85,9 +85,6 @@ app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages:
// UseAntiforgery must be after UseRouting
app.UseAntiforgery();
// Map SignalR hub for Electron communication
app.MapHub<ElectronNET.AspNet.Hubs.ElectronHub>("/electron-hub");
app.MapStaticAssets();
app.MapRazorComponents<ElectronNET.Samples.BlazorSignalR.Components.App>()
.AddInteractiveServerRenderMode();

View File

@@ -7,8 +7,7 @@
"launchBrowser": false,
"applicationUrl": "http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ELECTRON_USE_SIGNALR": "true"
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}