servicemanagement

package module
v0.1.0 Latest Latest
Warning

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

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

README

go-macos/servicemanagement

ci Go Reference License

SMAppService from pure Go, CGO_ENABLED=0: register an application, an agent or a daemon to start at login, and find out when the person has to approve it. No cgo, no launchctl, no Objective-C source file anywhere in the build — it reaches the ServiceManagement framework through go-macos/objc, which reaches it through purego.

svc := servicemanagement.MainApp()

if err := svc.Register(); err != nil {
        return err
}

st, err := svc.Status()
if err != nil {
        return err
}
if st == servicemanagement.RequiresApproval {
        fmt.Println(st.Advice()) // say it; do not fail, and do not stay silent
}

That is the whole API:

MainApp() Service +[SMAppService mainAppService] — the application itself.
Agent(plistName string) Service +agentServiceWithPlistName: — a per-user LaunchAgent shipped in the bundle.
Daemon(plistName string) Service +daemonServiceWithPlistName: — a system LaunchDaemon shipped in the bundle.
LoginItem(identifier string) Service +loginItemServiceWithIdentifier: — a helper app, replacing SMLoginItemSetEnabled.
(Service) Register() error ask macOS to start it at login.
(Service) Unregister() error take the registration away.
(Service) Status() (Status, error) -status: NotRegistered, Enabled, RequiresApproval, NotFound.
(Status) Registered() / Running() / Advice() what it means, and what to tell a person.
Bundled() (string, bool) the bundle identifier this process has, if any.

requiresApproval is a normal outcome, not a failure

Register can return nil and leave the service in RequiresApproval. That is macOS saying: it is registered, it will not run yet, a person has to allow it in System Settings → General → Login Items & Extensions. It happens routinely — most visibly when the person switched this very item off before, in which case the switch stays off whatever the program does.

A program that treats that as success runs nothing and says nothing. A program that treats it as an error tells the person something is broken when nothing is. Both are wrong, which is why Status is a value rather than something flattened into an error, and why Status.Advice() exists at all — it carries the sentence to put in front of the person, naming the pane they would otherwise never find.

switch st {
case servicemanagement.Enabled:
        // nothing to say
case servicemanagement.RequiresApproval, servicemanagement.NotRegistered, servicemanagement.NotFound:
        fmt.Fprintln(os.Stderr, st.Advice())
}

It requires a bundle, and macOS hides that from you

SMAppService identifies its caller by bundle identifier. A bare executable has none — and macOS does not say so. Outside a bundle:

  • the class is there, and the factory methods hand back real objects;
  • -status answers notFound, which is exactly what a genuinely missing service answers;
  • only -register: fails, and it fails with Codesigning failure loading plist … code: -67028 — which names neither the cause nor the fix.

So every operation here asks -[[NSBundle mainBundle] bundleIdentifier] first and reports ErrNotBundled, which is a distinct sentinel from every other error in the package. Bundled() asks the same question directly, which is what a caller uses to choose between this package and a plist.

go-macos/appbundle builds the bundle, 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,
})

Where an agent's plist lives

Agent and Daemon take a plist file name — not a path, not a launchd label:

servicemanagement.Agent("io.github.go-downloader.godl.plist")

The file must be inside the bundle, at Contents/Library/LaunchAgents (Contents/Library/LaunchDaemons for a daemon). A name that resolves to nothing is not rejected: the object exists, its status is NotFound, and -register: fails with Unable to read plist (code 108). That is the one to look for when a registration that worked during development stops working the day the plist stops being copied into the bundle.

Against the legacy path

go-macos/launchagent writes a plist into ~/Library/LaunchAgents. That still works, and it is still the only thing that works for a program that is not an application — a CLI in /usr/local/bin, a build artefact, macOS 12. It is also invisible to the person: the item appears in System Settings under a reverse-DNS label with no name and no icon, and nothing tells the program when they switch it off.

launchagent prefers this package when the caller is in a bundle, and falls back to the plist when it is not.

plist in ~/Library/LaunchAgents SMAppService
needs a bundle no yes
macOS any 13+
shown to the person as a bare label the application, by name and icon
person can switch it off not really yes — and you can find out
supported by Apple deprecated for apps yes

