Skip to content

Commit 9c89d84

Browse files
Enhance socket handling with environment variable support for auth token and startup info
1 parent 91f76f8 commit 9c89d84

3 files changed

Lines changed: 152 additions & 31 deletions

File tree

src/ElectronNET.API/Common/ProcessRunner.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
namespace ElectronNET.Common
22
{
33
using System;
4+
using System.Collections.Generic;
45
using System.Diagnostics;
56
using System.Diagnostics.CodeAnalysis;
67
using System.Text;
@@ -111,6 +112,11 @@ public string StandardError
111112
public int? LastExitCode { get; private set; }
112113

113114
public bool Run(string exeFileName, string commandLineArgs, string workingDirectory)
115+
{
116+
return this.Run(exeFileName, commandLineArgs, workingDirectory, null);
117+
}
118+
119+
public bool Run(string exeFileName, string commandLineArgs, string workingDirectory, IDictionary<string, string> environmentVariables)
114120
{
115121
this.CommandLine = commandLineArgs;
116122
this.WorkingFolder = workingDirectory;
@@ -128,6 +134,14 @@ public bool Run(string exeFileName, string commandLineArgs, string workingDirect
128134
WorkingDirectory = workingDirectory
129135
};
130136

137+
if (environmentVariables != null)
138+
{
139+
foreach (var kv in environmentVariables)
140+
{
141+
startInfo.EnvironmentVariables[kv.Key] = kv.Value;
142+
}
143+
}
144+
131145
return this.Run(startInfo);
132146
}
133147

src/ElectronNET.API/Runtime/Services/ElectronProcess/ElectronProcessActive.cs

Lines changed: 107 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
namespace ElectronNET.Runtime.Services.ElectronProcess
22
{
33
using System;
4+
using System.Collections.Generic;
45
using System.ComponentModel;
56
using System.Diagnostics;
67
using System.IO;
78
using System.Linq;
89
using System.Runtime.InteropServices;
9-
using System.Text.RegularExpressions;
10+
using System.Security.Cryptography;
11+
using System.Text.Json;
1012
using System.Threading;
1113
using System.Threading.Tasks;
1214
using ElectronNET.Common;
@@ -18,7 +20,9 @@
1820
[Localizable(false)]
1921
internal class ElectronProcessActive : ElectronProcessBase
2022
{
21-
private readonly Regex extractor = new Regex("^Electron Socket: listening on port (\\d+) at .* using ([a-f0-9]+)$");
23+
private const string AuthTokenEnvVar = "ELECTRONNET_AUTH_TOKEN";
24+
private const string StartupInfoEnvVar = "ELECTRONNET_STARTUP_INFO";
25+
2226
private readonly bool isUnpackaged;
2327
private readonly string electronBinaryName;
2428
private readonly string extraArguments;
@@ -92,8 +96,23 @@ protected override async Task StartCore()
9296
workingDir = dir.FullName;
9397
}
9498

99+
// Generate the auth token on the .NET side (256 bit entropy) and pass it
100+
// to Electron via an environment variable. Electron will report the
101+
// OS-selected port via a temporary handshake file - this avoids any
102+
// dependency on parsing Electron's console output.
103+
var authToken = CreateAuthToken();
104+
var startupInfoPath = Path.Combine(
105+
Path.GetTempPath(),
106+
$"electronnet-startup-{Environment.ProcessId}-{Guid.NewGuid():N}.json");
107+
95108
// We don't await this in order to let the state transition to "Starting"
96-
Task.Run(async () => await this.StartInternal(startCmd, args, workingDir).ConfigureAwait(false));
109+
Task.Run(async () => await this.StartInternal(startCmd, args, workingDir, authToken, startupInfoPath).ConfigureAwait(false));
110+
}
111+
112+
private static string CreateAuthToken()
113+
{
114+
var bytes = RandomNumberGenerator.GetBytes(32);
115+
return Convert.ToHexString(bytes).ToLowerInvariant();
97116
}
98117

99118
private void CheckRuntimeIdentifier()
@@ -158,7 +177,7 @@ protected override Task StopCore()
158177
return Task.CompletedTask;
159178
}
160179

161-
private async Task StartInternal(string startCmd, string args, string directoriy)
180+
private async Task StartInternal(string startCmd, string args, string directoriy, string authToken, string startupInfoPath)
162181
{
163182
var tcs = new TaskCompletionSource();
164183
using var cts = new CancellationTokenSource(2 * 60_000); // cancel after 2 minutes
@@ -167,26 +186,9 @@ private async Task StartInternal(string startCmd, string args, string directoriy
167186
// Time is over - let's kill the process and move on
168187
this.process.Cancel();
169188
// We don't want to raise exceptions here - just pass the barrier
170-
tcs.SetResult();
189+
tcs.TrySetResult();
171190
});
172191

173-
void Read_SocketIO_Parameters(object sender, string line)
174-
{
175-
// Look for "Electron Socket: listening on port %s at ..."
176-
var match = extractor.Match(line);
177-
178-
if (match?.Success ?? false)
179-
{
180-
var port = int.Parse(match.Groups[1].Value);
181-
var token = match.Groups[2].Value;
182-
183-
this.process.LineReceived -= Read_SocketIO_Parameters;
184-
ElectronNetRuntime.ElectronAuthToken = token;
185-
ElectronNetRuntime.ElectronSocketPort = port;
186-
tcs.SetResult();
187-
}
188-
}
189-
190192
void Monitor_SocketIO_Failure(object sender, EventArgs e)
191193
{
192194
// We don't want to raise exceptions here - just pass the barrier
@@ -196,7 +198,7 @@ void Monitor_SocketIO_Failure(object sender, EventArgs e)
196198
}
197199
else
198200
{
199-
tcs.SetResult();
201+
tcs.TrySetResult();
200202
}
201203
}
202204

@@ -207,10 +209,24 @@ void Monitor_SocketIO_Failure(object sender, EventArgs e)
207209

208210
this.process = new ProcessRunner("ElectronRunner");
209211
this.process.ProcessExited += Monitor_SocketIO_Failure;
210-
this.process.LineReceived += Read_SocketIO_Parameters;
211-
this.process.Run(startCmd, args, directoriy);
212212

213-
await tcs.Task.ConfigureAwait(false);
213+
var env = new Dictionary<string, string>
214+
{
215+
[AuthTokenEnvVar] = authToken,
216+
[StartupInfoEnvVar] = startupInfoPath,
217+
};
218+
219+
this.process.Run(startCmd, args, directoriy, env);
220+
221+
// Wait for Electron to write the startup-info file (or for the process to die / timeout).
222+
var waitTask = WaitForStartupInfoAsync(startupInfoPath, cts.Token);
223+
var completed = await Task.WhenAny(waitTask, tcs.Task).ConfigureAwait(false);
224+
225+
int port = 0;
226+
if (completed == waitTask && waitTask.Status == TaskStatus.RanToCompletion)
227+
{
228+
port = waitTask.Result;
229+
}
214230

215231
Console.Error.WriteLine("[StartInternal]: after run:");
216232

@@ -221,10 +237,17 @@ void Monitor_SocketIO_Failure(object sender, EventArgs e)
221237

222238
Task.Run(() => this.TransitionState(LifetimeState.Stopped));
223239
}
224-
else
240+
else if (port > 0)
225241
{
242+
ElectronNetRuntime.ElectronAuthToken = authToken;
243+
ElectronNetRuntime.ElectronSocketPort = port;
226244
this.TransitionState(LifetimeState.Ready);
227245
}
246+
else
247+
{
248+
Console.Error.WriteLine("[StartInternal]: Did not receive Electron startup info before process exit/timeout.");
249+
Task.Run(() => this.TransitionState(LifetimeState.Stopped));
250+
}
228251
}
229252
catch (Exception ex)
230253
{
@@ -233,6 +256,63 @@ void Monitor_SocketIO_Failure(object sender, EventArgs e)
233256
Console.Error.WriteLine("[StartInternal]: Exception: " + ex);
234257
throw;
235258
}
259+
finally
260+
{
261+
try
262+
{
263+
if (File.Exists(startupInfoPath))
264+
{
265+
File.Delete(startupInfoPath);
266+
}
267+
}
268+
catch
269+
{
270+
// best effort cleanup
271+
}
272+
}
273+
}
274+
275+
private static async Task<int> WaitForStartupInfoAsync(string startupInfoPath, CancellationToken cancellationToken)
276+
{
277+
while (!cancellationToken.IsCancellationRequested)
278+
{
279+
try
280+
{
281+
if (File.Exists(startupInfoPath))
282+
{
283+
var json = await File.ReadAllTextAsync(startupInfoPath, cancellationToken).ConfigureAwait(false);
284+
if (!string.IsNullOrWhiteSpace(json))
285+
{
286+
using var doc = JsonDocument.Parse(json);
287+
if (doc.RootElement.TryGetProperty("port", out var portElement) &&
288+
portElement.TryGetInt32(out var port) &&
289+
port > 0)
290+
{
291+
return port;
292+
}
293+
}
294+
}
295+
}
296+
catch (JsonException)
297+
{
298+
// File may be partially written / racing with the writer - retry.
299+
}
300+
catch (IOException)
301+
{
302+
// Same - transient races on file access; retry.
303+
}
304+
305+
try
306+
{
307+
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
308+
}
309+
catch (TaskCanceledException)
310+
{
311+
break;
312+
}
313+
}
314+
315+
return 0;
236316
}
237317

238318
private void Process_Exited(object sender, EventArgs e)

src/ElectronNET.Host/main.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,14 @@ let unpackeddotnet = false;
2727
let dotnetpacked = false;
2828
let electronforcedport;
2929
let electronUrl;
30-
let authToken = randomUUID().split('-').join('');
30+
// Auth token: prefer the value provided by the .NET host via environment variable
31+
// (dotnet-first startup). Fall back to a freshly generated token so Electron can
32+
// still be launched stand-alone (e.g. for debugging).
33+
let authToken = process.env.ELECTRONNET_AUTH_TOKEN || randomUUID().split('-').join('');
34+
// Path to a temporary handshake file. When set by the .NET host, Electron writes
35+
// the OS-selected socket port into this file so .NET does not have to parse the
36+
// console output.
37+
const startupInfoPath = process.env.ELECTRONNET_STARTUP_INFO;
3138

3239
if (app.commandLine.hasSwitch('manifest')) {
3340
manifestJsonFileName = app.commandLine.getSwitchValue('manifest');
@@ -274,7 +281,25 @@ function startSocketApiBridge(port) {
274281
server.listen(port, host);
275282
server.on('listening', function () {
276283
const addr = server.address();
277-
console.info(`Electron Socket: listening on port ${addr.port} at ${addr.address} using ${authToken}`);
284+
console.info(`Electron Socket: listening on port ${addr.port} at ${addr.address}`);
285+
286+
// If the .NET host requested a startup-info handshake, write the selected
287+
// port atomically (tmp + rename) so .NET can pick it up without parsing
288+
// our console output. The auth token is intentionally NOT written to disk
289+
// - the .NET host already knows it (it generated it).
290+
if (startupInfoPath) {
291+
try {
292+
const payload = JSON.stringify({ port: addr.port, pid: process.pid });
293+
const tmp = `${startupInfoPath}.tmp`;
294+
const writeOptions = platform() === 'win32'
295+
? { encoding: 'utf8' }
296+
: { encoding: 'utf8', mode: 0o600 };
297+
fs.writeFileSync(tmp, payload, writeOptions);
298+
fs.renameSync(tmp, startupInfoPath);
299+
} catch (err) {
300+
console.error('Failed to write Electron startup info file:', err);
301+
}
302+
}
278303

279304
// Now that socket connection is established, we can guarantee port will not be open for portscanner
280305
if (unpackedelectron) {
@@ -406,7 +431,8 @@ function startAspCoreBackend(electronPort) {
406431

407432
let binFilePath = path.join(currentBinPath, binaryFile);
408433
var options = { cwd: currentBinPath };
409-
console.debug('Starting backend with parameters:', parameters.join(' '));
434+
// Do not log the parameters: they include the auth token.
435+
console.debug('Starting backend.');
410436
apiProcess = cProcess(binFilePath, parameters, options);
411437

412438
apiProcess.stdout.on('data', (data) => {
@@ -436,7 +462,8 @@ function startAspCoreBackendUnpackaged(electronPort) {
436462

437463
let binFilePath = path.join(currentBinPath, binaryFile);
438464
var options = { cwd: currentBinPath };
439-
console.debug('Starting backend (unpackaged) with parameters:', parameters.join(' '));
465+
// Do not log the parameters: they include the auth token.
466+
console.debug('Starting backend (unpackaged).');
440467
apiProcess = cProcess(binFilePath, parameters, options);
441468

442469
apiProcess.stdout.on('data', (data) => {

0 commit comments

Comments
 (0)