Skip to main content
All articles

What Must Never Cross a Handoff

Ben Van AkenCo-Founder & CTO11 min read
What Must Never Cross a Handoff

Part 5 was about sending a continuation to the right window. This part is about the activities you should never have put on the air at all. An NSUserActivity is not a private message to your other device. It is a publishing decision, and three of its four eligibility flags publish somewhere you did not intend.

Write all four flags by hand

An activity carries four eligibility flags, and Apple's one-line definitions on the class reference say exactly who each one is for. isEligibleForHandoff: "whether the activity can continue on another device using Handoff." isEligibleForSearch: "whether to add the activity to the on-device index." isEligibleForPublicIndexing: "whether the activity is publicly accessible by all iOS users." isEligibleForPrediction: "whether Siri can suggest the activity as a shortcut." Four audiences, in order: the user's own devices, this device, everybody, and the system's suggestion machinery.

The class page states no default value for any of the four, so there is nothing to look up and lean on. Write all four, including the ones you want off, at the single place you build the activity. A default you did not type is a decision you did not make, and it is invisible in a diff and invisible in review.

Swift
enum ActivityKind {
    static let openRecord =
        "com.example.app.open-record"
}

func recordActivity(
    id: String,
    accountID: String,
    title: String
) -> NSUserActivity {
    let activity = NSUserActivity(
        activityType: ActivityKind.openRecord
    )

    // All four. Every time. One place.
    activity.isEligibleForHandoff = true
    activity.isEligibleForSearch = false
    activity.isEligibleForPublicIndexing = false
    activity.isEligibleForPrediction = false

    activity.targetContentIdentifier = id
    activity.userInfo = [
        "recordID": id,
        "accountID": accountID,
    ]
    activity.webpageURL = nil
    return activity
}

One caveat about that block. Per-property availability is not in the class header, and only isEligibleForPublicIndexing's own page was fetched for this series, so no availability numbers for the other three appear here. Check them against your deployment targets before sharing one builder across platforms.

isEligibleForPublicIndexing is the dangerous one

Here is Apple's fuller description of the property, quoted in full from the current reference page:

Set the value of this property to true to add the activity object to the global Spotlight search indexes… If you set this property to true, the system indexes the values in the webpageURL and requiredUserInfoKeys properties… When someone chooses one of your app's public activities from search results, it tells Apple that your website's public information is popular, which can increase the ranking of that content in future searches. Important: Your app must maintain a strong reference to any activity objects you make eligible for search.

Apple, NSUserActivity.isEligibleForPublicIndexing

Read the middle sentence again, because it is the tell. The feedback loop is a ranking signal about your website's public information: the property is built for content that already exists in public, on a URL, for anyone, which is what the shorter gloss says outright. If your activity stands for something behind a login, there is no public page for that signal to be about. What you published is the existence of the record, plus whatever sits in webpageURL and requiredUserInfoKeys, in an index you do not own and cannot inspect.

Availability for this one flag, from the machine-readable header rather than the prose: iOS 9.0 and later, macOS 10.11 and later, watchOS 3.0 and later. It has been there long enough that plenty of sample code sets it without comment. Sample code is usually about a recipe app.

The strong reference Apple flags as Important

The last sentence of that quote is an ownership requirement, and the easiest thing here to get wrong by accident. Build the activity in a function, call becomeCurrent(), return, and ARC is entitled to release it the moment the local goes out of scope.

Swift
// Wrong: the only strong reference is a
// local that dies at the closing brace.
func advertise(_ record: Record) {
    let activity = recordActivity(
        id: record.id,
        accountID: record.accountID,
        title: record.displayTitle
    )
    activity.becomeCurrent()
}
Swift
// Right: the advertiser owns the activity
// for exactly as long as it is current.
@MainActor
final class RecordAdvertiser {
    private var current: NSUserActivity?

    func advertise(_ record: Record) {
        let activity = recordActivity(
            id: record.id,
            accountID: record.accountID,
            title: record.displayTitle
        )
        current = activity
        activity.becomeCurrent()
    }

    func stop() {
        current?.invalidate()
        current = nil
    }
}

webpageURL is the no-app fallback

It is tempting to treat webpageURL as metadata. It is not. It is the instruction a receiving device follows when it does not have your app installed: open this in a browser. On an activity representing private content, a non-nil value is a request to hand that content to a browser on a device you have never seen.