Errors

ErrUnsupported not darwin. Every symbol exists everywhere so consumers cross-compile.
ErrNotBundled this process has no bundle identifier.
ErrTooOld no SMAppService class: macOS 12 or earlier.
ErrNoService the factory yielded nil — not the same as a service being absent, which is the NotFound status.
*SystemError an NSError from SMAppServiceErrorDomain, carrying Op, Domain, Code and macOS's own Message, verbatim.

Status is never invented from an error. A service that could not be asked about yields the zero Status and an error, because zero happens to be NotRegistered and answering "not registered" to a question nobody could ask is a lie a caller would act on.

Threads

Nothing here is AppKit, so nothing here is main-thread-only: SMAppService is XPC-backed and every call is safe from any goroutine. Each operation runs inside its own autorelease pool on a pinned OS thread — the factory methods hand back autoreleased objects, and a goroutine that migrates between creating one and messaging it drains the pool on a thread that never owned it, which is a SIGSEGV inside objc_autoreleasePoolPop with nothing of this package on the stack.

Testing

The portable half — service identity, name validation, the guard order, status typing and advice — is at 100% on every platform, through seams the tests point at fakes. The Objective-C half is covered by a live suite that calls the real framework on a real Mac; it is at 99%, the one uncovered statement being the branch where -register: succeeds, which no test may reach because reaching it means really adding a login item to the machine running the tests.

Every service the live suite names is one macOS cannot register, so it reads and is refused, and leaves nothing behind.

BSD-3-Clause.

Documentation

Overview

Package servicemanagement registers a macOS application, agent or daemon to start at login, through SMAppService — from pure Go, with CGO_ENABLED=0. It reaches the ServiceManagement framework via github.com/go-macos/objc, which reaches it through purego: no cgo, no Objective-C source file, no launchctl.

svc := servicemanagement.MainApp()
if err := svc.Register(); err != nil {
	return err
}
st, err := svc.Status()
if err != nil {
	return err
}
if st == servicemanagement.RequiresApproval {
	fmt.Println(st.Advice()) // tell the person; do not fail
}

Why this rather than a plist

The legacy way to start something at login is to write a plist into ~/Library/LaunchAgents and hope launchd reads it — which is what github.com/go-macos/launchagent does, and still does. Since macOS 13 that is no longer the supported path for an application: SMAppService is. It keeps the registration with the bundle that owns it, it appears in System Settings under the application's own name instead of as an anonymous label, and — the part a plist can never have — the person can switch it off there, and the program can find out that they did.

requiresApproval is a NORMAL outcome, not a failure

Service.Register can return nil and leave the service in RequiresApproval. That is macOS saying: it is registered, it will not run yet, a person has to allow it in System Settings > General > Login Items & Extensions. It happens routinely — most visibly when the person has switched this very item off before, in which case the switch stays off until they turn it back on, whatever the program does.

A program that treats that as success runs nothing and says nothing. A program that treats it as an error tells the person something is broken when nothing is. Both are wrong, so Status is returned as a value rather than flattened into an error, and Status.Advice carries the sentence to show.

It requires a bundle, and that is the first thing that goes wrong

SMAppService is a bundle API: it identifies the caller by its bundle identifier, and a bare executable has none. Outside a bundle the class is still there, the factory methods still hand back objects, and -status still answers — with NotFound, which is indistinguishable from a service that is genuinely absent. The failure only surfaces at -register:, as "Codesigning failure loading plist", which names neither the cause nor the fix.

So every operation here checks first, with -[NSBundle bundleIdentifier], and reports ErrNotBundled: distinct from every other error, and answerable. Bundled exposes the same question directly. github.com/go-macos/appbundle builds the .app to answer it with, in pure Go, in the same 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,
})

Where an agent's plist lives

Agent and Daemon take a plist FILE NAME, not a path and not a label. The file must be shipped inside the bundle — Contents/Library/LaunchAgents for an agent, Contents/Library/LaunchDaemons for a daemon — and the name is the leaf of it, extension included:

servicemanagement.Agent("io.github.go-downloader.godl.plist")

