diff --git a/docs/SignalR-Authentication-Guide.md b/docs/Dev/Authentication.md similarity index 100% rename from docs/SignalR-Authentication-Guide.md rename to docs/Dev/Authentication.md diff --git a/docs/SignalR-Implementation-Summary.md b/docs/SignalR-Implementation-Summary.md deleted file mode 100644 index 7efede1..0000000 --- a/docs/SignalR-Implementation-Summary.md +++ /dev/null @@ -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` -- Includes `ConvertToType` 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("/electron-hub"); - -app.MapRazorComponents().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` handlers to fail silently. - -**Solution**: `SignalRConnection.ConvertToType` 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=` parameter -3. **Token appended to URLs**: - - Initial page load: `http://localhost:PORT/?token=` - - SignalR connection: `http://localhost:PORT/electron-hub?token=` -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(); - -builder.Services.AddElectron(); - -var app = builder.Build(); - -// Register middleware BEFORE UseRouting() -app.UseMiddleware(); - -app.UseRouting(); -app.MapHub("/electron-hub"); -app.MapRazorComponents().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 diff --git a/docs/SignalR-Startup-Mode.md b/docs/SignalR-Startup-Mode.md deleted file mode 100644 index f79e1a2..0000000 --- a/docs/SignalR-Startup-Mode.md +++ /dev/null @@ -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 diff --git a/src/ElectronNET.API/Bridge/ISocketConnection.cs b/src/ElectronNET.API/Bridge/ISocketConnection.cs index 214cb1f..215c8de 100644 --- a/src/ElectronNET.API/Bridge/ISocketConnection.cs +++ b/src/ElectronNET.API/Bridge/ISocketConnection.cs @@ -4,7 +4,7 @@ namespace ElectronNET.API.Bridge using System.Threading.Tasks; /// - /// Common interface for communication facades (SocketIO and SignalR). + /// Common interface for communication facades. /// Provides methods for bidirectional communication between .NET and Electron. /// internal interface ISocketConnection : IDisposable diff --git a/src/ElectronNET.API/Bridge/SocketIOConnection.cs b/src/ElectronNET.API/Bridge/SocketIOConnection.cs index cab6636..26289d0 100644 --- a/src/ElectronNET.API/Bridge/SocketIOConnection.cs +++ b/src/ElectronNET.API/Bridge/SocketIOConnection.cs @@ -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 + { + ["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. diff --git a/src/ElectronNET.API/Common/Extensions.cs b/src/ElectronNET.API/Common/Extensions.cs index b08428d..a5de118 100644 --- a/src/ElectronNET.API/Common/Extensions.cs +++ b/src/ElectronNET.API/Common/Extensions.cs @@ -14,7 +14,6 @@ namespace ElectronNET.Common { case StartupMethod.UnpackedElectronFirst: case StartupMethod.UnpackedDotnetFirst: - case StartupMethod.UnpackedDotnetFirstSignalR: return true; default: return false; diff --git a/src/ElectronNET.API/Common/ProcessRunner.cs b/src/ElectronNET.API/Common/ProcessRunner.cs index 5ac7612..d561020 100644 --- a/src/ElectronNET.API/Common/ProcessRunner.cs +++ b/src/ElectronNET.API/Common/ProcessRunner.cs @@ -26,6 +26,8 @@ private readonly StringBuilder stdOut = new StringBuilder(4 * 1024); private readonly StringBuilder stdErr = new StringBuilder(4 * 1024); + public event EventHandler 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 { diff --git a/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerDotNetFirst.cs b/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerDotNetFirst.cs index c271a11..6483bf9 100644 --- a/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerDotNetFirst.cs +++ b/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerDotNetFirst.cs @@ -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(); diff --git a/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerElectronFirst.cs b/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerElectronFirst.cs index 36d2c84..a76d8bf 100644 --- a/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerElectronFirst.cs +++ b/src/ElectronNET.API/Runtime/Controllers/RuntimeControllerElectronFirst.cs @@ -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(); diff --git a/src/ElectronNET.API/Runtime/Data/StartupMethod.cs b/src/ElectronNET.API/Runtime/Data/StartupMethod.cs index 378e8e5..2dcf68a 100644 --- a/src/ElectronNET.API/Runtime/Data/StartupMethod.cs +++ b/src/ElectronNET.API/Runtime/Data/StartupMethod.cs @@ -33,23 +33,5 @@ /// On the command lines, this is "unpackeddotnet" /// UnpackedDotnetFirst, - - /// Packaged Electron app where DotNet launches Electron and uses SignalR for communication. - /// - /// 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" - /// - PackagedDotnetFirstSignalR, - - /// Unpackaged execution where DotNet launches Electron and uses SignalR for communication. - /// - /// 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" - /// - UnpackedDotnetFirstSignalR, } } \ No newline at end of file diff --git a/src/ElectronNET.API/Runtime/Services/ElectronProcess/ElectronProcessActive.cs b/src/ElectronNET.API/Runtime/Services/ElectronProcess/ElectronProcessActive.cs index b2d32a9..8fc81c0 100644 --- a/src/ElectronNET.API/Runtime/Services/ElectronProcess/ElectronProcessActive.cs +++ b/src/ElectronNET.API/Runtime/Services/ElectronProcess/ElectronProcessActive.cs @@ -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); diff --git a/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs b/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs index 7200f49..4530a65 100644 --- a/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs +++ b/src/ElectronNET.API/Runtime/Services/SocketBridge/SocketBridgeService.cs @@ -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); diff --git a/src/ElectronNET.API/Runtime/StartupManager.cs b/src/ElectronNET.API/Runtime/StartupManager.cs index 07f007b..edb3e36 100644 --- a/src/ElectronNET.API/Runtime/StartupManager.cs +++ b/src/ElectronNET.API/Runtime/StartupManager.cs @@ -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; } } diff --git a/src/ElectronNET.AspNet/API/ElectronEndpointRouteBuilderExtensions.cs b/src/ElectronNET.AspNet/API/ElectronEndpointRouteBuilderExtensions.cs deleted file mode 100644 index 31e3847..0000000 --- a/src/ElectronNET.AspNet/API/ElectronEndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace ElectronNET.API -{ - using ElectronNET.AspNet.Hubs; - using Microsoft.AspNetCore.Builder; - using Microsoft.AspNetCore.Routing; - - /// - /// Extension methods for mapping the Electron SignalR hub. - /// - public static class ElectronEndpointRouteBuilderExtensions - { - /// - /// Maps the Electron SignalR hub to the /electron-hub endpoint. - /// This is required when using SignalR-based startup modes. - /// - /// The endpoint route builder. - /// The endpoint route builder for chaining. - public static IEndpointRouteBuilder MapElectronHub(this IEndpointRouteBuilder endpoints) - { - endpoints.MapHub("/electron-hub"); - return endpoints; - } - } -} diff --git a/src/ElectronNET.AspNet/API/WebHostBuilderExtensions.cs b/src/ElectronNET.AspNet/API/WebHostBuilderExtensions.cs index b49a9bb..a8f766a 100644 --- a/src/ElectronNET.AspNet/API/WebHostBuilderExtensions.cs +++ b/src/ElectronNET.AspNet/API/WebHostBuilderExtensions.cs @@ -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(); 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(); - break; default: throw new ArgumentOutOfRangeException(); } diff --git a/src/ElectronNET.AspNet/Bridge/SignalRConnection.cs b/src/ElectronNET.AspNet/Bridge/SignalRConnection.cs deleted file mode 100644 index 902d26c..0000000 --- a/src/ElectronNET.AspNet/Bridge/SignalRConnection.cs +++ /dev/null @@ -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; - - /// - /// 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 - /// - internal class SignalRConnection : ISocketConnection - { - private readonly IHubContext _hubContext; - private string _connectionId; - private readonly ConcurrentDictionary> _eventHandlers; - private readonly object _lockObj = new object(); - - public SignalRConnection(IHubContext hubContext) - { - _hubContext = hubContext; - _eventHandlers = new ConcurrentDictionary>(); - } - - public event EventHandler BridgeDisconnected; - public event EventHandler BridgeConnected; - - /// - /// SignalR connections are managed by ASP.NET Core, so this is a no-op. - /// Connection establishment happens via the ElectronHub. - /// - 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(string eventName, Action action) - { - lock (_lockObj) - { - _eventHandlers[eventName] = obj => - { - var converted = ConvertToType(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(string eventName, Action action) - { - lock (_lockObj) - { - _eventHandlers[eventName] = obj => - { - this.Off(eventName); - var converted = ConvertToType(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); - } - } - - /// - /// Converts an object to the specified type, handling JsonElement and numeric conversions. - /// - private static T ConvertToType(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(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(json); - } - - // Last resort: try to serialize and deserialize - var serialized = JsonSerializer.Serialize(obj); - return JsonSerializer.Deserialize(serialized); - } - catch (Exception ex) - { - Console.Error.WriteLine($"[SignalR] Type conversion failed from {obj.GetType().Name} to {targetType.Name}: {ex.Message}"); - return default; - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - 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(); - } - } - } -} diff --git a/src/ElectronNET.AspNet/Hubs/ElectronHub.cs b/src/ElectronNET.AspNet/Hubs/ElectronHub.cs deleted file mode 100644 index bda9b6f..0000000 --- a/src/ElectronNET.AspNet/Hubs/ElectronHub.cs +++ /dev/null @@ -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; - - /// - /// SignalR hub for bidirectional communication between ASP.NET Core and Electron. - /// Replaces socket.io for SignalR-based startup modes. - /// - public class ElectronHub : Hub - { - /// - /// Called when Electron client connects to the hub. - /// - 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(); - } - - /// - /// Called when Electron client disconnects from the hub. - /// - 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); - } - - /// - /// Registers the Electron client. Called by Electron on connection. - /// - public async Task RegisterElectronClient() - { - await Task.CompletedTask; - } - - /// - /// Receives events from Electron (e.g., "BrowserWindowCreated", "dialogResult"). - /// Called by Electron to send data back to .NET. - /// - /// The event name - /// The event arguments as an array - 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()); - } - - await Task.CompletedTask; - } - - /// - /// Invokes an Electron API method. Called by .NET to control Electron. - /// This is a placeholder for future API invocation patterns. - /// - /// The API method name - /// The method parameters as JSON - /// The result of the API call - public async Task InvokeElectronApi(string method, string data) - { - // Forward to Electron client - await Clients.Caller.SendAsync("electronApiCall", method, data); - - // TODO: Implement proper request-response pattern - return null; - } - - /// - /// Handles responses from Electron API calls. - /// This is a placeholder for future API invocation patterns. - /// - /// The unique identifier for this API call - /// The result data as JSON - public async Task ElectronApiResponse(string callId, string result) - { - // This will be handled by the SignalR facade to complete pending tasks - await Task.CompletedTask; - } - - } -} diff --git a/src/ElectronNET.AspNet/Middleware/ElectronAuthenticationMiddleware.cs b/src/ElectronNET.AspNet/Middleware/ElectronAuthenticationMiddleware.cs index e93b837..bda0272 100644 --- a/src/ElectronNET.AspNet/Middleware/ElectronAuthenticationMiddleware.cs +++ b/src/ElectronNET.AspNet/Middleware/ElectronAuthenticationMiddleware.cs @@ -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 /// public class ElectronAuthenticationMiddleware { diff --git a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetBase.cs b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetBase.cs index 35ebc1a..47947b9 100644 --- a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetBase.cs +++ b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetBase.cs @@ -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(); diff --git a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirst.cs b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirst.cs index 4c76291..5a73ee0 100644 --- a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirst.cs +++ b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirst.cs @@ -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) diff --git a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirstSignalR.cs b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirstSignalR.cs deleted file mode 100644 index 593ff4c..0000000 --- a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetDotnetFirstSignalR.cs +++ /dev/null @@ -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; - - /// - /// 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 - /// - internal class RuntimeControllerAspNetDotnetFirstSignalR : RuntimeControllerAspNetBase - { - private ElectronProcessBase electronProcess; - private readonly IServer server; - private readonly IHubContext 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 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(); - 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(); - } - } -} diff --git a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetElectronFirst.cs b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetElectronFirst.cs index c9eb069..54a3e93 100644 --- a/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetElectronFirst.cs +++ b/src/ElectronNET.AspNet/Runtime/Controllers/RuntimeControllerAspNetElectronFirst.cs @@ -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; diff --git a/src/ElectronNET.Host/api/signalr-bridge.js b/src/ElectronNET.Host/api/signalr-bridge.js deleted file mode 100644 index 9d94e28..0000000 --- a/src/ElectronNET.Host/api/signalr-bridge.js +++ /dev/null @@ -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 }; diff --git a/src/ElectronNET.Host/main.js b/src/ElectronNET.Host/main.js index 9a4188a..6f22906 100644 --- a/src/ElectronNET.Host/main.js +++ b/src/ElectronNET.Host/main.js @@ -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(); diff --git a/src/ElectronNET.Host/package.json b/src/ElectronNET.Host/package.json index cb8f06a..519dff5 100644 --- a/src/ElectronNET.Host/package.json +++ b/src/ElectronNET.Host/package.json @@ -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" }, diff --git a/src/ElectronNET.Samples.BlazorSignalR/Program.cs b/src/ElectronNET.Samples.BlazorSignalR/Program.cs index 3cb9792..225a772 100644 --- a/src/ElectronNET.Samples.BlazorSignalR/Program.cs +++ b/src/ElectronNET.Samples.BlazorSignalR/Program.cs @@ -85,9 +85,6 @@ app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: // UseAntiforgery must be after UseRouting app.UseAntiforgery(); -// Map SignalR hub for Electron communication -app.MapHub("/electron-hub"); - app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); diff --git a/src/ElectronNET.Samples.BlazorSignalR/Properties/launchSettings.json b/src/ElectronNET.Samples.BlazorSignalR/Properties/launchSettings.json index a30f292..6d8e62d 100644 --- a/src/ElectronNET.Samples.BlazorSignalR/Properties/launchSettings.json +++ b/src/ElectronNET.Samples.BlazorSignalR/Properties/launchSettings.json @@ -7,8 +7,7 @@ "launchBrowser": false, "applicationUrl": "http://localhost:0", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ELECTRON_USE_SIGNALR": "true" + "ASPNETCORE_ENVIRONMENT": "Development" } } }