From 90b4a287d9d1c61266e23b6317258154061aeb74 Mon Sep 17 00:00:00 2001 From: theolivenbaum Date: Thu, 14 Jul 2022 16:56:50 +0200 Subject: [PATCH] Add experimental mode to start electron from C# (for development purposes) --- ElectronNET.API/BridgeConnector.cs | 13 +- ElectronNET.API/Electron.Experimental.cs | 142 ++++++++++++++++++ ElectronNET.API/Electron.cs | 9 +- ElectronNET.API/ProcessHelper.cs | 37 +++++ ElectronNET.Host/main.js | 49 ++++-- ElectronNET.WebApp/Program.cs | 31 +++- .../Properties/launchSettings.json | 47 +++--- ElectronNET.WebApp/Startup.cs | 5 +- 8 files changed, 288 insertions(+), 45 deletions(-) create mode 100644 ElectronNET.API/Electron.Experimental.cs create mode 100644 ElectronNET.API/ProcessHelper.cs diff --git a/ElectronNET.API/BridgeConnector.cs b/ElectronNET.API/BridgeConnector.cs index 0e527c7..66df3cd 100644 --- a/ElectronNET.API/BridgeConnector.cs +++ b/ElectronNET.API/BridgeConnector.cs @@ -476,6 +476,13 @@ namespace ElectronNET.API Log("ElectronNET socket {1} failed to connect {0}", ex, socket.Id); }; + + socket.OnReconnectFailed += (_, ex) => + { + _connectedSocketEvent.Reset(); + Log("ElectronNET socket {1} failed to reconnect {0}", ex, socket.Id); + }; + socket.OnReconnected += (_, __) => { _connectedSocketEvent.Set(); @@ -490,8 +497,8 @@ namespace ElectronNET.API socket.OnError += (_, msg) => { - //_connectedSocketEvent.Reset(); - Log("ElectronNET socket {1} error: {0}...", msg, socket.Id); + //_connectedSocketEvent.Reset(); + Log("ElectronNET socket {1} error: {0}...", msg, socket.Id); }; _socket = socket; @@ -530,7 +537,7 @@ namespace ElectronNET.API } internal static ILogger Logger { private get; set; } - internal static string AuthKey { private get; set; } + internal static string AuthKey { get; set; } = null; private class CamelCaseNewtonsoftJsonSerializer : NewtonsoftJsonSerializer { diff --git a/ElectronNET.API/Electron.Experimental.cs b/ElectronNET.API/Electron.Experimental.cs new file mode 100644 index 0000000..4bc6763 --- /dev/null +++ b/ElectronNET.API/Electron.Experimental.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Net.Sockets; +using System.Net; +using System.Threading.Tasks; + +namespace ElectronNET.API +{ + public static partial class Electron + { + /// + /// Experimental code, use with care + /// + public static class Experimental + { + /// + /// Starts electron from C#, use during development to avoid having to fully publish / build your app on every compile cycle + /// You will need to run the CLI at least once (and once per update) to bootstrap all required files + /// + /// + /// + /// + /// + /// + /// + public static async Task StartElectronForDevelopment(int webPort, string projectPath = null, string[] extraElectronArguments = null, bool clearCache = false) + { + string aspCoreProjectPath; + + if (!string.IsNullOrEmpty(projectPath)) + { + if (Directory.Exists(projectPath)) + { + aspCoreProjectPath = projectPath; + } + else + { + throw new DirectoryNotFoundException(projectPath); + } + } + else + { + aspCoreProjectPath = Directory.GetCurrentDirectory(); + } + + string tempPath = Path.Combine(aspCoreProjectPath, "obj", "Host"); + + if (!Directory.Exists(tempPath)) + { + Directory.CreateDirectory(tempPath); + } + + var mainFileJs = Path.Combine(tempPath, "main.js"); + if (!File.Exists(mainFileJs)) + { + throw new Exception("You need to run once the electronize-h5 start command to bootstrap the necessary files"); + } + + var nodeModulesDirPath = Path.Combine(tempPath, "node_modules"); + + bool runNpmInstall = false; + + if (!Directory.Exists(nodeModulesDirPath)) + { + runNpmInstall = true; + } + + var packagesJson = Path.Combine(tempPath, "package.json"); + + var packagesPrevious = Path.Combine(tempPath, "package.json.previous"); + + if (!runNpmInstall) + { + + if (File.Exists(packagesPrevious)) + { + if (File.ReadAllText(packagesPrevious) != File.ReadAllText(packagesJson)) + { + runNpmInstall = true; + } + } + else + { + runNpmInstall = true; + } + } + + if (runNpmInstall) + { + throw new Exception("You need to run once the electronize-h5 start command to bootstrap the necessary files"); + } + + string arguments = ""; + + if (extraElectronArguments is object) + { + arguments = string.Join(' ', extraElectronArguments); + } + + if (clearCache) + { + arguments += " --clear-cache=true"; + } + + BridgeConnector.AuthKey = Guid.NewGuid().ToString().Replace("-", ""); + + var socketPort = FreeTcpPort(); + + arguments += $" --development=true --devauth={BridgeConnector.AuthKey} --devport={socketPort}"; + + string path = Path.Combine(tempPath, "node_modules", ".bin"); + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + if (isWindows) + { + ProcessHelper.Execute(@"electron.cmd ""..\..\main.js"" " + arguments, path); + } + else + { + ProcessHelper.Execute(@"./electron ""../../main.js"" " + arguments, path); + } + + BridgeSettings.InitializePorts(socketPort, webPort); + await Task.Delay(500); + } + + /// + /// Return a free local TCP port + /// + /// + public static int FreeTcpPort() + { + TcpListener l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + int port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } + } + } +} \ No newline at end of file diff --git a/ElectronNET.API/Electron.cs b/ElectronNET.API/Electron.cs index cf17103..34faf33 100644 --- a/ElectronNET.API/Electron.cs +++ b/ElectronNET.API/Electron.cs @@ -1,13 +1,14 @@ using Microsoft.Extensions.Logging; using System.Runtime.Versioning; using System; +using System.Collections.Generic; namespace ElectronNET.API { /// /// The Electron.NET API /// - public static class Electron + public static partial class Electron { private static ILoggerFactory loggerFactory; @@ -17,7 +18,13 @@ namespace ElectronNET.API /// public static void ReadAuth() { + if (!string.IsNullOrEmpty(BridgeConnector.AuthKey)) + { + throw new Exception($"Don't call ReadAuth twice or from with {nameof(Experimental)}.{nameof(Experimental.StartElectronForDevelopment)}"); + } + var line = Console.ReadLine(); + if(line.StartsWith("Auth=")) { BridgeConnector.AuthKey = line.Substring("Auth=".Length); diff --git a/ElectronNET.API/ProcessHelper.cs b/ElectronNET.API/ProcessHelper.cs new file mode 100644 index 0000000..08fedc2 --- /dev/null +++ b/ElectronNET.API/ProcessHelper.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace ElectronNET.API +{ + internal class ProcessHelper + { + public static void Execute(string command, string workingDirectoryPath) + { + using (Process cmd = new Process()) + { + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + if (isWindows) + { + cmd.StartInfo = new ProcessStartInfo("cmd.exe", "/c " + command); + } + else + { + // works for OSX and Linux (at least on Ubuntu) + var escapedArgs = command.Replace("\"", "\\\""); + cmd.StartInfo = new ProcessStartInfo("bash", $"-c \"{escapedArgs}\""); + } + + cmd.StartInfo.RedirectStandardInput = false; + cmd.StartInfo.RedirectStandardOutput = false; + cmd.StartInfo.RedirectStandardError = false; + cmd.StartInfo.CreateNoWindow = true; + cmd.StartInfo.UseShellExecute = false; + cmd.StartInfo.WorkingDirectory = workingDirectoryPath; + cmd.Start(); + } + } + } +} diff --git a/ElectronNET.Host/main.js b/ElectronNET.Host/main.js index e5101c0..402cbdb 100644 --- a/ElectronNET.Host/main.js +++ b/ElectronNET.Host/main.js @@ -9,7 +9,7 @@ const crypto = require('crypto'); fixPath(); //For macOS and Linux packaged-apps, the path variable might be missing -const auth = crypto.randomBytes(32).toString('hex'); +let auth = crypto.randomBytes(32).toString('hex'); let io, server, browserWindows, ipc, apiProcess, loadURL; let appApi, menu, dialogApi, notification, tray, webContents; @@ -25,6 +25,8 @@ let ignoreApiProcessClosed = false; let manifestJsonFileName = 'electron.manifest.json'; let watchable = false; +let development = false; + if (app.commandLine.hasSwitch('manifest')) { manifestJsonFileName = app.commandLine.getSwitchValue('manifest'); }; @@ -33,6 +35,10 @@ if (app.commandLine.hasSwitch('watch')) { watchable = true; }; +if (app.commandLine.hasSwitch('development')) { + development = true; +}; + let currentBinPath = path.join(__dirname.replace('app.asar', ''), 'bin'); let manifestJsonFilePath = path.join(currentBinPath, manifestJsonFileName); @@ -42,6 +48,10 @@ if (watchable) { manifestJsonFilePath = path.join(currentBinPath, manifestJsonFileName); } +if (development) { + auth = app.commandLine.getSwitchValue("devauth"); +} + // handle macOS events for opening the app with a file, etc app.on('will-finish-launching', () => { app.on('open-file', (evt, file) => { @@ -136,19 +146,27 @@ app.on('ready', () => { if (isSplashScreenEnabled()) { startSplashScreen(); } - // Added default port as configurable for port restricted environments. - let defaultElectronPort = 8000; - if (manifestJsonFile.electronPort) { - defaultElectronPort = (manifestJsonFile.electronPort) - if (defaultElectronPort == 'random') { - defaultElectronPort = (Math.floor(Math.random() * 2000 + 8000)); //Use random port to reduce risk of race conditions between when we find a free port here, and when the app locks on the port - } - } - // hostname needs to be localhost, otherwise Windows Firewall will be triggered. - portscanner.findAPortNotInUse(defaultElectronPort, 65535, 'localhost', function (error, port) { + + if (development) { + let port = parseInt(app.commandLine.getSwitchValue('devport')); try { console.log('Electron Socket IO Port: ' + port); } catch { } startSocketApiBridge(port); - }); + } else { + + // Added default port as configurable for port restricted environments. + let defaultElectronPort = 8000; + if (manifestJsonFile.electronPort) { + defaultElectronPort = (manifestJsonFile.electronPort) + if (defaultElectronPort == 'random') { + defaultElectronPort = (Math.floor(Math.random() * 2000 + 8000)); //Use random port to reduce risk of race conditions between when we find a free port here, and when the app locks on the port + } + } + // hostname needs to be localhost, otherwise Windows Firewall will be triggered. + portscanner.findAPortNotInUse(defaultElectronPort, 65535, 'localhost', function (error, port) { + try { console.log('Electron Socket IO Port: ' + port); } catch { } + startSocketApiBridge(port); + }); + } }); app.on('quit', async (event, exitCode) => { @@ -268,8 +286,11 @@ function startSocketApiBridge(port) { server.on('listening', function () { try { console.log('Electron Socket started on port %s at %s', server.address().port, server.address().address); } catch { } // Now that socket connection is established, we can guarantee port will not be open for portscanner + if (watchable) { startAspCoreBackendWithWatch(port); + } else if (development) { + //Nothing else to do } else { startAspCoreBackend(port); } @@ -283,7 +304,8 @@ function startSocketApiBridge(port) { let checkReconnectTimeout = 0; // @ts-ignore io.on('connection', (socket) => { - + try { console.log('Socket ' + socket.id + ' connected from .NET'); } catch { } + isConnected = true; clearTimeout(checkReconnectTimeout); @@ -311,7 +333,6 @@ function startSocketApiBridge(port) { }); socket.on("auth", function (authKey) { - if (authKey != auth) { throw new Error("Invalid auth key"); } diff --git a/ElectronNET.WebApp/Program.cs b/ElectronNET.WebApp/Program.cs index 5af0fa6..77ced2f 100644 --- a/ElectronNET.WebApp/Program.cs +++ b/ElectronNET.WebApp/Program.cs @@ -2,24 +2,49 @@ using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; +using System; using System.Diagnostics; +using System.IO; +using Microsoft.Extensions.DependencyInjection; +using System.Threading.Tasks; namespace ElectronNET.WebApp { public class Program { - public static void Main(string[] args) + public static async Task Main(string[] args) { + IWebHostBuilder builder; + +#if DEBUG + var webPort = Electron.Experimental.FreeTcpPort(); + + await Electron.Experimental.StartElectronForDevelopment(webPort); + + builder = CreateWebHostBuilder(args); + // check for the content folder if its exists in base director otherwise no need to include + // It was used before because we are publishing the project which copies everything to bin folder and contentroot wwwroot was folder there. + // now we have implemented the live reload if app is run using /watch then we need to use the default project path. + if (Directory.Exists($"{AppDomain.CurrentDomain.BaseDirectory}\\wwwroot")) + { + builder.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory); + } + + builder.UseUrls("http://localhost:" + webPort); +#else + builder = CreateWebHostBuilder(args); Debugger.Launch(); Electron.ReadAuth(); - CreateWebHostBuilder(args).Build().Run(); + builder.UseElectron(args); +#endif + + await builder.Build().RunAsync(); } public static IWebHostBuilder CreateWebHostBuilder(string[] args) { return WebHost.CreateDefaultBuilder(args) .ConfigureLogging((hostingContext, logging) => { logging.AddConsole(); }) - .UseElectron(args) .UseStartup(); } } diff --git a/ElectronNET.WebApp/Properties/launchSettings.json b/ElectronNET.WebApp/Properties/launchSettings.json index 32efbcd..ae9cd85 100644 --- a/ElectronNET.WebApp/Properties/launchSettings.json +++ b/ElectronNET.WebApp/Properties/launchSettings.json @@ -1,4 +1,29 @@ { + "profiles": { + "WSL": { + "commandName": "WSL2", + "launchUrl": "http://localhost:50395/", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:50395/" + }, + "distributionName": "" + }, + "run with electronize": { + "commandName": "Executable", + "executablePath": "$(SolutionDir)ElectronNET.CLI\\bin\\Debug\\net6.0\\dotnet-electronize-h5.exe", + "commandLineArgs": "start /from-build-output $(SolutionDir)ElectronNET.WebApp\\bin\\$(Configuration)\\net6.0", + "workingDirectory": "$(SolutionDir)ElectronNET.WebApp" + }, + "run from csharp": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:50395/", + "hotReloadEnabled": false + } + }, "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, @@ -6,27 +31,5 @@ "applicationUrl": "http://localhost:50394/", "sslPort": 0 } - }, - "profiles": { - "Electron.NET App": { - "commandName": "Executable", - "executablePath": "$(SolutionDir)ElectronNET.CLI\\bin\\Debug\\net5.0\\dotnet-electronize-h5.exe", - "commandLineArgs": "start /from-build-output $(SolutionDir)ElectronNET.WebApp\\bin\\$(Configuration)\\net5.0", - "workingDirectory": "$(SolutionDir)ElectronNET.WebApp" - }, - "IIS Express": { - "commandName": "IISExpress", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "ElectronNET.WebApp": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "http://localhost:50395/" - } } } \ No newline at end of file diff --git a/ElectronNET.WebApp/Startup.cs b/ElectronNET.WebApp/Startup.cs index f6c0c33..34a1005 100644 --- a/ElectronNET.WebApp/Startup.cs +++ b/ElectronNET.WebApp/Startup.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using System.Threading.Tasks; namespace ElectronNET.WebApp { @@ -43,11 +44,11 @@ namespace ElectronNET.WebApp if (HybridSupport.IsElectronActive) { - ElectronBootstrap(); + Task.Run(() => ElectronBootstrap()); } } - public async void ElectronBootstrap() + public async Task ElectronBootstrap() { //AddDevelopmentTests();