Startup Service
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.
Registration
Section titled “Registration”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 behaviour
Section titled “Platform behaviour”| 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 |
Linux: GUI app or service
Section titled “Linux: GUI app or service”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-failurewithRestartSec=5is written by default (RestartOnFailure = falseturns 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}.
What gets written
Section titled “What gets written”[Unit]Description=My AppPartOf=graphical-session.target # SystemdGraphicalSession onlyAfter=graphical-session.target # (SystemdSystem: After=network.target)
[Service]Type=simpleExecStart="/opt/my-app/MyApp" "--autostart"WorkingDirectory=/opt/my-appUser=myworker # SystemdSystem + LinuxServiceUser onlyRestart=on-failureRestartSec=5
[Install]WantedBy=graphical-session.target # default.target / multi-user.target for the service modesWorkingDirectory 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.
Behaviour
Section titled “Behaviour”Registerwrites the unit, then runssystemctl [--user] daemon-reloadandenable. 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, runsystemctl [--user] start {Identifier}.Unregisterrunsdisable, deletes the unit, and runsdaemon-reload.GetStatemapssystemctl is-enabled:enabled→Enabled,disabled→DisabledByUser,masked→DisabledByPolicy. A masked unit is never overwritten.Identifiermust be a valid unit name: letters, digits, and:_.-. Spaces and@throwInvalidOperationException.SystemdSystemRegister/UnregisterthrowInvalidOperationExceptionunless the process runs as root.- Switching
LinuxModedoesn’t remove the previous mode’s entry. CallUnregisterunder the old mode first.
Installing a worker as a service
Section titled “Installing a worker as a service”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.Systemdbuilder.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 --installif (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.


