launchagent

package module
v0.0.0-...-335270f Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: BSD-3-Clause Imports: 6 Imported by: 0

README

go-macos/launchagent

ci Go Reference License

Keep a program running across logouts and restarts. Pure Go, CGO_ENABLED=0, and it builds and is tested on every platform, which is what lets a cross-compiling build set up a macOS agent.

reg, err := launchagent.Enable(launchagent.Spec{
    Label:      "io.github.go-downloader.godl",
    Program:    "/usr/local/bin/godl",
    Args:       []string{"queue", "run"},
    RunAtLoad:  true,          // start it at login — this is what survives a restart
    KeepAlive:  true,          // start it again when it exits
    StdoutPath: "/tmp/godl.log",
})
if err != nil {
    return err
}
if reg.Advice != "" {
    fmt.Println(reg.Advice)    // the person has to allow it; say so
}

The two mechanisms, and which one you get

MethodPlist MethodAppService
what it is a plist in ~/Library/LaunchAgents SMAppService, via go-macos/servicemanagement
needs an application bundle no yes
macOS any, and every other platform for the file work 13+
shown to the person as a bare reverse-DNS label the application, by name and icon
person can switch it off not really yes — and State reports it
supported by Apple deprecated for applications yes

Enable prefers SMAppService when this process is inside a bundle, and writes the plist otherwise. That is the whole rule.

The plist API is unchanged

Install, Remove, Installed, Path and Dir still mean exactly what they meant: the plist, and nothing else. A caller that wants the file — and knows it wants the file — still gets it, with the same signatures and the same behaviour.

Enable(Spec) (Registration, error) register by whichever mechanism applies, and say which.
State(label) (Registration, error) what is actually set up, and how.
Disable(label) error remove it — both ways, see below.
Install(Spec) (string, error) write the plist. Unchanged.
Remove(label) error / Installed(label) (bool, error) the plist alone. Unchanged.
Path(label) / Dir() where the plist goes. Unchanged.

A refusal falls back, and says so

Inside a bundle whose Contents/Library/LaunchAgents does not hold LABEL.plist, SMAppService refuses. Enable then writes the legacy plist and puts the refusal in Registration.Fallback:

if reg.Fallback != nil {
    log.Printf("SMAppService declined (%v); using %s", reg.Fallback, reg.Path)
}

Failing outright would stop a working program working the day it gained a bundle. Falling back silently would hide a real packaging defect for as long as anybody cared to look. So it falls back and it tells you, and a caller that wants strictness has one field to check.

Registration.Advice is not decoration

SMAppService can register a service and leave it switched off, waiting for the person to allow it in System Settings → General → Login Items & Extensions. That happens routinely — most of all when they turned this very item off before, in which case it stays off whatever the program does.

Enable returns nil in that case and Registration.Enabled is false. A program that ignores it starts nothing and says nothing; the Advice is the sentence to put in front of the person, naming the pane they would never find on their own.

Disable takes away both

A program that shipped a plist and then gained a bundle has two registrations. macOS acts on the SMAppService one, so removing only that leaves the program still starting at login — the bug a person describes as "I turned it off and it came back". Disable removes both, and finding nothing to remove is not an error.

SMAppService is asked for its status before being asked to unregister, because -unregister: on something that was never registered is reported by macOS as an error (SMAppServiceErrorDomain 22, "Invalid argument") rather than as the no-op it is.

Notes on the plist

  • Install writes the agent; it does not ask launchd to load it. That is a running session's business, and a build or an installer has no session to speak for. RunAtLoad covers the next login, which is the case this exists for.
  • A label that cannot be a file name is refused. It reaches the filesystem as a path, so one carrying a separator would write the agent where launchd never looks — a service that silently never runs.
  • Optional keys are written only when asked for. A plist naming a log nobody requested, or keeping alive a program meant to finish, is a service fighting its own author.
  • A plist is XML, so a path is escaped: /opt/rock & roll/godl is a path somebody has, and unescaped it ends the document early.
  • Removing an agent that is not there is not an error: asking for it to be gone and finding it gone is the outcome wanted.

Building the bundle

SMAppService is only reachable from one. go-macos/appbundle assembles it in pure Go, in the same cross-compiling build:

_, err := appbundle.Build(appbundle.Spec{
    Dir: "dist", Name: "godl", Identifier: "io.github.go-downloader.godl",
    Version: "0.1.0", Executable: "build/godl", Accessory: true,
})

The agent's plist goes at Contents/Library/LaunchAgents/LABEL.plist — the file name SMAppService resolves, and nowhere else.

BSD-3-Clause.

Documentation

Overview

Package launchagent keeps a program running across logouts and restarts.

A program a person started in a terminal ends with the terminal, and a program that ends is a queue that stops, a watcher that stops watching, a sync that silently falls behind. Something has to tell macOS to start it again, and there are two ways to say so:

  • Install writes a plist into ~/Library/LaunchAgents, which is path and file work — no cgo, nothing macOS-only — and works for anything, including a bare executable in /usr/local/bin and a macOS older than 13. It is also invisible to the person who owns the machine: the item shows up in System Settings as a reverse-DNS label with no name and no icon, and nothing tells the program when they switch it off.

  • Enable prefers SMAppService when the process is inside an application bundle, through github.com/go-macos/servicemanagement. That is what Apple supports since macOS 13, it appears under the application's own name, and the person can turn it off — which State can then report, and which the plist can never tell you.

Enable, State and Disable choose between the two and say which one they used. Install, Remove, Installed, Path and Dir are the plist alone, unchanged: they still mean exactly what they meant, so a caller that wants the file and nothing else still gets it.

Everything here is CGO_ENABLED=0 and builds and is tested on every platform; off darwin, servicemanagement reports itself unsupported and the plist is what is left, which is what lets a cross-compiling build write one.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Dir

func Dir() (string, error)

Dir is where a user's own agents live.

func Disable

func Disable(label string) error

Disable stops the program starting at login, whichever way it is held — and it takes away BOTH, because a program that gained a bundle after shipping a plist has two registrations and removing one of them leaves it starting at login anyway.

A registration that is not there is not an error: asking for it to be gone and finding it gone is the outcome wanted. SMAppService is asked for its status first for exactly that reason: -unregister: on something never registered is reported by macOS as an error (SMAppServiceErrorDomain 22, "Invalid argument") rather than as the no-op it is.

func Install

func Install(s Spec) (string, error)

Install writes the agent and reports where it put it.

It does not ask launchd to load it: that is a running session's business, and a build or an installer has no session to speak for. RunAtLoad covers the next login, which is the case this exists for.

func Installed

func Installed(label string) (bool, error)

Installed reports whether the agent is there.

func Path

func Path(label string) (string, error)

Path is where the agent with this label is kept, whether or not it is there.

func Remove

func Remove(label string) error

Remove takes the agent away. An agent that is not there is not an error: asking for it to be gone and finding it gone is the outcome wanted.

Types

type Method

type Method int

Method is the mechanism actually holding a startup registration.

It is reported rather than assumed because the two are not interchangeable and a caller has things to say about which one it got: a plist is invisible to the person who owns the machine, and an SMAppService registration can be switched off by them at any moment.

const (
	// MethodPlist is a plist in ~/Library/LaunchAgents — what [Install]
	// writes. It works for anything, including a bare executable in
	// /usr/local/bin, and macOS shows it to the person as a reverse-DNS label
	// with no name and no icon.
	MethodPlist Method = iota
	// MethodAppService is SMAppService, the supported path since macOS 13. It
	// requires an application bundle, it appears in System Settings under the
	// application's own name, and the person can switch it off there — which
	// [State] can then report.
	MethodAppService
)

func (Method) String

func (m Method) String() string

String names the method.

type Registration

type Registration struct {
	// Method is the mechanism holding it.
	Method Method
	// Enabled reports whether the program will actually start at the next
	// login. It is false for a registration waiting to be approved: macOS
	// holds it and will not run it.
	Enabled bool
	// Path is the plist, when Method is [MethodPlist]. It is the path
	// [Install] returns, and it is set even when nothing is installed there,
	// so a caller can say where it looked.
	Path string
	// Advice is what to tell the person, or "" when there is nothing for them
	// to do. It is non-empty exactly when macOS is waiting on them — most
	// often because they switched this item off before, in which case it
	// stays off whatever this program does.
	Advice string
	// Fallback is the SMAppService failure that made [Enable] write a plist
	// instead, and nil when there was none.
	//
	// It is carried rather than swallowed. Falling back is the right
	// behaviour — a program that was working must not stop working the day it
	// gains a bundle — but a fallback is also how "the plist is not in the
	// bundle" looks from the outside, and a caller that never sees this error
	// never finds that out. Log it; do not fail on it.
	Fallback error
}

Registration is what Enable did, or what State found.

func Enable

func Enable(s Spec) (Registration, error)

Enable makes the program start at login by whichever mechanism this process can actually use, and reports which one that was.

Inside an application bundle it prefers SMAppService: it is what Apple supports, it puts the item in System Settings under the application's own name, and it lets the person turn it off — which is a feature, not a problem, provided the program can find out. Everywhere else, and whenever SMAppService refuses, it writes the plist Install writes.

A refusal is reported in Registration.Fallback rather than returned as an error. The commonest one is a bundle that does not ship Contents/Library/LaunchAgents/LABEL.plist — a real defect, but not a reason to leave a program that used to start at login no longer starting at login.

A nil error does NOT mean the program will run. Read Registration.Enabled, and show Registration.Advice when it is there: a registration awaiting approval is held by macOS and idle, and saying nothing about it is how a person ends up with a feature that silently does nothing.

func State

func State(label string) (Registration, error)

State reports whether the program is set to start at login, and how.

It asks SMAppService first when there is a bundle to ask from, and falls through to the plist when macOS holds no registration. That order matters for a program that shipped a plist before it gained a bundle: both can exist at once, and the SMAppService one is the one macOS acts on.

type Spec

type Spec struct {
	// Label identifies the agent to launchd, in reverse-DNS form. It is also
	// the plist's file name, and what Remove and Installed are given.
	Label string
	// Program is the executable, and Args what follows it. The program is
	// named absolutely: launchd starts it from no particular directory and
	// with no particular PATH.
	Program string
	Args    []string
	// WorkingDir is where the program runs. Empty leaves launchd's choice.
	WorkingDir string
	// RunAtLoad starts it when the user logs in, which is what makes this
	// survive a restart rather than merely a terminal closing.
	RunAtLoad bool
	// KeepAlive starts it again when it exits. A program that means to
	// finish should leave this false, or launchd will fight it.
	KeepAlive bool
	// StdoutPath and StderrPath are where its output goes. A service with
	// nowhere to write is a service nobody can diagnose.
	StdoutPath string
	StderrPath string
}

Spec is the program to keep running.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL