mirror of
https://github.com/ElectronNET/Electron.NET.git
synced 2026-07-09 02:07:47 +00:00
Merge branch 'feature/secure-connection' of https://github.com/ElectronNET/Electron.NET into feature/secure-connection
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -14,7 +14,6 @@ namespace ElectronNET.Common
|
||||
{
|
||||
case StartupMethod.UnpackedElectronFirst:
|
||||
case StartupMethod.UnpackedDotnetFirst:
|
||||
case StartupMethod.UnpackedDotnetFirstSignalR:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<T> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:0",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ELECTRON_USE_SIGNALR": "true"
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user