A name that does not resolve to a file in there is not rejected: the object exists, its status is NotFound, and -register: fails with "Unable to read plist". That is the one to look for when a registration that "worked" during development stops working once the plist stopped being copied into the bundle.

Threads

Nothing here is AppKit, so nothing here is main-thread-only: SMAppService is backed by XPC and every call in this package is safe from any goroutine. Each operation runs inside its own autorelease pool on a pinned OS thread (see objc.AutoreleasePool), because the factory methods hand back autoreleased objects and a goroutine that migrates between creating one and messaging it drains the pool on a thread that never owned it.

Portability

Every exported symbol exists on every platform, so a consumer cross-compiles without a build tag of its own; off darwin each operation reports ErrUnsupported and Bundled reports false. The portable half — service identity, name validation, status typing and advice — behaves identically everywhere and is tested to the last branch on runners with no macOS in sight.

Index

Constants

View Source
const Framework = "/System/Library/Frameworks/ServiceManagement.framework/ServiceManagement"

Framework is the ServiceManagement framework's path, opened by the darwin half on first use. It is exported because a caller that loads frameworks of its own has one list to keep, not two.

Variables

View Source
var (
	// ErrUnsupported is returned by every operation on non-darwin platforms.
	// SMAppService is macOS-only; the symbols exist everywhere so consumers
	// cross-compile.
	ErrUnsupported = errors.New("servicemanagement: unsupported on this platform (darwin only)")

	// ErrNotBundled reports that this process is not inside an application
	// bundle, so it has no bundle identifier for SMAppService to register.
	//
	// It is separate from every other error here because it is the one that
	// actually happens, and because macOS hides it: outside a bundle the
	// factory methods still return objects and -status still answers NotFound,
	// so the only native symptom is a register failure blaming code signing.
	// Build the bundle with github.com/go-macos/appbundle.
	ErrNotBundled = errors.New("servicemanagement: this process is not in an application bundle (SMAppService identifies its caller by bundle identifier)")

	// ErrTooOld reports that SMAppService does not exist in this process:
	// the class is absent, which means macOS older than 13 (Ventura).
	// A caller that must run on both keeps github.com/go-macos/launchagent as
	// its fallback.
	ErrTooOld = errors.New("servicemanagement: SMAppService is not available (macOS 13 or later is required)")

	// ErrNoService reports that the ServiceManagement factory yielded nil for
	// this service. The service cannot be spoken about at all, which is not
	// the same as it being absent — that is reported as the [NotFound] status.
	ErrNoService = errors.New("servicemanagement: SMAppService returned no object for this service")
)

Sentinel errors. They are stable and may be compared with errors.Is.

Functions

func Bundled

func Bundled() (string, bool)

Bundled reports this process's bundle identifier, and whether it has one.

It is the question SMAppService really asks, and the honest answer to "will any of this work" — a path that merely looks like a .app is not enough, since a bundle with no CFBundleIdentifier fails the same way a bare executable does. Off darwin it reports false, which is the truth there too.

Types

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is one registrable service: the application itself, one of the agents or daemons its bundle ships, or a login item belonging to it.

The zero Service is MainApp. It is a value, so it costs nothing to keep and nothing to release; the Objective-C object is made and dropped inside each operation.

func Agent

func Agent(plistName string) Service

Agent is a per-user LaunchAgent the bundle ships, named by its plist's FILE NAME — "io.example.thing.plist", not a path and not a label. The file must be at Contents/Library/LaunchAgents inside the bundle; +[SMAppService agentServiceWithPlistName:] resolves it there and nowhere else.

func Daemon

func Daemon(plistName string) Service

Daemon is a system-wide LaunchDaemon the bundle ships, named by its plist's file name at Contents/Library/LaunchDaemons.

Registering one asks for an administrator's password: a daemon runs as root, before anybody logs in. That prompt is macOS's, and it arrives whether or not the calling program expected it.

func LoginItem

func LoginItem(identifier string) Service

LoginItem is a helper application inside this bundle, named by ITS bundle identifier: +[SMAppService loginItemServiceWithIdentifier:]. It is the modern replacement for SMLoginItemSetEnabled.

func MainApp

func MainApp() Service

MainApp is the application this process is in: +[SMAppService mainAppService]. Registering it is what "open at login" means for an application.

func (Service) Name

func (s Service) Name() string

Name is the plist file name or login-item identifier the service was made with, and "" for MainApp.

func (Service) Register

func (s Service) Register() error

Register asks macOS to start this service at login.

A nil error does NOT mean it will run. macOS may register the service and leave it switched off, waiting for the person to allow it; read Status and show Status.Advice when it comes back RequiresApproval. That is not a failure and must not be reported as one.

Registering an already-registered service is not an error: it is how a program re-asserts a registration after an update.

func (Service) Status

func (s Service) Status() (Status, error)

Status reports what macOS currently thinks of the service.

It never invents a status from an error: a service that cannot be asked about yields an error and the zero Status, not NotFound. Outside a bundle macOS itself answers NotFound for everything, and passing that on would be this package telling a caller its plist is missing when the real answer is that nobody asked.

func (Service) String

func (s Service) String() string

String describes the service, for a log or an error.

func (Service) Unregister

func (s Service) Unregister() error

Unregister takes the registration away.

macOS reports unregistering something that was never registered as an error (code 22, "Invalid argument") rather than as the no-op it looks like. That is left as macOS reports it rather than swallowed here: a caller for whom "gone is gone" is the wanted outcome reads Status first, and a caller that swallowed it blindly would also swallow a real refusal.

type Status

type Status int

Status is SMAppServiceStatus: what macOS currently thinks of a service.

The numeric values are Apple's, not ours: they cross the ObjC boundary as an NSInteger and are pinned here so a mis-ordered constant cannot turn "enabled" into "needs approval" silently.

const (
	// NotRegistered means the service is known and has never been registered,
	// or has been unregistered. Nothing will run.
	NotRegistered Status = 0
	// Enabled means the service is registered and allowed to run. This is the
	// one outcome that needs nothing said to anybody.
	Enabled Status = 1
	// RequiresApproval means the service is registered and will NOT run until
	// a person allows it in System Settings. It is a normal outcome of a
	// successful [Service.Register] — see [Status.Advice].
	RequiresApproval Status = 2
	// NotFound means macOS has no such service: no plist of that name in the
	// bundle, or a bundle it does not recognise. It is also what a process
	// outside a bundle sees for everything, which is why the operations here
	// refuse to reach this far without one.
	NotFound Status = 3
)

func (Status) Advice

func (s Status) Advice() string

Advice is the sentence to show a person, or "" when there is nothing they need to do.

It exists so that RequiresApproval cannot be handled by silence. A program that registered successfully and starts nothing has to say why, and the only place the person can act is a Settings pane they will not find by guessing.

func (Status) Registered

func (s Status) Registered() bool

Registered reports whether macOS holds a registration for the service — Enabled or RequiresApproval. It is the question "did my Register take", which is not the same as "will it run": a service awaiting approval is registered and idle.

func (Status) Running

func (s Status) Running() bool

Running reports whether the service is actually allowed to start, which is Enabled alone.

func (Status) String

func (s Status) String() string

String names the status the way Apple's own constant does.

type SystemError

type SystemError struct {
	// Op is "register" or "unregister".
	Op string
	// Service is the service the operation was for.
	Service string
	// Domain is the NSError domain, normally "SMAppServiceErrorDomain".
	Domain string
	// Code is the NSError code.
	Code int
	// Message is -localizedDescription, verbatim.
	Message string
}

SystemError is an NSError from SMAppServiceErrorDomain, with the operation that produced it.

The message is macOS's own -localizedDescription, carried through unaltered rather than translated into something friendlier: it is the only text that says which of the several ways a registration can fail actually happened, and a friendlier summary would lose exactly that.

Three that come up, from this package's own runs:

  • code 3, "Codesigning failure loading plist" — the caller is not in a bundle. This package reports ErrNotBundled before reaching that.
  • code 108, "Unable to read plist: NAME" — there is a bundle, but no plist of that name inside it.
  • code 22, "Invalid argument" — unregistering something that was never registered. Read Service.Status first if that is not an error to you.

func (*SystemError) Error

func (e *SystemError) Error() string

Error implements the error interface.

Jump to

Keyboard shortcuts

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