How an iOS app extension talks to its containing app

19 October 2026

Most of the time, it can't. There's no open channel between an extension and its containing app, and the app usually isn't even running while the extension is. What works for nearly every extension, Notification Service and Location Push Service extensions included, is to write what the app needs into the App Group container and have the app read it the next time it runs.

This article covers how to do that properly, the handful of cases where you can do more, and before any of it, whether you need to reach the app at all.

Do you need the app?

The usual reason for wanting to call into the app is that the app already has the code. It parses the payload, updates the database, talks to the server, and nobody wants a second copy of all that in the extension. Sometimes the worry is that the existing code won't fit in the limits of extensions. Extensions get much less memory than apps (Apple's extension guide calls the limits "significantly lower"), and a notification service extension gets no more than 30 seconds.

The first problem doesn't need the app. Put the code in a framework or a Swift package that both targets link, and the extension runs the same code in its own process. [The extension guide] (https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionScenarios.html) recommends exactly that, with "Require Only App-Extension-Safe API" turned on for the framework/package. Anything in the shared code that extensions can't use gets marked, and the compiler keeps the extensions away from it:

import UIKit

/// Shared by the app and its extensions.
enum Thumbnails {
    /// Fine everywhere: plain image work.
    static func make(from data: Data, side: CGFloat) -> UIImage? {
        guard let image = UIImage(data: data) else { return nil }
        let size = CGSize(width: side, height: side)
        return UIGraphicsImageRenderer(size: size).image { _ in
            image.draw(in: CGRect(origin: .zero, size: size))
        }
    }

    /// Uses UIApplication, so it's for the app only.
    @available(iOSApplicationExtension, unavailable)
    @MainActor
    static func makeInBackground(_ work: @escaping @Sendable () -> Void) {
        let task = UIApplication.shared.beginBackgroundTask(withName: "Thumbs")
        work()
        UIApplication.shared.endBackgroundTask(task)
    }
}

Call makeInBackground from an extension and the build stops with "'makeInBackground' is unavailable in application extensions for iOS".

For the second problem, do only what can't wait. A notification service extension has to decrypt the text before it's shown; rebuilding the chat index can happen later. A location push extension wants to update a server, creating a path can be done when the app is visible.

Instead, leave the app a note in the shared container saying what's left, and let the app do it when it next runs, or schedule a BGProcessingTask for it then. The work happens later, with the app's memory and the app's time.

If neither of those covers it and the app really does have to hear from the extension, here are your options (depending on the extension type you have)

The mailbox

The extension writes each message as its own file in the App Group container, then posts a Darwin notification. If the app is running, it hears the notification and reads the files. Running or not, it also reads them at launch and every time it becomes active, so a message written while the app was suspended or not running is still there when it looks.

The notification is only there to make it quicker. If it never arrives, nothing is lost, because the next launch or activation picks the message up anyway. MMWormhole made this pattern popular years ago, and its README makes the same point: the notification reaches the app only if the app is awake.

Both targets share this code:

import Foundation
import notify

/// Observes one Darwin notification name until released.
final class DarwinObserver {
    private var token: Int32 = NOTIFY_TOKEN_INVALID

    /// Calls `handler` on the main queue each time `name` is posted.
    init(_ name: String, handler: @escaping @MainActor () -> Void) {
        notify_register_dispatch(name, &token, .main) { _ in
            MainActor.assumeIsolated { handler() }
        }
    }

    deinit {
        notify_cancel(token)
    }
}

/// One message, as the extension wrote it.
struct Envelope<Body: Codable & Sendable>: Codable, Sendable {
    let sent: Date
    let body: Body
}

/// A directory of message files in the App Group container.
struct Mailbox: Sendable {
    /// The Darwin notification that says a message has arrived.
    static let ping = "group.com.example.app.mailbox"

    private let inbox: URL
    private let staging: URL

    /// The mailbox in `appGroup`, or nil if the group is not entitled.
    init?(appGroup: String) {
        let files = FileManager.default
        guard let root = files.containerURL(
            forSecurityApplicationGroupIdentifier: appGroup)
        else { return nil }
        inbox = root.appending(path: "Mailbox/Inbox")
        staging = root.appending(path: "Mailbox/Staging")
        do {
            try files.createDirectory(
                at: inbox, withIntermediateDirectories: true)
            try files.createDirectory(
                at: staging, withIntermediateDirectories: true)
        } catch {
            return nil
        }
    }

