Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

NSWorkspace on macOS: Open URLs and Hand Off Work Reliably

Use NSWorkspace to open files and URLs asynchronously, handle completion errors, select an app intentionally, and avoid stale assumptions about Launch Services.

When an app asks macOS to open a file or URL, it is handing work to the workspace and the system’s app-selection services. The request can fail, the destination app may already be running, the URL may be unsupported, or the chosen handler may change between launches. NSWorkspace provides asynchronous open and launch APIs so applications can make this handoff without treating another app’s startup as a synchronous function call.

Use NSWorkspace.shared for standard system-managed opening. Prefer URL-based APIs and NSWorkspace.OpenConfiguration for modern code; many older path and application-name methods are deprecated. An open request is not proof that the destination completed its own workflow. It tells your app whether the handoff succeeded and may identify the app that accepted it.

Open with the user’s default handler

To open a document with its default app or a web URL with the user’s browser, call the asynchronous open(_:configuration:completionHandler:) method. Use an absolute, well-formed URL and handle both result branches. The completion handler runs on a concurrent queue according to Apple’s documentation, so marshal any AppKit UI update to the main actor and avoid capturing non-Sendable mutable state in Swift concurrency code.

import AppKit

func openExternally(_ url: URL) {
    let configuration = NSWorkspace.OpenConfiguration()
    NSWorkspace.shared.open(url, configuration: configuration) { app, error in
        if let error {
            Task { @MainActor in
                presentOpenFailure(error)
            }
            return
        }

        guard app != nil else {
            Task { @MainActor in
                presentOpenFailure(OpenError.noHandlerReported)
            }
            return
        }
        // The system accepted the request. This is not proof that the
        // destination completed a user-level operation.
    }
}

The placeholder OpenError is application-defined. A successful handoff should not be reported as “file imported” or “message sent” unless your app independently receives confirmation of that action. Keep the user-facing language accurate: “Opened in another app” is different from “Saved successfully.”

Choose a destination app when the product requires it

Some workflows must open a URL in a specific installed app. Use the overload that accepts an application URL and pass an OpenConfiguration. Resolve the app bundle deliberately; do not guess its location from a display name or assume it is installed in /Applications. If the bundle is missing or has been moved, show a recoverable explanation and offer the default handler when that makes sense.

Opening multiple URLs is one request, but the destination may still handle them individually and may reject some content. Validate your input list, preserve meaningful ordering if the API/product contract requires it, and make completion behavior clear. Avoid opening duplicate URLs caused by retry logic; give each user command a request ID so an accidental double click does not launch repeated work.

Use the configuration properties for supported behaviors such as printing or controlling launch presentation. Do not invent flags based on old Launch Services APIs. The current documentation and SDK availability are the authority for which configuration options exist in a target macOS release.

Opening a URL with its default handler and launching an application bundle are separate requests. Use the URL-opening method when the intent is “show this content”; use the application-opening method when the intent is “start this specific app.” That distinction matters when a custom scheme has several possible handlers or when a product knows exactly which bundled companion it requires. After a launch completion, retain the returned NSRunningApplication only if later behavior genuinely depends on observing that process; do not store it as a permanent identity for the app, since applications can terminate and relaunch.

Batching multiple URLs can reduce repeated user interaction, but it should represent one intentional user command. Before issuing the request, filter out malformed and duplicate inputs while preserving the order that the user selected. If a URL list comes from a document or server response, label the action clearly so users understand that it may bring another application to the foreground. Avoid automatically opening a large number of tabs or windows during app launch just because old state contained a list of external URLs.

Keep the source UI state independent from the external app’s activation state. A handoff may activate another app, but your own document model should not assume the user has abandoned it. When the completion callback arrives, update a progress label only if it still corresponds to the current request. A generation identifier prevents a delayed failure from an earlier request from overwriting the result of a more recent successful open.

Handoff latency and activation

App launch can take noticeable time, and the target can already be running or can fail during startup. Keep the originating UI responsive and indicate progress only when the user needs to wait. Most simple open requests should complete in the background while the original app remains usable. Do not block the main thread waiting for a process identifier or poll runningApplications in a tight loop to infer success.

If your own feature needs to react to app lifecycle events, use the documented workspace notification center and understand its coverage. For example, didLaunchApplicationNotification is not posted for all background or agent apps; Apple documents that some apps are omitted and recommends observing runningApplications when the use case truly needs all app launches and terminations. Do not assume one notification is a complete process audit stream.

URL and file semantics

Opening a file URL delegates access to another app; it does not make the file part of your app’s model. A URL can be stale, refer to a package, or no longer exist by the time the handler opens it. When your app owns the document workflow, use NSDocument and file coordination as appropriate rather than launching an external editor as a substitute for document integration.

For custom URL schemes, validate scheme and host before opening them. A URL may contain user-controlled query parameters, private data, or a path that is no longer trusted. Avoid placing sensitive information in a URL that will be handed to another process or appear in diagnostics. If the system opens a remote URL in a browser, your app cannot assume a network request occurred or that the browser accepted it.

For file: URLs, create them using URL APIs rather than manual string concatenation. Standardize path handling carefully and preserve the correct file identity. A symlink or package directory can make a lexical path comparison misleading. The receiving application is responsible for its own access checks and file interpretation; your application should not claim the external operation succeeded beyond the handoff contract.

Error handling and retries

Classify errors into useful user-facing cases without exposing low-level internals unnecessarily. An unavailable handler, invalid URL, launch denial, or destination startup error should not all produce the same vague message. Preserve the NSError domain and code for sanitized diagnostics, but do not log full document paths or URL query contents when they may contain personal data.

Retry only when the failure is plausibly transient and the operation is safe to repeat. An automatic retry can open duplicate windows or print multiple copies. For a user-triggered retry, keep the original target and request context and let the user confirm if repeating the action has a side effect. If a completion arrives after the originating window closes, suppress the stale UI update while retaining appropriate telemetry.

Keep retry state bounded and visible. A failed request should not remain in an invisible automatic retry loop after the user dismisses its error. If the user selects “Try Again,” submit a new operation with a new request identifier and report the new result. For a sequence of unrelated URLs, track outcomes per item so one invalid location does not make the whole batch look like it failed when other URLs opened successfully, if the chosen API exposes enough information to make that distinction.

When an app offers both an internal preview and external opening, make the choice explicit. Internal preview keeps the user within the app and can apply the app’s own rendering policy; external opening delegates to another handler with its own settings and behavior. Do not silently switch between these paths based on an undocumented heuristic. The user should be able to predict whether a click changes windows, launches software, or navigates the current document.

Verification

Test the default handler, a missing explicit app, an unsupported URL, a removed file, a package document, a remote URL, a slow launch, an already-running destination, a closed originating window, and repeated activation. Confirm the UI remains responsive and completion is handled on the correct executor. For file handoffs, test a URL whose target moves or disappears between request creation and completion.

An NSWorkspace handoff is reliable when the source makes a precise request, uses current URL-based APIs, handles asynchronous failure, and describes only what it can observe. Treat Launch Services as a broker between apps, not as an extension of your own process or a guarantee about what the receiving app will do.

Related:

Sources:

Comments