Set it only when the app owns a public URL for that content and the URL renders something a stranger may see. Everywhere else, nil. A second reason to check it: SwiftUI's scene matching falls back to webpageURL rendered as an absoluteString when targetContentIdentifier is nil, so a URL added as decoration also steers window selection.

One production app drove the audit behind this series, a SwiftUI and AppKit workspace app we deliberately do not name. Its associated-domains entitlement carries webcredentials: only, no applinks: at all. That app has no universal link to put in webpageURL. Neither does most business software. Setting one anyway does not expose a page; it invents one, and points other people's devices at whatever your web server does with an unclaimed path.

Identifiers, never rendered content

The rule that survives every refactor: a payload carries identifiers, and the receiving app fetches. A record id and an account id are meaningless without an authenticated session against your backend. A rendered invoice, a customer name or a field value is somebody's data, and once it is in userInfo it is on the air, in an indexable object, one flipped flag away from a search index.

Swift
// Identifiers. Useless without a session.
activity.userInfo = [
    "recordID": record.id,
    "accountID": session.accountID,
    "v": 1,
]

// Not this. Somebody's data, in an object
// whose flags you may not control forever.
activity.userInfo = [
    "customerName": record.customerName,
    "amountDue": record.formattedAmount,
    "summary": renderedSummary,
]

There is one real case where field values must cross: a half-typed form the user is moving from their phone to their Mac. Nothing else can reconstruct an unsaved draft, because the server has never seen it. That is not an exception to the rule. It is precisely why the size cap and the policy gate from part 4 exist. The draft crosses because a gate said this type may carry values, it fits the budget, and the user is signed in. Not because someone put a dictionary literal in a view.

Never log a payload value

Handoff is hard to observe, so the first instinct when it misbehaves is to print the activity. That puts customer data into a log file, a crash report attachment and, if the log ships anywhere, a third-party system whose retention policy is not yours.

Swift
// Yes: one line per decision.
logger.info("handoff advertise kind=record")
logger.debug("handoff payload keys=3 bytes=142")
logger.info("handoff blocked reason=policy")
logger.info("handoff invalidate reason=signout")

// No: every one of these is somebody's data.
logger.debug("handoff userInfo=\(userInfo)")
logger.info("handoff title=\(activity.title)")
logger.debug("handoff url=\(webpageURL)")

Ids get redacted or hashed if they appear at all; field values and titles never appear. One line per decision at info, one at debug for shape, because key and byte counts are diagnostic and carry nothing. Resist "just this once" while chasing a bug: that is how every leaked log line got written.

title is a user-facing string, so localize it

The title is not a debug label. It is what the OS renders to the user on the receiving side, in the App Switcher and on the Dock. That makes it a rendered string like any other, and it belongs in a String Catalog under a symbolic key, not as an English literal three layers deep in a view model.

Swift
activity.title = String(
    localized: "handoff.record.title",
    defaultValue: "Record \(record.shortCode)",
    comment: "Handoff label in App Switcher"
)

It also makes the title the one part of the payload that is deliberately visible. It renders on a screen you are not looking at, possibly in a room you are not in. A record's short code tells the right person which record it is; a customer's name tells everyone in that room who your customer is.

Invalidate aggressively

The app's current activity is what the other device advertises. That sentence is the whole section. An activity you forgot to invalidate keeps offering a record the user has left, after the state that made it legitimate is gone.

Invalidate on every one of these:

  • The view or scene advertising it disappears.
  • Sign-out. The identity that authorised the payload is gone.
  • User switch: a different person on the same device.
  • Account or tenant switch, which changes who the record belongs to even though the person did not.
  • The feature being turned off, by the user's preference or by an organisation-level setting.
  • Policy turning negative. A payload permitted at 09:00 under one setting is not permitted at 09:05 under another.
Swift
enum StopReason: String {
    case disappeared, signedOut
    case userSwitched, accountSwitched
    case featureDisabled, policyDenied
}

func stop(_ reason: StopReason) {
    guard current != nil else { return }
    logger.info(
        "handoff invalidate \(reason.rawValue)"
    )
    current?.invalidate()
    current = nil
}

invalidate() is not the whole job

invalidate() retires the activity that is current. It does nothing about the activities the system has saved on your behalf. Those have their own API: deleteSavedUserActivities(withPersistentIdentifiers:completionHandler:) and deleteAllSavedUserActivities(completionHandler:), the second documented as "Deletes all user activities created by your app." Both need something to delete by: persistentIdentifier, "a unique and persistent value you use to identify the activity."

Swift
// Assign one when you advertise.
let key = "record.\(record.id)"
activity.persistentIdentifier = key

// Delete the ones for a record that is gone.
NSUserActivity.deleteSavedUserActivities(
    withPersistentIdentifiers: [key]
) { /* nothing to resume */ }

// Clear the lot on sign-out or an account
// switch: none of them are yours any more.
NSUserActivity.deleteAllSavedUserActivities {
    // continue teardown here
}

Sign-out and account switch are the moments for the second one. The person who leaves is not the person who arrives, and an activity saved under the previous session is a record the new session has no business being offered.

There is a declarative complement, too: expirationDate, "the date after which the activity is no longer eligible for Handoff or indexing". Invalidation covers the events you can see. An expiry covers the ones you cannot (the app killed in the background, the device left in a drawer) by bounding how long anything you published stays offerable.

requiredUserInfoKeys, honestly

Apple defines the property as "A set of keys that represent the minimal information about the activity that should be stored for later restoration", and ties it explicitly to public indexing: it is one of the two properties named in the quote above as being indexed when you publish an activity.

What it does for ordinary Handoff delivery is a different question, and the honest answer is that the research behind this series found no Apple page documenting any effect at all. The widespread belief that the system withholds an activity whose userInfo is missing a declared required key is not sourced to any Apple sentence we could find. It may be true. It is not documented, and repeating it as fact is how it gets quoted back as one.

So do not use it as a validation mechanism. If a restore needs certain keys, check for them on arrival in your own code, where a failure is a typed rejection you can test without a device. Do use it when you are deliberately publishing a public activity. There it has a documented meaning, and that meaning is "index these values".

The one honest off switch

Continuity has one enterprise lever worth knowing precisely. allowActivityContinuation governs Handoff, and is the only Continuity restriction this research pass could confirm verbatim from Apple's own example Restrictions payload in the Device Management documentation. It restricts Handoff wholesale: not your activity type, not your app, all of it. It is often said to inherit to Universal Clipboard, which does ride on Handoff by Apple's security documentation. That inheritance is an inference from the architecture, not a sentence on that page.

The neighbouring keys in MDM vendor documentation (allowUniversalControl, allowiPhoneMirroring, forceAirDropUnmanaged) are corroborated by those vendors only, never against Apple's own key table, which did not render in either fetch of this research pass. Real levers an administrator can pull; not things to present to a customer as Apple-documented.

None of it reaches into your app: the restriction is enforced by the system below you, and you should not assume you can read it. What you own are two switches, and a feature carrying other people's data wants both: a user-level switch, because it is the user's data moving between the user's devices, and an organisation-level floor, because the user is not always the person accountable for it. They compose by AND; neither substitutes for the other.

Swift
// Both switches, plus the session that
// makes the payload meaningful at all.
var mayAdvertise: Bool {
    preferences.handoffEnabled
        && policy.handoffAllowed
        && session.isSignedIn
}

Because policy is a moving value, a false reading is not only a reason not to start but a reason to stop: wire it to the same stop(.policyDenied) path as sign-out, or you have built a switch that turns the feature off for the next activity and leaves the current one on the air.

What this part does not claim

  • We have not measured what happens to an already-published activity after the public-indexing flag is turned off. Nothing here tells you how to un-publish; treat it as one-way until you have verified otherwise.
  • Per-flag availability is not in the class header, and only isEligibleForPublicIndexing's own page was fetched, so no availability numbers are stated here for the other three.
  • requiredUserInfoKeys' effect on ordinary Handoff delivery is unresolved, not merely unknown to us. It was looked for and not found, which is a finding in its own right.

Part 7 moves from a feature you opt into with an API to one that has been running in your app since the day you shipped it, whether you designed for it or not.

Previously (Part 5): Sending Handoff to the Right Window

Next up (Part 7): Universal Clipboard: The Continuity Feature You Already Shippedlands in 3 days

Comments

Loading comments…

Shipping Handoff for data that is not yours?

We build native iOS and macOS apps for teams whose data belongs to their customers. Book a call and let's look at what your app is advertising.