    /// Extension side: writes one message, then rings the doorbell.
    func send<Body: Codable & Sendable>(_ body: Body) throws {
        let envelope = Envelope(sent: .now, body: body)
        let data = try JSONEncoder().encode(envelope)
        let name = UUID().uuidString + ".json"
        // Write the whole file outside the inbox, then move it in. A
        // move within one volume is atomic, so the app never sees half
        // a file.
        let staged = staging.appending(path: name)
        try data.write(to: staged)
        try FileManager.default.moveItem(
            at: staged, to: inbox.appending(path: name))
        notify_post(Mailbox.ping)
    }

    /// App side: hands each waiting message to `handle`, oldest first,
    /// and deletes its file only after `handle` returns. A message whose
    /// handling throws stays in the inbox for the next drain.
    func receive<Body: Codable & Sendable>(
        _ type: Body.Type, handle: (Body) throws -> Void
    ) throws {
        let files = FileManager.default
        let urls = try files.contentsOfDirectory(
            at: inbox, includingPropertiesForKeys: nil)
            .filter { $0.pathExtension == "json" }
        // A file that will not decode stays where it is, for inspection.
        let waiting = urls.compactMap { url -> (URL, Envelope<Body>)? in
            guard let data = try? Data(contentsOf: url),
                  let envelope = try? JSONDecoder().decode(
                      Envelope<Body>.self, from: data)
            else { return nil }
            return (url, envelope)
        }
        for (url, envelope) in waiting.sorted(by: { $0.1.sent < $1.1.sent }) {
            try handle(envelope.body)
            try files.removeItem(at: url)
        }
    }
}

A message is written outside the inbox and moved in, so the app can't read half of one. Its file is deleted after the app has handled it, and not before. Nothing keeps a file open between calls, which matters more than it looks; see the section on the shared container.

Sending, from any extension:

/// What the share extension hands over.
struct SharedLink: Codable, Sendable {
    let url: URL
    let note: String
}

func handOff(_ url: URL, note: String) throws {
    let mailbox = Mailbox(appGroup: "group.com.example.app")
    try mailbox?.send(SharedLink(url: url, note: note))
}

Reading, in the app. It drains the inbox when the doorbell rings, at launch, and whenever the scene becomes active:

import Observation
import SwiftUI

/// The app's side of the mailbox.
@MainActor @Observable
final class Inbox {
    private(set) var links: [SharedLink] = []
    @ObservationIgnored
    private let mailbox = Mailbox(appGroup: "group.com.example.app")
    @ObservationIgnored
    private var observer: DarwinObserver?

    init() {
        observer = DarwinObserver(Mailbox.ping) { [weak self] in
            self?.drain()
        }
        drain()
    }

    /// Moves every waiting message out of the mailbox.
    func drain() {
        guard let mailbox else { return }
        do {
            try mailbox.receive(SharedLink.self) { links.append($0) }
        } catch {
            // Whatever was not handled is still in the inbox; the next
            // drain picks it up.
        }
    }
}

@main
struct ExampleApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @State private var inbox = Inbox()

    var body: some Scene {
        WindowGroup {
            List(inbox.links, id: \.url) { Text($0.note) }
        }
        .onChange(of: scenePhase) { _, phase in
            if phase == .active { inbox.drain() }
        }
    }
}

In a UIKit app, call drain() from sceneDidBecomeActive(_:) instead.

A race condition can make the message arrive twice. If the app is killed after handling one and before deleting its file, the next drain hands it over again, so whatever the app does with a message has to cope with seeing it a second time. The array in the example only stands in for that; a real app saves the message somewhere before the file goes.

Notification service extensions

A notification service extension runs when an alert notification with mutable-content arrives, and it has no more than 30 seconds to process the notification and take any other necessary actions. It can't launch the app or open it. The usual request is to get whatever the push brought into the app's data straight away.

For that, use the mailbox. Write what the app needs, ring the doorbell, and hand the content back:

import UserNotifications

/// What the notification tells the app.
struct ArrivedMessage: Codable, Sendable {
    let id: String
}

