Skip to content

Conversation

@Denny09310
Copy link
Contributor

This PR introduces support for multiple startup/shutdown callbacks to improve initialization control.
Previously, we only had OnAppReady, which runs too late for some features like protocol registration.

Now this supports:

  • OnBeforeReady → runs before Electron's ready (useful for protocol setup)
  • OnReady → same as current OnAppReady
  • OnWillQuit → triggered when shutdown starts
  • OnQuit → after everything is fully stopped

Also added:

  • New UseElectron overload with options
  • Updated RuntimeControllerAspNetBase to call the new events
  • Existing API stays compatible

This is an initial implementation and can be adjusted later based on feedback. Let me know if you'd like to change event order, naming, or extend it further.

@Denny09310
Copy link
Contributor Author

Although this introduces some lifecycle events, I still don’t understand where to place the calls, because even with these events, the protocol API throws an error for being too late.

…e, removed static OnAppReadyCallback in favor of Events.OnReady
@FlorianRappl
Copy link
Collaborator

Although this introduces some lifecycle events, I still don’t understand where to place the calls, because even with these events, the protocol API throws an error for being too late.

Yeah I think we will need to touch the "main.js" / JS counterpart for it. Not sure yet. Unfortunately, I won't have time today to look at that. Hopefully tomorrow / by the end of the week.

Copy link
Collaborator

@softworkz softworkz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is not needed. You can simply do this in the WebApp's Program.cs:

        public static void Main(string[] args)
        {
            ElectronNetRuntime.RuntimeController.WaitStartedTask.ContinueWith(_ =>
            {
                // Do something before ready state
            });

            CreateWebHostBuilder(args).Build().Run();
        }

Or when a more fine-grained state would be needed, that's the intended kind of pattern.

Background

About Statics

From an architectural point of view, having statics in the first place is always a kind of smell, but sometimes - like here - it's more or less unavoidable or rather: the consequences (incompatibility, more complicated setup, etc.) would not weigh out the architectual advantage.

But - when it's gotta be static - then it should really be a single static point of access where everything is anchored to.
The previous code had a number of statics and some pseudo-statics (singletons without static accessor) which were "floating around" and code needed to go sideways and make some extra-turns in order for them to access each other.

The static ElectronNetRuntime class is that single static anchor now and ElectronNetRuntime.RuntimeController is the place and point of access for everything related to lifecycle management.

Why no Events?

The reasons why I used awaitable tasks instead of events are these:

  • Single Occurence
    Each event happens exactly once
  • Fixed Sequence
    The events do not occur arbitrarily. There's a fixed and guaranteed order
  • Consumer Convenience

When you look at Main() in Program.cs in the ElectronNET.ConsoleApp demo you can see what I mean:

    await runtimeController.Start();

    await runtimeController.WaitReadyTask;

    await ElectronBootstrap();

    await runtimeController.WaitStoppedTask;

This allows to write much nicer and clearly structured code than with events.

That being said, if we really need events, they should be exposed by the RuntimeController.

Side Note

An "options" class that is passed as an argument but exposing application lifecycle events is more a kind of hack than a sane pattern. 😉

@softworkz
Copy link
Collaborator

As we are talking about RuntimeController: It's not perfect yet. There's a least one path I know of, where a downstream error (here: launching the electron executable in case of 'dotnet-first') does not propagate back the exception to the awaitable tasks and there might be more cases of that kind.

Just wanted to mention it so that you're aware and it doesn't become forgotten. :-)

@Denny09310
Copy link
Contributor Author

Using an ‘options’ class passed as an argument to expose application lifecycle events is more of a hack than a proper design pattern. 😉

While I don’t completely agree with that statement, my approach was inspired by the DI patterns in ASP.NET Core authentication mechanisms, which use an Events property to handle lifecycle callbacks (e.g., ticket or authentication events), if there's already an alternative solution in place, I'm fine with closing this PR.

@Denny09310
Copy link
Contributor Author

The example you provided isn’t working because inside Main, the ElectronNetRuntime.RuntimeController is null, which causes an exception. This happens because the StartupManager does not initialize this variable when the application type is set to 'Aspnet':

public void Initialize()
{
    try
    {
        ElectronNetRuntime.BuildInfo = this.GatherBuildInfo();
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex);
    }

    this.CollectProcessData();
    this.SetElectronExecutable();

    ElectronNetRuntime.StartupMethod = this.DetectAppTypeAndStartup();
    Console.WriteLine("Evaluated StartupMethod: " + ElectronNetRuntime.StartupMethod);

    if (ElectronNetRuntime.DotnetAppType != DotnetAppType.AspNetCoreApp)
    {
        ElectronNetRuntime.RuntimeControllerCore = this.CreateRuntimeController();
    }
}

Even if I remove the if statement so that RuntimeController is assigned, the ContinueWith callback still never gets executed for some reason.

@softworkz
Copy link
Collaborator

Even if I remove the if statement so that RuntimeController is assigned, the ContinueWith callback still never gets executed for some reason.

Okay, let me check...

@softworkz
Copy link
Collaborator

softworkz commented Nov 19, 2025

Alright - don't comment out that condition - this would get the wrong controller.

This should work:

        public static void Main(string[] args)
        {
            var builder = CreateWebHostBuilder(args);

            ElectronNetRuntime.RuntimeController.WaitStartedTask.ContinueWith(_ =>
            {
                // Do something before ready state
            });

            builder.Build().Run();
        }

@Denny09310
Copy link
Contributor Author

Denny09310 commented Nov 19, 2025

Mmh this gives me the same null reference as before. I'll try with the develop branch maybe I've touched something involuntarily

Edit: no even in the develop branch RuntimeController is null

@softworkz
Copy link
Collaborator

Sorry - tested this time 😆

        public static void Main(string[] args)
        {
            var host = CreateWebHostBuilder(args).Build();

            var constroller = host.Services.GetService<IElectronNetRuntimeController>();

            constroller.WaitStartedTask.ContinueWith(_ =>
            {
                // Do something before ready state
            });

            host.Run();
        }

@softworkz
Copy link
Collaborator

softworkz commented Nov 19, 2025

While I don’t completely agree with that statement, my approach was inspired by the DI patterns in ASP.NET Core authentication mechanisms

The code above is actually the "DI way", I just forgot that I did it that way for AspNet (doing too many things in parallel).
My point was about the naming ("options") and the fact that it wasn't the runtime controller itself (where the events come from).

@Denny09310
Copy link
Contributor Author

We can close this PR as the solution you've provided is working 😁

@Denny09310 Denny09310 closed this Nov 19, 2025
@softworkz
Copy link
Collaborator

Cool, thanks!

@Denny09310
Copy link
Contributor Author

@softworkz Even though inside the ContinueWith calling the socket causes an exception 😂

System.Exception: 'Cannot access socket bridge. Runtime is not in 'Ready' state'

@FlorianRappl FlorianRappl mentioned this pull request Nov 19, 2025
@FlorianRappl
Copy link
Collaborator

@softworkz Even though inside the ContinueWith calling the socket causes an exception 😂

System.Exception: 'Cannot access socket bridge. Runtime is not in 'Ready' state'

Yeah I think this is partially due to the socket design here. I think we should abstract that away and queue all messages before the socket is open - then send the queued ones once the connection has been established.

@Denny09310
Copy link
Contributor Author

Denny09310 commented Nov 19, 2025

Okay thanks, I'll see what I can do here

@softworkz
Copy link
Collaborator

@softworkz Even though inside the ContinueWith calling the socket causes an exception 😂

System.Exception: 'Cannot access socket bridge. Runtime is not in 'Ready' state'

It wasn't clear to me what you want. You had an "onbeforeready" event, that's why I suggested the WaitStartedTask. But when you want to send something, you need to use the WaitReadyTask.

@softworkz
Copy link
Collaborator

Yeah I think this is partially due to the socket design here. I think we should abstract that away and queue all messages before the socket is open - then send the queued ones once the connection has been established.

The whole design is not suitable for getting replies late. When the state is "Ready", messages and API calls will be working, not before. Also, when it never gets into ready state, all tasks would be handing or receive timeout exceptions.

@softworkz
Copy link
Collaborator

Okay thanks, I'll see what I can do here

Better not in that direction. Just use the WaitReady event.

@Denny09310
Copy link
Contributor Author

Please see #940 to see why all this issue was brought up. In short I wanted to implement the protocol API from electron, but there isn't a suitable time to send the event, early is too early as the socket is not open, later is too late as the app is already in the 'ready' state

@softworkz
Copy link
Collaborator

Ready means the socket connection is established. Not sure what you want to do before that?

Whatever you try - remember that there are 4 modes of operation

dotnet-first, electron-first and both in packaged and unpackaged mode.

The Ready event/state is the point where things are ready - in all 4 cases.

@Denny09310
Copy link
Contributor Author

I understand what you're saying, but in this context “ready” also refers to the Electron app being fully ready. That won’t work for my case because the protocol API needs to be registered before the app reaches the ready state, but at that point, the socket connection still isn’t initialized. If there’s no way to trigger something in between those stages, then I’ll just drop the protocol API implementation entirely.

@softworkz
Copy link
Collaborator

Regarding 940 - I've just read the headline but haven't had time to look at it, I'm afraid.
Is this a fundamental change or just some extra side-featuie?

@Denny09310
Copy link
Contributor Author

Denny09310 commented Nov 19, 2025

Let's say an extra side feature, it's not a problem if it's not possible to implement it, I'll close it as not planned and call it a day, maybe reopen it when (and if) will be possible 😁

@softworkz
Copy link
Collaborator

softworkz commented Nov 19, 2025

Let's say an extra side feature, it's not a problem if it's not possible to implement it

Assuming you would have plain electron, at which event would this be needed to set up?

Or how - if before any event?

@Denny09310
Copy link
Contributor Author

Denny09310 commented Nov 19, 2025

From the docs:

All methods unless specified can only be used after the ready event of the app module gets emitted.

The registerSchemesAsPrivileged especially:

This method can only be used before the ready event of the app module gets emitted and can be called only once.

@softworkz
Copy link
Collaborator

softworkz commented Nov 19, 2025

Did you know that you can put a file named custom_main.js into your project?
It will be called before the app starts.

It can look like this:

module.exports.onStartup = function(host) {

  const { app } = require('electron');

  console.log('custom_main.js onStartup invoked');

  return true;
}

There you can call registerSchemesAsPrivileged

In the project, you need to specify the target folder like this:

    <None Update="Assets\custom_main.js">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <TargetPath>.electron\custom_main.js</TargetPath>
    </None>

@Denny09310
Copy link
Contributor Author

Ehm no 😅. I'll try this later, thanks for the patience

@softworkz
Copy link
Collaborator

thanks for the patience

Sure, you're very welcome!

@softworkz
Copy link
Collaborator

Are you interested in registerSchemesAsPrivileged() for serving local content from disk?

@Denny09310
Copy link
Contributor Author

Denny09310 commented Nov 19, 2025

Actually, yes! That was the purpose

@softworkz
Copy link
Collaborator

softworkz commented Nov 19, 2025

The best path is to serve from file:// URLs with "DisableWebSecurity".

Everything else gives issues. For example, when registerSchemesAsPrivileged(), you can disable CSP, but it's still like under https, which means that you cannot load images from http: - only https. And there are more stupid limitations.

PS: Documentation for custom_main.js: I probably forgot about it 🤷

@Denny09310
Copy link
Contributor Author

It's not a bit overkill disabling all the security features only for this? If it's the only way I'll do it, but I'm a bit unsure

@softworkz
Copy link
Collaborator

softworkz commented Nov 19, 2025

It's not a bit overkill disabling all the security features only for this? If it's the only way I'll do it, but I'm a bit unsure

It depends on your purpose. If the app also loads web content, then I would think again of course.
In our case, the app never loads any web pages - all code is shipped with the app. But it loads images and videos from people's local or remote servers, and when using a non-privileged protocol half of the web apis are unavailable and whith a privileged protocol you can't load images from http anymore - which is insane, because nobody has https for local servers.
In this context, it's totally ridiculous when browser manufacturers are claiming that remote content is "secure" because it's via https and your own local content is not.
I mean, every hacker can get a letsencrypt cert within a minute, so what is secure about it? (in this regard at least...)

Though, I should add: When you can get along otherwise, then that's better of course

@softworkz
Copy link
Collaborator

BTW: Chrome developers don't like this mode (as per source-code comments in Chromium), but it's future proof: They cannot remove it, because the Android WebView works like that and it would break a large share of Android apps 🤣

@Denny09310
Copy link
Contributor Author

Okay if I will ever need to load local media and I don't serve external web content I'll just disable websecurity

@softworkz
Copy link
Collaborator

softworkz commented Nov 19, 2025

PS: Documentation for custom_main.js: I probably forgot about it

Well, I remember now that I also wasn't sure whether it's meant to stay like this:

Originally it was just a quick throw-in to prevent the app from starting when there are still leftover processes running, so that our beta testers know they need to kill them first. After that was addressed, it become unused. Then, recently it became useful again for initializing Sentry (which should happen at a very early stage), dropped that later and few days ago it became useful again for auto-provisioning WebHID API device access permissions (adding that to ElectronNET API would be a future goal).

Now you came up with another reasonable use case - so in total, I get to believe that it's a useful feature indeed.
Most of the things for which you may need direct elctron API access can be done from index.ts in a custom ElectronHostHook, but as we see here: not all of them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants