Compare commits

..

8 Commits

Author SHA1 Message Date
Florian Rappl
e5ccf75c9f Merge pull request #1094 from ElectronNET/dependabot/npm_and_yarn/src/ElectronNET.Host/ElectronHostHook/socket.io-parser-4.2.7
Bump socket.io-parser from 4.2.6 to 4.2.7 in /src/ElectronNET.Host/ElectronHostHook
2026-08-05 09:08:03 +02:00
dependabot[bot]
819e643ab8 Bump socket.io-parser in /src/ElectronNET.Host/ElectronHostHook
Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.6 to 4.2.7.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.6...socket.io-parser@4.2.7)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 05:07:46 +00:00
Florian Rappl
7fe3f9b5b6 Merge pull request #1093 from ElectronNET/develop
Release 0.5.2
2026-08-03 14:53:48 +02:00
Florian Rappl
16cbc5f2a4 Improved platform agnostic for test 2026-08-03 14:36:20 +02:00
Florian Rappl
3a2c997e6a Ensure windows are not retained after being destroyed #1008 2026-08-03 14:25:47 +02:00
Florian Rappl
f88f5f8b18 Added tests, improved icon implementation 2026-08-03 14:19:25 +02:00
Florian Rappl
4e39f5f377 Added support for MacOS modern app icon #1047 2026-08-03 14:12:09 +02:00
Florian Rappl
7fa5ec5b0b Fixed #1050 2026-08-03 14:09:01 +02:00
11 changed files with 352 additions and 55 deletions

View File

@@ -3,7 +3,13 @@
## ElectronNET.Core
- Fixed token param being appended to external URLs (#1075)
- Fixed startup mode discriminator in case of existing `wwwroot` (#1050)
- Fixed CA1416 on ASP.NET project (#1091) @epsnm
- Fixed loading issue in plain Visual Studio (#1084) @epsnm
- Fixed bloated ASAR file (#1080) @epsnm
- Improved selection of runtime identifier (#1081) @epsnm
- Added more variants for `UseElectron` (#1076) @AeonSake
- Added support for modern MacOS app icon (#1047)
# 0.5.1

View File

@@ -70,6 +70,12 @@ Since electron builder still expects a `package.json` file to exist, ElectronNET
}
```
### App Icon Path
The `ElectronIcon` property supports classic icon files (such as `.ico` and `.icns`) and modern macOS `.icon` app icon packages.
For `.icon`, provide the folder path (for example `Assets/MyApp.icon`). During build/publish, Electron.NET copies the full directory into the Electron output so `electron-builder.json` can reference it (for example `"mac": { "icon": "MyApp.icon" }`).
### Node.js Integration
Electron.NET requires Node.js integration to be enabled for IPC to function. If you are not using the IPC functionality you can disable Node.js integration like so:

View File

@@ -19,6 +19,7 @@ namespace ElectronNET.API
internal WindowManager()
{
EnsureBrowserWindowClosedSubscription();
}
internal static WindowManager Instance
@@ -106,17 +107,6 @@ namespace ElectronNET.API
tcs.SetResult(browserWindow);
});
BridgeConnector.Socket.Once<int[]>("BrowserWindowClosed", (ids) =>
{
for (int index = 0; index < _browserWindows.Count; index++)
{
if (!ids.Contains(_browserWindows[index].Id))
{
_browserWindows.RemoveAt(index);
}
}
});
if (loadUrl.Equals("http://localhost", StringComparison.OrdinalIgnoreCase) && ElectronNetRuntime.AspNetWebPort.HasValue)
{
loadUrl = $"{loadUrl}:{ElectronNetRuntime.AspNetWebPort}";
@@ -149,6 +139,40 @@ namespace ElectronNET.API
return await tcs.Task.ConfigureAwait(false);
}
private readonly object _browserWindowSubscriptionSync = new();
private bool _browserWindowClosedSubscribed;
private void EnsureBrowserWindowClosedSubscription()
{
if (_browserWindowClosedSubscribed)
{
return;
}
lock (_browserWindowSubscriptionSync)
{
if (_browserWindowClosedSubscribed)
{
return;
}
BridgeConnector.Socket.On<int[]>("BrowserWindowClosed", HandleBrowserWindowClosed);
_browserWindowClosedSubscribed = true;
}
}
private void HandleBrowserWindowClosed(int[] ids)
{
if (ids == null || ids.Length == 0)
{
_browserWindows.Clear();
return;
}
var existingIds = ids.ToHashSet();
_browserWindows.RemoveAll(window => !existingIds.Contains(window.Id));
}
private bool IsWindows10()
{
return RuntimeInformation.OSDescription.Contains("Windows 10");

View File

@@ -216,14 +216,16 @@
var webPort = ElectronNetRuntime.AspNetWebPort ?? 0;
// 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.
// In packaged mode, static content is deployed alongside the app binaries, so we must
// point content root to the process base directory. In unpackaged/watch scenarios we
// keep the default project content root to preserve live reload behavior.
var isPackagedStartup = ElectronNetRuntime.StartupMethod == StartupMethod.PackagedElectronFirst ||
ElectronNetRuntime.StartupMethod == StartupMethod.PackagedDotnetFirst;
// For port 0 (dynamic port assignment), Kestrel requires binding to specific IP (127.0.0.1) not localhost
var host = webPort == 0 ? "127.0.0.1" : "localhost";
if (Directory.Exists($"{AppDomain.CurrentDomain.BaseDirectory}\\wwwroot"))
if (isPackagedStartup)
{
builder = builder.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
.UseUrls($"http://{host}:{webPort}");

View File

@@ -247,9 +247,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",

View File

@@ -230,7 +230,10 @@ module.exports = (socket, app) => {
window = app["mainWindow"];
if (window) {
window.reload();
windows.push(window);
synchronizeWindowRegistry();
if (!windows.some((entry) => tryGetWindowId(entry) === window.id)) {
windows.push(window);
}
electronSocket.emit("BrowserWindowCreated", window.id);
return;
}
@@ -253,21 +256,9 @@ module.exports = (socket, app) => {
}
});
lastOptions = options;
window.on("closed", (sender) => {
for (let index = 0; index < windows.length; index++) {
const windowItem = windows[index];
try {
windowItem.id;
}
catch (error) {
if (error.message === "Object has been destroyed") {
windows.splice(index, 1);
const ids = [];
windows.forEach((x) => ids.push(x.id));
electronSocket.emit("BrowserWindowClosed", ids);
}
}
}
window.on("closed", () => {
synchronizeWindowRegistry();
emitBrowserWindowClosed();
});
app.on("activate", () => {
// On macOS it's common to re-create a window in the app when the
@@ -703,12 +694,47 @@ module.exports = (socket, app) => {
getWindowById(id).setBrowserView((0, browserView_1.browserViewMediateService)(browserViewId));
});
function getWindowById(id) {
const runtimeWindow = electron_1.BrowserWindow.fromId(id);
if (runtimeWindow) {
return runtimeWindow;
}
synchronizeWindowRegistry();
for (let index = 0; index < windows.length; index++) {
const element = windows[index];
if (element.id === id) {
if (tryGetWindowId(element) === id) {
return element;
}
}
throw new Error(`BrowserWindow with id '${id}' was not found.`);
}
function tryGetWindowId(element) {
try {
return element.id;
}
catch {
return null;
}
}
function synchronizeWindowRegistry() {
const runtimeWindows = electron_1.BrowserWindow.getAllWindows();
const runtimeWindowIds = new Set(runtimeWindows.map((entry) => entry.id));
for (let index = windows.length - 1; index >= 0; index--) {
const windowId = tryGetWindowId(windows[index]);
if (windowId === null || !runtimeWindowIds.has(windowId)) {
windows.splice(index, 1);
}
}
readyToShowWindowsIds = readyToShowWindowsIds.filter((entryId) => runtimeWindowIds.has(entryId));
}
function emitBrowserWindowClosed() {
const ids = [];
for (const entry of windows) {
const windowId = tryGetWindowId(entry);
if (windowId !== null) {
ids.push(windowId);
}
}
electronSocket.emit("BrowserWindowClosed", ids);
}
};
//# sourceMappingURL=browserWindows.js.map

View File

@@ -255,7 +255,10 @@ export = (socket: Socket, app: Electron.App) => {
window = app["mainWindow"];
if (window) {
window.reload();
windows.push(window);
synchronizeWindowRegistry();
if (!windows.some((entry) => tryGetWindowId(entry) === window.id)) {
windows.push(window);
}
electronSocket.emit("BrowserWindowCreated", window.id);
return;
}
@@ -283,21 +286,9 @@ export = (socket: Socket, app: Electron.App) => {
lastOptions = options;
window.on("closed", (sender) => {
for (let index = 0; index < windows.length; index++) {
const windowItem = windows[index];
try {
windowItem.id;
} catch (error) {
if (error.message === "Object has been destroyed") {
windows.splice(index, 1);
const ids = [];
windows.forEach((x) => ids.push(x.id));
electronSocket.emit("BrowserWindowClosed", ids);
}
}
}
window.on("closed", () => {
synchronizeWindowRegistry();
emitBrowserWindowClosed();
});
app.on("activate", () => {
@@ -907,11 +898,57 @@ export = (socket: Socket, app: Electron.App) => {
});
function getWindowById(id: number): Electron.BrowserWindow {
const runtimeWindow = BrowserWindow.fromId(id);
if (runtimeWindow) {
return runtimeWindow;
}
synchronizeWindowRegistry();
for (let index = 0; index < windows.length; index++) {
const element = windows[index];
if (element.id === id) {
if (tryGetWindowId(element) === id) {
return element;
}
}
throw new Error(`BrowserWindow with id '${id}' was not found.`);
}
function tryGetWindowId(element: Electron.BrowserWindow): number | null {
try {
return element.id;
} catch {
return null;
}
}
function synchronizeWindowRegistry(): void {
const runtimeWindows = BrowserWindow.getAllWindows();
const runtimeWindowIds = new Set(runtimeWindows.map((entry) => entry.id));
for (let index = windows.length - 1; index >= 0; index--) {
const windowId = tryGetWindowId(windows[index]);
if (windowId === null || !runtimeWindowIds.has(windowId)) {
windows.splice(index, 1);
}
}
readyToShowWindowsIds = readyToShowWindowsIds.filter((entryId) =>
runtimeWindowIds.has(entryId),
);
}
function emitBrowserWindowClosed(): void {
const ids: number[] = [];
for (const entry of windows) {
const windowId = tryGetWindowId(entry);
if (windowId !== null) {
ids.push(windowId);
}
}
electronSocket.emit("BrowserWindowClosed", ids);
}
};

View File

@@ -0,0 +1,133 @@
using System.Diagnostics;
namespace ElectronNET.IntegrationTests.Tests;
/// <summary>
/// Tests for ElectronNET.Core.targets icon copy behavior.
/// Covers GitHub issue #1047: modern macOS .icon app-icon packages are folders
/// and must be copied recursively, not treated as a single file.
/// </summary>
public class ElectronIconTargetsTests
{
private static readonly string CorePropsPath = FindBuildFile("src/ElectronNET/build/ElectronNET.Core.props", "ElectronNET/build/ElectronNET.Core.props");
private static readonly string CoreTargetsPath = FindBuildFile("src/ElectronNET/build/ElectronNET.Core.targets", "ElectronNET/build/ElectronNET.Core.targets");
[Fact]
public async Task ElectronCoreTargets_ElectronIconDirectory_ShouldBeMappedIntoElectronOutput()
{
var tempDir = CreateTempProjectDirectory();
try
{
var iconPackageDir = Path.Combine(tempDir, "Assets", "MyApp.icon");
Directory.CreateDirectory(Path.Combine(iconPackageDir, "layers"));
await File.WriteAllTextAsync(Path.Combine(iconPackageDir, "manifest.json"), "{}");
await File.WriteAllTextAsync(Path.Combine(iconPackageDir, "layers", "foreground.png"), "not-a-real-png");
await WriteMinimalCsprojAsync(tempDir);
var (exitCode, output) = await RunDotnetMsBuildAsync(tempDir, "DumpElectronIconCopyItems");
var normalizedOutput = NormalizePathSeparators(output);
exitCode.Should().Be(0,
$"MSBuild target evaluation must succeed. Full output:\n{output}");
normalizedOutput.Should().Contain(
".electron/MyApp.icon/manifest.json",
$"the icon package root file must be mapped into .electron/MyApp.icon. Full output:\n{output}");
normalizedOutput.Should().Contain(
".electron/MyApp.icon/layers/foreground.png",
$"nested files in .icon package must preserve structure in .electron output. Full output:\n{output}");
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
private static string FindBuildFile(string relativeFromRepoRoot, string relativeFromSrc)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var fromRepoRoot = Path.Combine(dir.FullName, relativeFromRepoRoot);
if (File.Exists(fromRepoRoot))
{
return Path.GetFullPath(fromRepoRoot);
}
var fromSrc = Path.Combine(dir.FullName, relativeFromSrc);
if (File.Exists(fromSrc))
{
return Path.GetFullPath(fromSrc);
}
dir = dir.Parent;
}
throw new FileNotFoundException(
$"Could not locate '{relativeFromRepoRoot}' by walking up from '{AppContext.BaseDirectory}'.");
}
private static string CreateTempProjectDirectory()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"electron-net-icon-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
Directory.CreateDirectory(Path.Combine(tempDir, "Properties"));
return tempDir;
}
private static Task WriteMinimalCsprojAsync(string tempDir)
{
var propsPathEscaped = CorePropsPath.Replace("'", "&apos;");
var targetsPathEscaped = CoreTargetsPath.Replace("'", "&apos;");
return File.WriteAllTextAsync(
Path.Combine(tempDir, "TestApp.csproj"),
$$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<Import Project="{{propsPathEscaped}}" />
<PropertyGroup Label="ElectronNetCommon">
<ElectronIcon>Assets/MyApp.icon</ElectronIcon>
</PropertyGroup>
<Import Project="{{targetsPathEscaped}}" />
<Target Name="DumpElectronIconCopyItems"
DependsOnTargets="ElectronResolvePaths;ElectronGetCopyToOutputDirectoryItems">
<Message Importance="High"
Text="ELECTRON_COPY_ITEMS: @(_ElectronFilesToCopyWithTargetPath->'%(TargetPath)')" />
</Target>
</Project>
""");
}
private static async Task<(int ExitCode, string Output)> RunDotnetMsBuildAsync(string workingDirectory, string target)
{
var psi = new ProcessStartInfo("dotnet", $"msbuild TestApp.csproj --nologo -v:minimal /restore /t:{target}")
{
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var process = Process.Start(psi)!;
var stdOut = await process.StandardOutput.ReadToEndAsync();
var stdErr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode, stdOut + stdErr);
}
private static string NormalizePathSeparators(string value)
{
return value.Replace('\\', '/');
}
}

View File

@@ -0,0 +1,55 @@
namespace ElectronNET.IntegrationTests.Tests;
/// <summary>
/// Regression checks for BrowserWindow lifecycle cleanup in WindowManager.
/// Covers GitHub issue #1008.
/// </summary>
public class WindowManagerLifecycleTests
{
private static readonly string WindowManagerFilePath = FindWindowManagerFile();
[Fact]
public void WindowManager_ShouldUsePersistentBrowserWindowClosedSubscription()
{
File.Exists(WindowManagerFilePath).Should().BeTrue(
$"WindowManager source must exist at '{WindowManagerFilePath}'.");
var content = File.ReadAllText(WindowManagerFilePath);
content.Should().Contain(
"Socket.On<int[]>(\"BrowserWindowClosed\"",
"closed-window cleanup must be wired for every close event, not just the first one.");
content.Should().NotContain(
"Socket.Once<int[]>(\"BrowserWindowClosed\"",
"a one-shot subscription causes stale BrowserWindow references after subsequent closes (issue #1008).");
}
private static string FindWindowManagerFile()
{
const string RelativeFromRepoRoot = "src/ElectronNET.API/API/WindowManager.cs";
const string RelativeFromSrc = "ElectronNET.API/API/WindowManager.cs";
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var fromRepoRoot = Path.Combine(dir.FullName, RelativeFromRepoRoot);
if (File.Exists(fromRepoRoot))
{
return Path.GetFullPath(fromRepoRoot);
}
var fromSrc = Path.Combine(dir.FullName, RelativeFromSrc);
if (File.Exists(fromSrc))
{
return Path.GetFullPath(fromSrc);
}
dir = dir.Parent;
}
throw new FileNotFoundException(
"Could not locate WindowManager.cs by walking up from " +
$"'{AppContext.BaseDirectory}'.");
}
}

View File

@@ -313,7 +313,10 @@
<ItemGroup>
<!--<_ElectronFiles Include="$(ElectronIntermediatePackageJson)" />-->
<_ElectronFiles Include="$(ElectronSplashScreen)" Condition="'$(ElectronSplashScreen)'!=''" />
<_ElectronFiles Include="$(ElectronIcon)" Condition="'$(ElectronIcon)'!=''" />
<_ElectronFiles Include="$(ElectronIcon)"
Condition="'$(ElectronIcon)'!='' AND ( !Exists('$(ElectronIcon)') OR !$([System.IO.Directory]::Exists('$(ElectronIcon)') ) )" />
<_ElectronIconDirectoryFiles Include="$(ElectronIcon)\**\*"
Condition="'$(ElectronIcon)'!='' AND Exists('$(ElectronIcon)') AND $([System.IO.Directory]::Exists('$(ElectronIcon)') )" />
</ItemGroup>
<ItemGroup>
@@ -329,11 +332,16 @@
</ItemGroup>
<Message Importance="High" Text="_ElectronFilesToCopy: @(_ElectronFilesToCopy)" />
<Message Importance="High" Text="_ElectronIconDirectoryFiles: @(_ElectronIconDirectoryFiles)" />
<ItemGroup>
<_ElectronFilesToCopyWithTargetPath Include="@(_ElectronFilesToCopy)">
<TargetPath>$(ElectronDirName)\%(FileName)%(Extension)</TargetPath>
</_ElectronFilesToCopyWithTargetPath>
<_ElectronFilesToCopyWithTargetPath Include="@(_ElectronIconDirectoryFiles)">
<TargetPath>$(ElectronDirName)\$(ElectronIconFileName)\%(RecursiveDir)%(FileName)%(Extension)</TargetPath>
</_ElectronFilesToCopyWithTargetPath>
</ItemGroup>
<Message Text="_ElectronFilesToCopyWithTargetPath: @(_ElectronFilesToCopyWithTargetPath)" />

View File

@@ -572,7 +572,7 @@
<StringProperty Name="ElectronIcon"
DisplayName="App Icon"
Description="Choose a ICO file to be used as application icon"
Description="Choose an icon path for the app (e.g. .ico, .icns, or modern macOS .icon folder)"
Subtype="File"
Category="General">
<StringProperty.DataSource>
@@ -581,7 +581,7 @@
<StringProperty.ValueEditors>
<ValueEditor EditorType="FilePath">
<ValueEditor.Metadata>
<NameValuePair Name="FileTypeFilter" Value="Icon files (*.ico)|*.ico|All files (*.*)|*.*" />
<NameValuePair Name="FileTypeFilter" Value="Icon files (*.ico,*.icns)|*.ico;*.icns|All files (*.*)|*.*" />
</ValueEditor.Metadata>
</ValueEditor>
</StringProperty.ValueEditors>