final class NotificationService: UNNotificationServiceExtension {
    private var contentHandler: ((UNNotificationContent) -> Void)?
    private var content: UNMutableNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler:
            @escaping (UNNotificationContent) -> Void
    ) {
        self.contentHandler = contentHandler
        let content = request.content.mutableCopy()
            as? UNMutableNotificationContent
        self.content = content

        // A note for the app. It reads it the next time it runs, or now,
        // if it's running and hears the doorbell.
        if let id = request.content.userInfo["id"] as? String {
            try? Mailbox(appGroup: "group.com.example.app")?
                .send(ArrivedMessage(id: id))
        }
        contentHandler(content ?? request.content)
    }

    override func serviceExtensionTimeWillExpire() {
        if let contentHandler, let content {
            contentHandler(content)
        }
    }
}

For data the app needs only when the person taps the notification, there's a simpler route: leave it in the notification's userInfo. A tap that launches the app brings the response in the scene's connection options as notificationResponse, and a tap while the app is running goes to userNotificationCenter(_:didReceive:withCompletionHandler:). Either way, the payload is in response.notification.request.content.userInfo.

What the extension can't do is get the app to run before the person opens it. If that matters, you can try to have your server send a background notification as well; see the section on pushes further down to understand the downsides of that.

Location push service extensions

A location push service extension runs when your server sends a location push. It finds the device's location and sends it to your server, which is the job Apple designed it for. Apple's article on creating one lists the conditions. The app needs Always authorization, and a device gets about 360 location pushes a day, refilled at roughly one every few minutes.

For the app's copy, it's the mailbox again:

import CoreLocation

/// A location for the app to pick up.
struct SharedLocation: Codable, Sendable {
    let latitude: Double
    let longitude: Double
    let time: Date
}

final class LocationPushService: NSObject, CLLocationPushServiceExtension,
                                 CLLocationManagerDelegate {
    private var completion: (() -> Void)?
    private var manager: CLLocationManager?

    func didReceiveLocationPushPayload(
        _ payload: [String: Any], completion: @escaping () -> Void
    ) {
        self.completion = completion
        let manager = CLLocationManager()
        manager.delegate = self
        manager.requestLocation()
        self.manager = manager
    }

    func locationManager(_ manager: CLLocationManager,
                         didUpdateLocations locations: [CLLocation]) {
        if let location = locations.last {
            // Send it to your server first. Then leave a copy for the app.
            try? Mailbox(appGroup: "group.com.example.app")?.send(
                SharedLocation(latitude: location.coordinate.latitude,
                               longitude: location.coordinate.longitude,
                               time: location.timestamp))
        }
        finish()
    }

    func locationManager(_ manager: CLLocationManager,
                         didFailWithError error: any Error) {
        finish()
    }

    func serviceExtensionWillTerminate() {
        finish()
    }

    private func finish() {
        completion?()
        completion = nil
    }
}

The same article tells you not to keep sending pushes for long-running or frequent location work, and to hand that work to the app instead, with Core Location's liveUpdates(_:). That reads like a call the extension makes. It isn't. Nothing gets handed over for you, and the extension has no way to start anything in the app.

What it means is that the app runs the continuous part itself. The WWDC23 session Discover streamlined location updates explains the pieces. An app receiving live updates in a CLBackgroundActivitySession keeps getting them in the background, and if it's terminated, Core Location launches it in the background again when there are updates. On that launch the app has to call liveUpdates(_:) again and recreate its session objects. The catch is that a background activity session can only be started while the app is in the foreground; from the background, the app can only rejoin one it started earlier. Apple's background location guide says the same of CLServiceSession.

So the app starts continuous sharing when the person turns it on, with the app on screen, and keeps it going until they turn it off:

import CoreLocation

/// Continuous sharing, run by the app itself.
@MainActor
final class LocationSharing {
    private var service: CLServiceSession?
    private var background: CLBackgroundActivitySession?
    private var updates: Task<Void, Never>?

    /// Call when the person turns sharing on, with the app on screen,
    /// and again at every launch while sharing is on.
    func start() {
        service = CLServiceSession(authorization: .always)
        background = CLBackgroundActivitySession()
        updates = Task {
            do {
                for try await update in CLLocationUpdate.liveUpdates() {
                    if let location = update.location { report(location) }
                }
            } catch {
                // The updates ended; log it.
            }
        }
    }

    /// Call when the person turns sharing off.
    func stop() {
        updates?.cancel()
        background?.invalidate()
        service?.invalidate()
        updates = nil
        background = nil
        service = nil
    }

    private func report(_ location: CLLocation) {
        // Send it to your server.
    }
}

Keep a flag in the shared container that says sharing is on, and call start() at launch whenever it's set. The extension then handles the occasional request while continuous sharing is off. If the extension learns that continuous sharing should start, it can only leave a note in the mailbox; the app acts on it the next time the person opens it.

Darwin notifications Not to be confused by push notifications

The mailbox's doorbell is a Darwin notification: a name posted to the system's notification server, which tells every process registered for that name. The API is notify.h, and the two calls you need are notify_post and notify_register_dispatch, both used in the mailbox code above. Every process on the device shares the same names, so start yours with your App Group identifier.

What a notification carries

Nothing. A posted notification is an event with no contents at all.

The notification server does keep one 64-bit number per name, which any process can set with notify_set_state and read with notify_get_state. That number isn't attached to any notification; it's a variable that happens to live next to the name. Each set replaces the old value, and a process gets whatever the value is at the moment it asks. In my test below, three notifications posted with the values 1, 2 and 3 all read 3 when they were delivered. A version counter is about the most it's good for. Messages go in the container.

Use notify.h or Core Foundation?

Core Foundation offers the same notifications through CFNotificationCenterGetDarwinNotifyCenter(), which its documentation describes as a front end to notify.h. Its functions are the same ones used for distributed notifications on the Mac, which is why they take an object, a userInfo dictionary and a deliverImmediately flag. For Darwin notifications, CFNotificationCenterPostNotification ignores all three. No version of a Darwin notification carries a dictionary, or reaches a suspended app any sooner.

So the choice is only about convenience. The Core Foundation version needs the main thread's run loop and a C callback with a context pointer. notify.h takes a block, runs it on any queue you like, and gives you the state variable. I use notify.h.

When the app is suspended

Apple's reply to a developer whose background app kept missing these: iOS doesn't resume a suspended app to deliver one. If the app is resumed later, it should get the notification then. If it's terminated while suspended, it never does.

I wanted to know what "should" means in practice, so I tested it in the iOS 27 simulator. The test app logs a line every second while it runs, so the log shows exactly when it's suspended, and logs every Darwin notification it receives.

Posted while suspendedThenDelivered
3 notifications, 5 s apartresumed 30 s later3, within a millisecond of resuming, all reading the last state value
1 notificationresumed about 12 hours later1, on resuming
100 notifications in 14 sresumed 5 s later17, on resuming
1 notificationterminated, then launchednone

A notification posted to a suspended app is held for as long as the process lives. There's no documented time limit. The practical limit is the suspended process itself, which the system can terminate at any moment to free memory. And what's held isn't a queue. The notify(3) man page says notifications posted in quick succession may be coalesced, and that the kernel's queues for them are limited, which is how 100 posts turned into 17. One delivery tells you something was posted since you last looked. It never tells you how many times.

Run tests like this from the Home Screen. Apple's reply makes the point too: under the Xcode debugger the app is never suspended, so notifications that would wait, or be lost, arrive straight away.

CFMessagePort

CFMessagePort gets suggested because it can carry data. Its documentation says CFMessagePortCreateLocal returns NULL on iOS 7 and later, and on the Mac, where it does work, Apple's advice is to use XPC. Leave it alone.

Getting the app to run

The mailbox waits for the app. These don't, but each one works only for some extensions, or only from some parts of the system. Check that yours is one of them before you plan around it.

A background URL session

If the extension has a file to download or upload, it can hand the transfer to a background URLSession and finish. When the transfer is done, the system wakes the containing app to deal with the result, not the extension. Apple's guide describes this, and a post by Apple DTS on networking in short-lived extensions fills in the details.

It goes like this:

  1. The extension creates a background session in the App Group, starts the task, and finishes. The system carries on with the transfer.
  2. When the transfer ends and no process is connected to the session, the system resumes the app, or launches it in the background, and calls application(_:handleEventsForBackgroundURLSession:completionHandler:).
  3. The app creates a session with the same identifier, which reconnects to the transfer, and the session's delegate gets the results.
  4. When the session calls urlSessionDidFinishEvents(forBackgroundURLSession:), the app invalidates the session and calls the completion handler it got in step 2, on the main thread.

The configuration and the delegate go in shared code:

import Foundation

/// The background session both targets use.
enum Transfer {
    static let sessionID = "com.example.app.transfer"
    static let appGroup = "group.com.example.app"

    /// Creates, or reconnects to, the shared background session.
    static func session(delegate: URLSessionDelegate) -> URLSession {
        let configuration = URLSessionConfiguration
            .background(withIdentifier: sessionID)
        // Required in an extension: without it the session is
        // invalidated as soon as it is created.
        configuration.sharedContainerIdentifier = appGroup
        // Wake or launch the app when the transfer needs attention.
        configuration.sessionSendsLaunchEvents = true
        return URLSession(configuration: configuration,
                          delegate: delegate, delegateQueue: nil)
    }
}

/// A downloaded file waiting in the App Group container.
struct DownloadedFile: Codable, Sendable {
    let name: String
}

/// Compiled into both targets: whichever process is connected to the
/// session when a task finishes gets the callback.
final class TransferDelegate: NSObject, URLSessionDownloadDelegate {
    private let finishedEvents: @Sendable () -> Void

    init(finishedEvents: @escaping @Sendable () -> Void = {}) {
        self.finishedEvents = finishedEvents
    }

    func urlSession(_ session: URLSession,
                    downloadTask: URLSessionDownloadTask,
                    didFinishDownloadingTo location: URL) {
        // `location` is temporary: move the file before returning, into
        // the shared container, and tell the app it is there.
        let files = FileManager.default
        guard let root = files.containerURL(
                  forSecurityApplicationGroupIdentifier: Transfer.appGroup),
              let mailbox = Mailbox(appGroup: Transfer.appGroup)
        else { return }
        let name = UUID().uuidString
        do {
            try files.moveItem(at: location,
                               to: root.appending(path: name))
            try mailbox.send(DownloadedFile(name: name))
        } catch {
            // Log it; the download has to be retried.
        }
    }

    func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        finishedEvents()
    }
}

The extension starts the download and gets out of the way:

func download(_ url: URL) {
    let session = Transfer.session(delegate: TransferDelegate())
    session.downloadTask(with: url).resume()
    // The extension can be terminated from here on; the system finishes
    // the download and hands the result to the app.
    extensionContext?.completeRequest(returningItems: nil)
}

The app picks it up:

import UIKit

final class AppDelegate: NSObject, UIApplicationDelegate {
    private var session: URLSession?
    private var finishEvents: (() -> Void)?

    func application(
        _ application: UIApplication,
        handleEventsForBackgroundURLSession identifier: String,
        completionHandler: @escaping () -> Void
    ) {
        finishEvents = completionHandler
        // Creating the session with the same identifier reconnects to
        // it. Never create a second one with the same identifier in one
        // process.
        guard session == nil else { return }
        session = Transfer.session(delegate: TransferDelegate { [weak self] in
            Task { @MainActor in self?.eventsDone() }
        })
    }

    private func eventsDone() {
        // Disconnect, so the extension can use the session next time,
        // then tell the system the app has handled everything.
        session?.finishTasksAndInvalidate()
        session = nil
        finishEvents?()
        finishEvents = nil
    }
}

A SwiftUI app attaches this class with @UIApplicationDelegateAdaptor.

Three things trip people up. Only one process can be connected to a background session at a time, and a second one that creates it gets NSURLErrorBackgroundSessionInUseByAnotherProcess; that's why the app invalidates the session when it's done with it. A short transfer may finish while the extension is still around and complete there, a long one completes in the app, and you can't choose, so the delegate lives in both. And with Background App Refresh turned off, for the app or for the whole device, the system doesn't wake the app for the session at all. Apple's reply dates that to iOS 13. The extension can't check the setting, but the app can, and can warn the person that transfers finish only when they open it:

let wakes = UIApplication.shared.backgroundRefreshStatus == .available

App Intents, from widgets and controls

An app intent is a Swift type that describes one thing your app can do: a title, the parameters it needs, and a perform() method that does it. You write it once, and the system can then run it for you from Shortcuts, Siri, Spotlight, the Action button, a control in Control Center, or a button in a widget or a Live Activity, without the person opening the app. The framework is App Intents, which arrived in iOS 16 and has grown in every release since.

import AppIntents

/// Saves a link. The system runs `perform()` when the button is tapped.
struct SaveLinkIntent: AppIntent {
    static let title: LocalizedStringResource = "Save Link"

    // iOS 27: run this in the app's process, never the widget's.
    static var allowedExecutionTargets: IntentExecutionTargets { .main }

    @Parameter(title: "Link")
    var url: URL

    init() {}

    init(url: URL) {
        self.url = url
    }

    func perform() async throws -> some IntentResult {
        try Mailbox(appGroup: "group.com.example.app")?
            .send(SharedLink(url: url, note: "From the widget"))
        return .result()
    }
}

A widget runs it from a button:

Button(intent: SaveLinkIntent(url: url)) {
    Label("Save", systemImage: "bookmark")
}

What matters for this article is which process runs perform(). By default, a widget's intent runs in the widget extension. WidgetKit's documentation lists the cases where it runs in the app instead: intents that conform to LiveActivityIntent, AudioPlaybackIntent, ForegroundContinuableIntent or PushToTalkTransmissionIntent, and intents that set openAppWhenRun. For a LiveActivityIntent, the system launches the app's process without bringing the app to the screen.

The last two releases changed how you say where an intent runs. iOS 26 deprecated openAppWhenRun in favour of supportedModes, which says whether an intent runs in the background, in the foreground, or starts in one and moves to the other. iOS 27 added allowedExecutionTargets, which names the processes that may run it: .main for the app, .appIntentsExtension, .widgetKitExtension, or a combination (IntentExecutionTargets). Left alone, it's any available target. The intent above says .main, so its code always runs in the app. iOS 27 also added LongRunningIntent, for work that needs more than the usual 30 seconds; Apple's App Intents updates page has the rest. Whichever you use, the intent has to be compiled into the app target for the app to run it.

This only helps where the system runs intents. A share extension, a notification service extension or a location push extension can't run an app intent to reach the app.

Opening the app

A widget or Live Activity can open the app when it's tapped, with widgetURL(_:), a Link, or a button that runs an OpenURLIntent (iOS 18), which hands one of your universal links to the app:

if let url = URL(string: "https://example.com/links") {
    Button(intent: OpenURLIntent(url)) {
        Label("Open", systemImage: "arrow.up.forward.app")
    }
}

A share or action extension has no supported way to do it. NSExtensionContext.open(_:completionHandler:) works only where the extension point allows it, which on iOS means Today and iMessage extensions, and an iMessage extension can open only its own app. When a developer asked in 2025, Apple's reply was that there's no supported way to launch an app from an app extension, Today extensions and widgets aside.

There are workarounds going around that reach UIApplication from inside an extension. They aren't part of the documented API, they can break in any release, and App Review guideline 2.5.1 asks apps to use public APIs only for their intended purpose. I won't go into them here.

A push from your own server

Any extension that can reach the network can ask your server to wake the app with a background notification. The request to APNs carries apns-push-type: background and apns-priority: 5, and the payload holds nothing but content-available:

{ "aps": { "content-available": 1 } }

The app treats it as one more reason to check the mailbox:

func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
    // The push is the wake-up; the message is in the mailbox.
    drainMailbox()
    return .newData
}

But don't count on it. Apple's documentation says the system treats these as low priority, doesn't guarantee delivery, throttles them, and suggests no more than two or three an hour. It launches the app if it has to, unless the person force-quit it, and gives it up to 10 seconds.

Network Extension and File Provider

These two extension types are the exceptions: each has a real channel to the app.

Network Extension

The containing app sends Data to its tunnel provider with sendProviderMessage(_:responseHandler:), and the provider answers in handleAppMessage(_:completionHandler:). Only the app can start an exchange. The documentation warns that the app may not be running when the provider is, since the provider can be started from Settings or by Connect On Demand, so don't use this channel to start or stop the tunnel.

When the provider has news for the app, Apple's reply suggests long polling. The app asks for the status, the provider holds on to the completion handler and calls it at the next change, and the app asks again:

// App side.
@MainActor
final class TunnelStatus {
    func watch(_ session: NETunnelProviderSession) {
        do {
            try session.sendProviderMessage(Data("status".utf8)) { reply in
                Task { @MainActor in
                    if let reply { self.show(reply) }
                    self.watch(session)   // ask again: long polling
                }
            }
        } catch {
            // The provider is not running; observe NEVPNStatusDidChange.
        }
    }

    func show(_ data: Data) {}
}

// Provider side.
final class PacketTunnelProvider: NEPacketTunnelProvider {
    private var waiting: (@Sendable (Data?) -> Void)?

    override func handleAppMessage(
        _ messageData: Data,
        completionHandler: (@Sendable (Data?) -> Void)? = nil
    ) {
        // Keep the request open; answer it at the next change.
        waiting = completionHandler
    }

    func statusChanged(to status: Data) {
        // A completion handler can be called only once.
        waiting?(status)
        waiting = nil
    }
}

If connected and disconnected are all you need, observe NEVPNStatusDidChange and skip the messaging.

File Provider

NSFileProviderService gives the app and a File Provider extension an NSXPCConnection, with an @objc protocol both sides compile. The extension offers services through NSFileProviderServiceSource, and the app asks for them by item:

@objc protocol AccountService {
    func signOut(reply: @escaping (Error?) -> Void)
}

func accountService(for item: URL) async throws -> AccountService? {
    let services = try await FileManager.default
        .fileProviderServicesForItem(at: item)
    let name = NSFileProviderServiceName("com.example.account")
    guard let service = services[name] else { return nil }
    let connection = try await service.fileProviderConnection()
    connection.remoteObjectInterface = NSXPCInterface(with: AccountService.self)
    connection.resume()
    return connection.remoteObjectProxy as? AccountService
}

The documentation says a service you get this way works only on the item at that URL. For something that isn't about one item, like signing out, one developer reports that the root container works (NSFileProviderItemIdentifier.rootContainer). Apple hasn't said whether that's intended. You should treat anything that is not documented as a bug that will eventually be fixed.

The shared container

Everything else goes through the App Group container: UserDefaults(suiteName:) for small values, files under containerURL(forSecurityApplicationGroupIdentifier:), a Core Data, SwiftData or SQLite store kept there, and a shared keychain access group for secrets.

UserDefaults(suiteName: "group.com.example.app")?
    .set(Date.now, forKey: "lastShare")

Two processes are now using the same files, and Apple's guide asks you to coordinate with Core Data, SQLite or POSIX locks. File coordination works as well, on one condition: an extension that uses NSFilePresenter has to remove its presenters when it goes into the background.

Locks are also how the shared container gets processes killed. A process suspended while holding a file lock or an SQLite database lock is terminated with the code 0xdead10cc. Apple's crash report documentation gives the fix: ask for time to finish the work and release the lock before suspension. A database open in both the app and an extension, either of which can be suspended at any moment, is where this usually comes from. Wrap each write:

// In the app.
let task = UIApplication.shared.beginBackgroundTask(withName: "Save")
save()
UIApplication.shared.endBackgroundTask(task)

// In an extension, which can't use UIApplication.
ProcessInfo.processInfo.performExpiringActivity(withReason: "Save") { expiring in
    guard !expiring else { return }
    save()
}

The mailbox above stays clear of all this. It never holds a file open, and the only process that deletes messages is the app.

Which mechanism for which job

MechanismWorks fromCarries dataApp suspendedApp not running
Mailbox in the shared containerany extensionyesread when resumedread at next launch
Darwin notificationany extensionno; a shared 64-bit value beside itheld until resumed, coalescedlost
Background URLSessionextensions that transfer filesthe transferred fileapp resumedapp launched, unless Background App Refresh is off
Background push from your serverany extension that can reach the networkthe push payloadapp resumedapp launched, unless force-quit; throttled
App intent run in the appwidgets, Live Activities, controlsthe intent's parametersrunsapp process launched
widgetURL, Link, OpenURLIntentwidgets, Live Activitiesa URLapp openedapp opened
Provider messagesNetwork ExtensionData, request and replythe app starts every exchange
File Provider serviceFile ProviderXPC callsthe app starts every connection

Sources

Apple documentation:

Apple Developer Forums:

Other: