Skip to content
macOSDeep Dive Published Updated 6 min readViews unavailable

SMAppService on macOS: Login Items, Launch Agents, and User Approval

How modern macOS apps register bundled background helpers with SMAppService, interpret approval status, migrate older installs, and avoid persistence traps.

A background helper is not just another process an app should copy into a hidden directory and launch forever. Modern macOS surfaces login and background items to the user, validates their relationship to the containing app, and lets the user disable them in System Settings. Beginning in macOS 13, SMAppService is the Service Management API for registering these bundled services.

The API covers login-item applications, user launch agents, privileged launch daemons, and the main application as a login item. Registration tells macOS which bundled component should be managed. It does not guarantee the user approved it, that the process is currently running, or that an old manually installed helper disappeared.

Choose the service type before designing the bundle

An ordinary login item is an application bundled inside the main app. It runs in the user’s GUI context and fits work that needs an app lifecycle. A launch agent uses a launchd property list and runs in the logged-in user’s domain. A launch daemon runs system-wide and is appropriate only for work that truly requires that context.

SMAppService provides constructors for these roles, including login items by identifier and agents or daemons by property-list name. The corresponding executable and configuration must live in the bundle locations macOS expects. Treat that layout as part of the signed product, not something assembled after installation.

Do not choose a daemon merely to avoid user-session constraints. System-wide execution increases update, authorization, and attack-surface costs. Split privileged operations into a narrow service interface and keep UI, networking, and parsing in the least-privileged process.

Registration is a request with visible status

Calling register() can succeed while the service still requires user approval. Inspect the service’s status and design UI for the actual state. The status values distinguish enabled, not registered, not found, and approval-required conditions.

let helper = SMAppService.loginItem(identifier: "com.example.App.Helper")

do {
    try helper.register()
    switch helper.status {
    case .enabled:
        showBackgroundFeatureReady()
    case .requiresApproval:
        explainHowToReviewLoginItems()
    default:
        showBackgroundFeatureUnavailable()
    }
} catch {
    reportRegistrationFailure(error)
}

Do not loop on register() when approval is required. The decision belongs to the user. Apple provides openSystemSettingsLoginItems() so the app can take the user to the relevant settings area after a clear explanation, but the app should not nag on every launch.

Status is not process liveness

An enabled service is allowed and registered. It may be idle because its launchd conditions have not fired, restarting after a crash, or failing immediately due to a configuration error. Conversely, a legacy copy may still run even when the new service reports not registered.

Use an authenticated IPC health exchange when the main app must know whether a helper is operational. Include protocol version and build identity so a stale helper cannot impersonate a compatible one. Bound connection attempts and present a recoverable diagnostic rather than starting arbitrary duplicate processes.

launchd owns launch policy. Do not add a timer in the main app that repeatedly invokes the executable, and do not assume KeepAlive means an unconditionally immortal process. Configure only supported keys and let crash throttling work.

Signing binds the helper to the product

Every nested executable must be signed correctly before the outer application is sealed. A post-build script that replaces a helper invalidates the containing signature. Archive and notarize the exact bundle that registration will expose.

The helper should validate connecting clients through an appropriate code-signing or audit-token policy before accepting privileged requests. A private Mach service name is not authentication. The main app should likewise verify it reached the expected service and not an unrelated process with a similar interface.

Keep the IPC contract narrow. Pass file descriptors or security-scoped references when possible instead of granting a helper unrestricted path access. Validate message sizes, types, and authorization inside the helper even if the GUI already checked them.

Updating requires a coordinated lifecycle

Because the service is bundled with the app, moving or replacing the containing application changes the executable it represents. Updates must preserve a valid signed bundle and allow launchd to transition between versions. The helper should complete or checkpoint work so termination does not corrupt state.

Use a versioned data format with atomic writes. The new helper may encounter state written by the old one, and rollback may encounter state written by the new one. A schema migration that only works forward can make an application update impossible to undo.

After an update, check the service status and perform a protocol handshake. Do not unregister and re-register on every launch as a general update mechanism. That can disrupt user intent and turn ordinary launches into persistent configuration churn.

Legacy migration must remove only what the app owns

Older products may have used SMLoginItemSetEnabled, copied launch-agent plists into Library directories, or installed privileged helpers with earlier Service Management APIs. Apple’s migration guidance describes how modern registrations relate to those earlier mechanisms.

Inventory every historical bundle identifier, label, path, and executable signature before cleanup. Remove only an artifact whose identity and ownership can be proven. A generic deletion of a similarly named plist can damage another product or an administrator’s configuration.

Migration should be idempotent: detect old state, register the modern bundled service, verify expected status, then remove obsolete owned files when safe. If the user had disabled the old component, do not reinterpret migration as consent to enable a new one.

Unregister is part of the product contract

When the user disables the feature inside the app, call unregister() and wait for its result. Also stop scheduling new work and let in-flight operations reach a safe boundary. Unregistration changes management state; design the helper to exit cleanly when its service is removed.

Application deletion and feature disablement are different. A deleted app cannot run cleanup code, so the platform’s bundled-item model is preferable to scattering executables across writable directories. Still provide clear in-app controls while the app is present.

Test a fresh install, approval denied, approval later granted, service disabled in System Settings, app moved, app updated while the helper runs, downgrade, damaged signature, missing helper, and old-install migration. Include standard and administrator accounts where daemon approval is relevant.

SMAppService makes background execution legible to macOS and to the user. A professional implementation embraces that visibility: bundle and sign the helper, request registration once, interpret status honestly, authenticate IPC, migrate conservatively, and treat disablement as a supported state rather than an error to defeat.

Related:

Sources:

Comments