2025-10-13 13:24:28 +02:00
|
|
|
|
namespace ElectronNET.Runtime.Services.ElectronProcess
|
|
|
|
|
|
{
|
|
|
|
|
|
using System;
|
2026-05-09 17:03:32 +02:00
|
|
|
|
using System.Collections.Generic;
|
2025-10-13 13:24:28 +02:00
|
|
|
|
using System.ComponentModel;
|
2026-05-09 17:03:32 +02:00
|
|
|
|
using System.Diagnostics;
|
2025-10-13 13:24:28 +02:00
|
|
|
|
using System.IO;
|
2025-12-15 12:12:05 +01:00
|
|
|
|
using System.Linq;
|
2025-11-15 09:31:19 +01:00
|
|
|
|
using System.Runtime.InteropServices;
|
2026-05-09 17:03:32 +02:00
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
|
using System.Threading;
|
2025-10-13 13:24:28 +02:00
|
|
|
|
using System.Threading.Tasks;
|
2025-12-15 12:12:05 +01:00
|
|
|
|
using ElectronNET.Common;
|
|
|
|
|
|
using ElectronNET.Runtime.Data;
|
2025-10-13 13:24:28 +02:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Launches and manages the Electron app process.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Localizable(false)]
|
|
|
|
|
|
internal class ElectronProcessActive : ElectronProcessBase
|
|
|
|
|
|
{
|
2026-05-09 17:03:32 +02:00
|
|
|
|
private const string AuthTokenEnvVar = "ELECTRONNET_AUTH_TOKEN";
|
|
|
|
|
|
private const string StartupInfoEnvVar = "ELECTRONNET_STARTUP_INFO";
|
|
|
|
|
|
|
2025-10-13 13:24:28 +02:00
|
|
|
|
private readonly bool isUnpackaged;
|
|
|
|
|
|
private readonly string electronBinaryName;
|
|
|
|
|
|
private readonly string extraArguments;
|
|
|
|
|
|
private readonly int socketPort;
|
|
|
|
|
|
private ProcessRunner process;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="ElectronProcessActive"/> class.</summary>
|
|
|
|
|
|
/// <param name="isUnpackaged">The is debug.</param>
|
|
|
|
|
|
/// <param name="electronBinaryName">Name of the electron.</param>
|
|
|
|
|
|
/// <param name="extraArguments">The extraArguments.</param>
|
|
|
|
|
|
/// <param name="socketPort">The socket port.</param>
|
|
|
|
|
|
public ElectronProcessActive(bool isUnpackaged, string electronBinaryName, string extraArguments, int socketPort)
|
|
|
|
|
|
{
|
|
|
|
|
|
this.isUnpackaged = isUnpackaged;
|
|
|
|
|
|
this.electronBinaryName = electronBinaryName;
|
|
|
|
|
|
this.extraArguments = extraArguments;
|
|
|
|
|
|
this.socketPort = socketPort;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 12:12:05 +01:00
|
|
|
|
protected override async Task StartCore()
|
2025-10-13 13:24:28 +02:00
|
|
|
|
{
|
|
|
|
|
|
var dir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
|
|
|
|
|
|
string startCmd, args, workingDir;
|
|
|
|
|
|
|
|
|
|
|
|
if (this.isUnpackaged)
|
|
|
|
|
|
{
|
2025-12-15 12:12:42 +01:00
|
|
|
|
this.CheckRuntimeIdentifier();
|
|
|
|
|
|
|
2025-10-13 13:24:28 +02:00
|
|
|
|
var electrondir = Path.Combine(dir.FullName, ".electron");
|
2025-12-15 12:12:05 +01:00
|
|
|
|
|
|
|
|
|
|
ProcessRunner chmodRunner = null;
|
|
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
|
|
|
|
{
|
|
|
|
|
|
var distFolder = Path.Combine(electrondir, "node_modules", "electron", "dist");
|
|
|
|
|
|
|
|
|
|
|
|
chmodRunner = new ProcessRunner("ElectronRunner-Chmod");
|
|
|
|
|
|
chmodRunner.Run("chmod", "-R +x " + distFolder, electrondir);
|
|
|
|
|
|
await chmodRunner.WaitForExitAsync().ConfigureAwait(true);
|
|
|
|
|
|
|
|
|
|
|
|
if (chmodRunner.LastExitCode != 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new Exception("Failed to set executable permissions on Electron dist folder.");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
Console.Error.WriteLine("[StartCore]: Exception: " + chmodRunner?.StandardError);
|
|
|
|
|
|
Console.Error.WriteLine("[StartCore]: Exception: " + chmodRunner?.StandardOutput);
|
|
|
|
|
|
Console.Error.WriteLine("[StartCore]: Exception: " + ex);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-13 13:24:28 +02:00
|
|
|
|
startCmd = Path.Combine(electrondir, "node_modules", "electron", "dist", "electron");
|
|
|
|
|
|
|
2025-11-15 09:31:19 +01:00
|
|
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
|
|
|
|
{
|
|
|
|
|
|
startCmd = Path.Combine(electrondir, "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", "Electron");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-13 13:24:28 +02:00
|
|
|
|
args = $"main.js -unpackeddotnet --trace-warnings -electronforcedport={this.socketPort:D} " + this.extraArguments;
|
|
|
|
|
|
workingDir = electrondir;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
2025-12-15 12:12:05 +01:00
|
|
|
|
dir = dir.Parent!.Parent!;
|
2025-10-13 13:24:28 +02:00
|
|
|
|
startCmd = Path.Combine(dir.FullName, this.electronBinaryName);
|
|
|
|
|
|
args = $"-dotnetpacked -electronforcedport={this.socketPort:D} " + this.extraArguments;
|
|
|
|
|
|
workingDir = dir.FullName;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 17:03:32 +02:00
|
|
|
|
// Generate the auth token on the .NET side (256 bit entropy) and pass it
|
|
|
|
|
|
// to Electron via an environment variable. Electron will report the
|
|
|
|
|
|
// OS-selected port via a temporary handshake file - this avoids any
|
|
|
|
|
|
// dependency on parsing Electron's console output.
|
|
|
|
|
|
var authToken = CreateAuthToken();
|
|
|
|
|
|
var startupInfoPath = Path.Combine(
|
|
|
|
|
|
Path.GetTempPath(),
|
|
|
|
|
|
$"electronnet-startup-{Environment.ProcessId}-{Guid.NewGuid():N}.json");
|
|
|
|
|
|
|
2025-10-13 13:24:28 +02:00
|
|
|
|
// We don't await this in order to let the state transition to "Starting"
|
2026-05-09 17:03:32 +02:00
|
|
|
|
Task.Run(async () => await this.StartInternal(startCmd, args, workingDir, authToken, startupInfoPath).ConfigureAwait(false));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string CreateAuthToken()
|
|
|
|
|
|
{
|
|
|
|
|
|
var bytes = RandomNumberGenerator.GetBytes(32);
|
|
|
|
|
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
2025-12-15 12:12:42 +01:00
|
|
|
|
}
|
2025-10-13 13:24:28 +02:00
|
|
|
|
|
2025-12-15 12:12:42 +01:00
|
|
|
|
private void CheckRuntimeIdentifier()
|
|
|
|
|
|
{
|
|
|
|
|
|
var buildInfoRid = ElectronNetRuntime.BuildInfo.RuntimeIdentifier;
|
|
|
|
|
|
if (string.IsNullOrEmpty(buildInfoRid))
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var osPart = buildInfoRid.Split('-').First();
|
|
|
|
|
|
var mismatch = false;
|
|
|
|
|
|
|
|
|
|
|
|
switch (osPart)
|
|
|
|
|
|
{
|
|
|
|
|
|
case "win":
|
|
|
|
|
|
|
|
|
|
|
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
|
|
|
|
{
|
|
|
|
|
|
mismatch = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case "linux":
|
|
|
|
|
|
|
|
|
|
|
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
|
|
|
|
|
{
|
|
|
|
|
|
mismatch = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case "osx":
|
|
|
|
|
|
|
|
|
|
|
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
|
|
|
|
{
|
|
|
|
|
|
mismatch = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case "freebsd":
|
|
|
|
|
|
|
|
|
|
|
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD))
|
|
|
|
|
|
{
|
|
|
|
|
|
mismatch = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (mismatch)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new PlatformNotSupportedException($"This Electron.NET application was built for '{buildInfoRid}'. It cannot run on this platform.");
|
|
|
|
|
|
}
|
2025-10-13 13:24:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
protected override Task StopCore()
|
|
|
|
|
|
{
|
|
|
|
|
|
this.process.Cancel();
|
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 17:03:32 +02:00
|
|
|
|
private async Task StartInternal(string startCmd, string args, string directoriy, string authToken, string startupInfoPath)
|
2025-10-13 13:24:28 +02:00
|
|
|
|
{
|
2026-05-09 17:03:32 +02:00
|
|
|
|
var tcs = new TaskCompletionSource();
|
|
|
|
|
|
using var cts = new CancellationTokenSource(2 * 60_000); // cancel after 2 minutes
|
|
|
|
|
|
using var _ = cts.Token.Register(() =>
|
|
|
|
|
|
{
|
|
|
|
|
|
// Time is over - let's kill the process and move on
|
|
|
|
|
|
this.process.Cancel();
|
|
|
|
|
|
// We don't want to raise exceptions here - just pass the barrier
|
|
|
|
|
|
tcs.TrySetResult();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
void Monitor_SocketIO_Failure(object sender, EventArgs e)
|
2025-11-15 09:31:19 +01:00
|
|
|
|
{
|
2026-05-09 17:03:32 +02:00
|
|
|
|
// We don't want to raise exceptions here - just pass the barrier
|
|
|
|
|
|
if (tcs.Task.IsCompleted)
|
|
|
|
|
|
{
|
|
|
|
|
|
this.Process_Exited(sender, e);
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
tcs.TrySetResult();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-13 13:24:28 +02:00
|
|
|
|
|
2026-05-09 17:03:32 +02:00
|
|
|
|
try
|
|
|
|
|
|
{
|
2025-11-15 09:31:19 +01:00
|
|
|
|
Console.Error.WriteLine("[StartInternal]: startCmd: {0}", startCmd);
|
|
|
|
|
|
Console.Error.WriteLine("[StartInternal]: args: {0}", args);
|
2025-10-13 13:24:28 +02:00
|
|
|
|
|
2025-11-15 09:31:19 +01:00
|
|
|
|
this.process = new ProcessRunner("ElectronRunner");
|
2026-05-09 17:03:32 +02:00
|
|
|
|
this.process.ProcessExited += Monitor_SocketIO_Failure;
|
2025-10-13 13:24:28 +02:00
|
|
|
|
|
2026-05-09 17:03:32 +02:00
|
|
|
|
var env = new Dictionary<string, string>
|
|
|
|
|
|
{
|
|
|
|
|
|
[AuthTokenEnvVar] = authToken,
|
|
|
|
|
|
[StartupInfoEnvVar] = startupInfoPath,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
this.process.Run(startCmd, args, directoriy, env);
|
|
|
|
|
|
|
|
|
|
|
|
// Wait for Electron to write the startup-info file (or for the process to die / timeout).
|
|
|
|
|
|
var waitTask = WaitForStartupInfoAsync(startupInfoPath, cts.Token);
|
|
|
|
|
|
var completed = await Task.WhenAny(waitTask, tcs.Task).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
|
|
int port = 0;
|
|
|
|
|
|
if (completed == waitTask && waitTask.Status == TaskStatus.RanToCompletion)
|
|
|
|
|
|
{
|
|
|
|
|
|
port = waitTask.Result;
|
|
|
|
|
|
}
|
2025-10-13 13:24:28 +02:00
|
|
|
|
|
2025-11-15 09:31:19 +01:00
|
|
|
|
Console.Error.WriteLine("[StartInternal]: after run:");
|
|
|
|
|
|
|
|
|
|
|
|
if (!this.process.IsRunning)
|
|
|
|
|
|
{
|
|
|
|
|
|
Console.Error.WriteLine("[StartInternal]: Process is not running: " + this.process.StandardError);
|
|
|
|
|
|
Console.Error.WriteLine("[StartInternal]: Process is not running: " + this.process.StandardOutput);
|
2025-10-13 13:24:28 +02:00
|
|
|
|
|
2025-11-15 09:31:19 +01:00
|
|
|
|
Task.Run(() => this.TransitionState(LifetimeState.Stopped));
|
|
|
|
|
|
}
|
2026-05-09 17:03:32 +02:00
|
|
|
|
else if (port > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
ElectronNetRuntime.ElectronAuthToken = authToken;
|
|
|
|
|
|
ElectronNetRuntime.ElectronSocketPort = port;
|
|
|
|
|
|
this.TransitionState(LifetimeState.Ready);
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
Console.Error.WriteLine("[StartInternal]: Did not receive Electron startup info before process exit/timeout.");
|
|
|
|
|
|
Task.Run(() => this.TransitionState(LifetimeState.Stopped));
|
|
|
|
|
|
}
|
2025-11-15 09:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
Console.Error.WriteLine("[StartInternal]: Exception: " + this.process?.StandardError);
|
|
|
|
|
|
Console.Error.WriteLine("[StartInternal]: Exception: " + this.process?.StandardOutput);
|
|
|
|
|
|
Console.Error.WriteLine("[StartInternal]: Exception: " + ex);
|
|
|
|
|
|
throw;
|
|
|
|
|
|
}
|
2026-05-09 17:03:32 +02:00
|
|
|
|
finally
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
if (File.Exists(startupInfoPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
File.Delete(startupInfoPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
// best effort cleanup
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static async Task<int> WaitForStartupInfoAsync(string startupInfoPath, CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
if (File.Exists(startupInfoPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
var json = await File.ReadAllTextAsync(startupInfoPath, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(json))
|
|
|
|
|
|
{
|
|
|
|
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
|
|
if (doc.RootElement.TryGetProperty("port", out var portElement) &&
|
|
|
|
|
|
portElement.TryGetInt32(out var port) &&
|
|
|
|
|
|
port > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
return port;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (JsonException)
|
|
|
|
|
|
{
|
|
|
|
|
|
// File may be partially written / racing with the writer - retry.
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (IOException)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Same - transient races on file access; retry.
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (TaskCanceledException)
|
|
|
|
|
|
{
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return 0;
|
2025-10-13 13:24:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void Process_Exited(object sender, EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
this.TransitionState(LifetimeState.Stopped);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|