Skip to content
Shiny.NET

Startup Service

Frameworks
.NET MAUI

IStartupService installs the running app into the operating system’s startup list: launch at login on Windows and macOS, and on Linux either launch at login for a GUI app or a systemd service for headless code. It’s safe to call from cross-platform code. Mobile reports NotSupported rather than throwing.

using Shiny;
builder.AddStartupService(opts =>
{
opts.Identifier = "my-app"; // Windows Run value / Linux .desktop file or systemd unit name
opts.DisplayName = "My App"; // Linux desktop entry Name / systemd Description
opts.Arguments.Add("--autostart"); // Windows + Linux (macOS can't pass arguments)
});

An IServiceCollection overload exists too, for an AppKit app with no MauiAppBuilder or a plain Generic Host worker:

services.AddStartupService(opts => opts.Identifier = "my-app");
public class StartupToggle(IStartupService startup)
{
public async Task Toggle(bool enable)
{
if (!startup.IsSupported)
return;
var state = enable
? await startup.Register()
: await startup.Unregister();
if (state is StartupServiceState.RequiresApproval or StartupServiceState.DisabledByUser)
await startup.OpenSettings(); // the user has the final say in the OS UI
}
}
State Meaning
NotSupported The platform (or the chosen Linux mode on this host) has no startup list this library can manage
NotRegistered Not in the startup list
Enabled Registered and will start
DisabledByUser Registered, but switched off in Task Manager / Login Items / the .desktop file / systemctl disable
DisabledByPolicy Blocked by group policy, MDM, or a masked systemd unit
RequiresApproval macOS only. Submitted, waiting for approval in System Settings
Platform Mechanism Notes
Windows (unpackaged, WindowsPackageType=None) HKCU\…\CurrentVersion\Run Honours ExecutablePath/Arguments. Reports DisabledByUser when switched off in Task Manager. OpenSettings opens ms-settings:startupapps
Windows (MSIX packaged) Not supported MSIX virtualizes HKCU writes, so a Run entry never reaches the shell
macOS 13+ (Mac Catalyst and AppKit) SMAppService.MainApp Registers the running app bundle. The first registration commonly returns RequiresApproval; OpenSettings opens Login Items
Linux Chosen by LinuxMode (below) Honours ExecutablePath/Arguments. OpenSettings returns false
iOS / Android / macOS 12 and earlier Not supported IsSupported is false

On Linux, StartupServiceOptions.LinuxMode sets whether the process starts as a GUI app at login or as a general service:

LinuxStartupMode Kind Unit / entry Starts
XdgAutostart (default) GUI ~/.config/autostart/{Identifier}.desktop At desktop login, on any XDG-compliant desktop, systemd or not
SystemdGraphicalSession GUI ~/.config/systemd/user/{Identifier}.service, WantedBy=graphical-session.target Once the desktop session is up; stopped when the session ends
SystemdUser Service ~/.config/systemd/user/{Identifier}.service, WantedBy=default.target When the user’s service manager starts: first login, or at boot with loginctl enable-linger <user>
SystemdSystem Service /etc/systemd/system/{Identifier}.service, WantedBy=multi-user.target At boot, with no login. Requires root

The systemd modes give you what XDG autostart can’t:

  • Crash restarts. Restart=on-failure with RestartSec=5 is written by default (RestartOnFailure = false turns it off). A clean exit isn’t restarted.
  • Logging. stdout and stderr go to the journal: journalctl [--user] -u {Identifier}.
  • Control. systemctl [--user] status|start|stop|restart {Identifier}.
[Unit]
Description=My App
PartOf=graphical-session.target # SystemdGraphicalSession only
After=graphical-session.target # (SystemdSystem: After=network.target)
[Service]
Type=simple
ExecStart="/opt/my-app/MyApp" "--autostart"
WorkingDirectory=/opt/my-app
User=myworker # SystemdSystem + LinuxServiceUser only
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target # default.target / multi-user.target for the service modes

WorkingDirectory defaults to AppContext.BaseDirectory (override with StartupServiceOptions.WorkingDirectory), and DOTNET_ROOT is carried into the unit when it’s set, so a framework-dependent app on a private runtime install still starts.

  • Register writes the unit, then runs systemctl [--user] daemon-reload and enable. It does not start the unit, so a GUI app registering itself never spawns a second copy. The unit runs from the next login or boot. To start it immediately, run systemctl [--user] start {Identifier}.
  • Unregister runs disable, deletes the unit, and runs daemon-reload.
  • GetState maps systemctl is-enabled: enabled → Enabled, disabled → DisabledByUser, masked → DisabledByPolicy. A masked unit is never overwritten.
  • Identifier must be a valid unit name: letters, digits, and : _ . -. Spaces and @ throw InvalidOperationException.
  • SystemdSystem Register/Unregister throw InvalidOperationException unless the process runs as root.
  • Switching LinuxMode doesn’t remove the previous mode’s entry. Call Unregister under the old mode first.

Because AddStartupService works on plain IServiceCollection, a Generic Host worker can install itself:

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSystemd(); // optional: Microsoft.Extensions.Hosting.Systemd
builder.Services.AddHostedService<Worker>();
builder.Services.AddStartupService(opts =>
{
opts.Identifier = "my-worker";
opts.DisplayName = "My Worker";
opts.LinuxMode = LinuxStartupMode.SystemdSystem;
opts.LinuxServiceUser = "myworker"; // runs as root when unset
});
var host = builder.Build();
// sudo ./my-worker --install
if (args.Contains("--install"))
{
var state = await host.Services.GetRequiredService<IStartupService>().Register();
Console.WriteLine($"Service: {state}");
return;
}
await host.RunAsync();

Use LinuxStartupMode.SystemdUser instead when the worker should run as the current user without root.