Eight parts of this series have asserted things about Handoff: where an activity arrives, what fits in it, which window it opens. This part is about how you would know any of it is true in your own app.
There is no automated path, and that is the finding
We went looking for a way to test a device-to-device continuation automatically: a CI recipe, an Apple sample project, a WWDC session, a community harness, anything. We found none. Not none that are convenient. Not none that are fast. None at all.
One lead surfaced: an xcrun devicectl invocation with a --payload-url argument, offered as a way to push an activity at a connected device. We did not run it, we found no Apple page documenting it for this purpose, and so it stays in this article as exactly what it is: an unverified lead. Presenting it as a technique would be the same mistake this series has spent eight parts refusing: promoting something plausible into something established.
The practitioner consensus is visible only by absence. Nobody publishes a Handoff CI job, because nobody appears to have one. What teams do instead is two physical devices and a human.
The absence is structural. Apple Platform Security (current edition) describes Handoff as a Bluetooth Low Energy 4.2 pairing made out of band through APNs, with advertisements encrypted and authenticated using AES-256 in GCM mode and replay protection, and the same Apple Account required throughout. Nothing in that description has a documented test double.
The consequence is a split, not despair: separate the feature into the part that needs no transport, the part that needs the app but not a second device, and the part that stays manual. Then make the manual part small and written down.
Test the parts that are pure
A continuation asks your app to do four things: recognise the activity type, validate the payload, decide whether to accept it, and route the accepted one to a screen. Only the last needs a user interface; the first three are pure functions over a type string and a dictionary, and that is where the defects live.
Return a verdict, do not throw one
The design move that makes them testable is small: have the parse return a typed rejection rather than throw. An activity from a device you do not control is not an exceptional condition. A foreign type, a missing key and a payload from an older build are all expected inputs. As values, every verdict becomes something a test can assert on and a log line can record without printing a payload value, which part 6 asked you never to do.
import Foundation
enum ActivityType {
static let viewItem = "com.example.app.view-item"
}
struct ItemContinuation: Equatable {
let id: String
}
// Error conformance only so it can sit in a
// Result. Nothing ever throws one of these.
enum HandoffRejection: Error, Equatable {
case wrongType(String)
case noPayload
case missingKey(String)
case wrongValueType(key: String)
case unsupportedValue(key: String)
case tooLarge(bytes: Int)
case unmeasurable
}The parse takes what a continuation carries and nothing else: no activity object, no app state, no clock. The size cap is a parameter with a default rather than a constant compared inline, and that choice is what makes the boundary pair possible.
enum ItemHandoff {
// Apple's published figure. Archived page,
// revised 2016-04-01. See part 4.
static let payloadCap = 3 * 1024
static func parse(
activityType: String,
userInfo: [AnyHashable: Any]?,
cap: Int = payloadCap
) -> Result<
ItemContinuation, HandoffRejection
> {
guard activityType
== ActivityType.viewItem
else {
return .failure(
.wrongType(activityType)
)
}
guard let userInfo,
!userInfo.isEmpty else {
return .failure(.noPayload)
}
for (key, value) in userInfo
where !isPermitted(value) {
return .failure(.unsupportedValue(
key: String(describing: key)
))
}
guard let bytes =
payloadBytes(userInfo) else {
return .failure(.unmeasurable)
}
guard bytes <= cap else {
return .failure(
.tooLarge(bytes: bytes)
)
}
guard let raw = userInfo["id"] else {
return .failure(.missingKey("id"))
}
guard let id = raw as? String,
!id.isEmpty else {
return .failure(
.wrongValueType(key: "id")
)
}
return .success(
ItemContinuation(id: id)
)
}
static func payloadBytes(
_ userInfo: [AnyHashable: Any]
) -> Int? {
try? NSKeyedArchiver.archivedData(
withRootObject: userInfo,
requiringSecureCoding: true
).count
}
// Apple's permitted value types, from the
// archived Adopting Handoff page. The
// current reference does not restate them.
static func isPermitted(
_ value: Any
) -> Bool {
switch value {
case is String, is Data, is Date,
is URL, is NSNumber, is NSNull:
return true
case let array as [Any]:
return array.allSatisfy(isPermitted)
case let dict as [AnyHashable: Any]:
return dict.values
.allSatisfy(isPermitted)
case let set as Set<AnyHashable>:
return set.allSatisfy(isPermitted)
default:
return false
}
}
}Two details are easy to get wrong. The permitted-type check encodes Apple's list of what a userInfo dictionary may contain (NSArray, NSData, NSDate, NSDictionary, NSNull, NSNumber, NSSet, NSString and NSURL). The current userInfo page states that list, fetched on 11 October 2026. The Swift type is still [AnyHashable: Any]?, so the compiler will accept a value the transport will not carry. The byte count is a measurement you define, not one the system hands you.
The boundary pair
With the parse pure, the tests are ordinary Swift Testing cases in a package test target: no device, no simulator, no launch. The first two below pin the verdicts you hit daily, and swapping the value under test covers the rest of the enum. The pair after them is what a cap-as-constant design cannot express: measure the payload, then run the parse with the cap at exactly that measurement and again one byte lower, pinning the comparison as less-than-or-equal without fighting the encoder to land on a round number.
import Foundation
import Testing
@Test func acceptsAWellFormedActivity() {
let result = ItemHandoff.parse(
activityType: ActivityType.viewItem,
userInfo: ["id": "item-42"]
)
#expect(result == .success(
ItemContinuation(id: "item-42")
))
}
@Test func rejectsAValueHandoffCannotCarry() {
let result = ItemHandoff.parse(
activityType: ActivityType.viewItem,
userInfo: ["id": "a",
"view": NSObject()]
)
#expect(result == .failure(
.unsupportedValue(key: "view")
))
}
@Test func acceptsAPayloadExactlyAtTheCap()
throws {
let info: [AnyHashable: Any] = [
"id": "item-42"
]
let bytes = try #require(
ItemHandoff.payloadBytes(info)
)
let result = ItemHandoff.parse(
activityType: ActivityType.viewItem,
userInfo: info,
cap: bytes
)
#expect(result == .success(
ItemContinuation(id: "item-42")
))
}
@Test func rejectsAPayloadOneByteOverTheCap()
throws {
let info: [AnyHashable: Any] = [
"id": "item-42"
]
let bytes = try #require(
ItemHandoff.payloadBytes(info)
)
let result = ItemHandoff.parse(
activityType: ActivityType.viewItem,
userInfo: info,
cap: bytes - 1
)
#expect(result == .failure(
.tooLarge(bytes: bytes)
))
}An off-by-one at a size cap survives every other kind of testing here, because the payload that triggers it is one byte from the payload that works. Two cases and a parameter make it impossible.
Synthesise the arrival for a UI test
What is left is routing: putting the reader on the right screen, in the right state. That needs the app running. It does not need a second device, provided you build the NSUserActivity in process and hand it to the same seam the real callback uses. Three conditions keep that honest. There is exactly one receive seam, so the synthetic and real paths converge immediately. The synthetic entry point sits behind #if DEBUG and a launch argument, so it cannot exist in a shipping build. And it builds a real NSUserActivity, so the parse it exercises is the parse that ships.
@MainActor
final class ItemRouter: ObservableObject {
@Published var openItemID: String?
// The one receive seam. Real deliveries
// and the synthetic one both land here.
func receive(_ activity: NSUserActivity) {
switch ItemHandoff.parse(
activityType: activity.activityType,
userInfo: activity.userInfo
) {
case let .success(continuation):
openItemID = continuation.id
case let .failure(rejection):
// The verdict, never a payload
// value. See part 6.
logVerdict(rejection)
}
}
}
#if DEBUG
enum SyntheticHandoff {
static let flag = "-synthetic-handoff-id"
static func activity(
in arguments: [String]
) -> NSUserActivity? {
guard
let i = arguments
.firstIndex(of: flag),
arguments.indices.contains(i + 1)
else { return nil }
let activity = NSUserActivity(
activityType: ActivityType.viewItem
)
activity.userInfo = [
"id": arguments[i + 1]
]
return activity
}
}
#endif
@main
struct ExampleApp: App {
@StateObject private var router
= ItemRouter()
var body: some Scene {
WindowGroup {
RootView(router: router)
.onContinueUserActivity(
ActivityType.viewItem
) { router.receive($0) }
.task {
#if DEBUG
if let activity =
SyntheticHandoff.activity(
in: CommandLine
.arguments
) {
router.receive(activity)
}
#endif
}
}
}
}Say in the suite what the green does not mean
The test that launches with that flag proves routing. It does not prove transport. It does not exercise the advertisement, the pairing, the same-Apple-Account requirement, the Team ID gate, or the NSUserActivityTypes declaration; the synthetic path never reads the Info.plist. Nor can it say which scene the system would have chosen on a multi-window Mac: part 5's matching algorithm belongs to the system, and this path steps around it.
So write that down where the test lives: a doc comment on the test class, where the next person to read a green sees it. The failure it prevents is concrete: someone drops the manual checklist because the suite already has Handoff tests.
/// Synthetic Handoff arrival.
///
/// Proves ROUTING ONLY: that an activity of
/// this type, once delivered, reaches the
/// right screen with the right state.
///
/// It does NOT exercise Handoff transport,
/// device pairing, the Apple Account or Team
/// ID gate, the NSUserActivityTypes
/// declaration, or scene selection on a
/// second device.
///
/// A green here is not evidence that Handoff
/// works. Transport is covered only by the
/// manual smoke checklist in QA/handoff.md.
final class SyntheticArrivalTests: XCTestCase {
func testSyntheticArrivalOpensTheItem() {
let app = XCUIApplication()
app.launchArguments += [
"-synthetic-handoff-id", "item-42"
]
app.launch()
XCTAssertTrue(
app.staticTexts["item.title"]
.waitForExistence(timeout: 5)
)
}
}The manual smoke test is a deliverable
Because the manual test is the only thing that exercises the transport, it deserves to be an artefact rather than a habit: a file in the repository, run as part of the definition of done, recording the date, the device models and the OS versions it last ran against. The environment is the thing under test, and Apple changes it.
Before any of it, check the two contractual conditions from part 2, because no amount of retrying fixes them: both apps signed by the same developer Team ID, and the receiving target declaring the activity type in NSUserActivityTypes. Then the six environmental conditions:
- Two physical devices. Simulator behaviour is not something we can cite either way, so treat the simulator as unsupported here until you have proven otherwise on your own hardware.
- Both signed into the same Apple Account. Apple Platform Security requires it throughout the transport, so a second Apple Account on the test iPad is a complete explanation for silence.
- Handoff switched on in settings on both devices.
- Bluetooth on, on both devices.
- Wi-Fi on, on both devices.
- Both builds installed from the same signing team. In practice that means checking that the Mac build on the tester's machine is not last month's build from a different team.
Most reports that Handoff is broken resolve to one of those six. Writing them down turns a vague afternoon into a checklist any tester can run, instead of a list each engineer re-derives from the same forum threads.
The run itself is short. Put device A into the state you advertise, confirm the activity is current, find the affordance on device B and continue it. Then assert that the destination matches where device A actually was, not merely that the right screen opened. Vary one thing at a time after that: the reverse direction, a cold receiving app, a payload close to your cap.
What nine parts could not settle
A series that spends eight parts asserting things owes its readers the questions it could not answer. Every item below was searched for in Apple's documentation, its archive, its forums and the wider community, and not found. Each is paired with what would settle it.
- The true empirical userInfo ceiling. The current reference publishes no size. Part 4 keeps the 2016 recommendation of 3 KB, from archived Adopting Handoff revised 2016-04-01, as a historical footnote, and Apple ships a dedicated too-large error code with no number attached. We found nobody reporting a measured breaking point. Settled by: a published bisection across OS versions and device pairs that states its measurement convention.
- Any Apple statement of app-delegate versus scene-delegate precedence. Across every delivery-path page we fetched, Apple documents no rule for what happens when both are implemented. That scenes win is long-standing practice and community reporting. Settled by: one Apple sentence, or a published DTS answer.
- Whether SwiftUI-lifecycle apps are formally exempt from TN3187. The inference is reasonable and Apple has never stated it. Settled by: an updated technote or a documented exemption.
- Whether enforcement of the scene mandate keys off the build SDK or the runtime OS. Third-party blogs say build SDK; a direct question to Apple DTS came back without answering it. Settled by: an Apple sentence, or a published matrix of build SDK against runtime OS.
- What happens when NSUserActivityTypes is omitted. No documented error, no documented log line, no documented fallback; the consequence is only derivable from the Team ID rule. Settled by: an Apple sentence naming the behaviour.
- The effect of requiredUserInfoKeys on ordinary Handoff delivery. Apple ties the property to public indexing only. The widespread belief that the system withholds an activity missing a required key is not sourced to any Apple page we found, and this series does not repeat it. Settled by: an Apple sentence, or a controlled two-device experiment someone publishes.
- Simulator versus device behaviour for Handoff. Widely believed to require real hardware; we found nothing citable in either direction. Settled by: an Apple statement, or a documented test on a current OS pair.
- A current Human Interface Guidelines page for Handoff. Not found. Whether it was retired or folded into another page is itself unknown, which is why this series describes no Handoff affordance's position on screen. Settled by: an Apple URL.
- Apple guidance on pasteboard content sensitivity for third-party apps. Part 7 established that Universal Clipboard is on by default and that macOS has no opt-out; what a developer should do about customer data on the general pasteboard has no Apple answer we could find. Settled by: an Apple security or HIG page addressing third-party apps.
- The full Restrictions key table, verbatim from Apple. Only allowActivityContinuation is confirmed verbatim from Apple's example Restrictions payload; every other key in that family reached us through MDM-vendor documentation, and this series never promotes one of those to Apple-documented. Settled by: Apple's own complete key table.
- How multiple windows on macOS receive a continuation beyond the shared scene-matching algorithm, and whether openWindow can consume an NSUserActivity. No page documents either. Settled by: SwiftUI documentation that addresses the macOS case directly.
- A repeatable automated Handoff test. One unverified xcrun devicectl --payload-url lead, and nothing else. Settled by: anyone publishing a harness that actually runs, with the verification alongside it.
There is a pattern worth taking to the next Apple framework you pick up. Where Apple documents a contract (the Team ID plus declared type gate, the scene-matching algorithm), the documentation is precise and quotable. Where the behaviour is emergent, a threshold or a precedence or an enforcement trigger, it is often not documented at all.
The method, and why you can repeat it
Two parts of this series stopped being opinion because of a technique that takes seconds to run. Apple's documentation pages are available as markdown by appending .md to the URL, and that markdown carries a header with machine-readable availability.
base=https://developer.apple.com/documentation
# The header carries availability per symbol.
curl -s "$base/foundation/nsuseractivity.md" \
| head -40That is how part 3's finding was measured rather than argued. The three UIApplicationDelegate Handoff methods carry an availability upper bound of 26.0 in their headers, while the UISceneDelegate and NSApplicationDelegate equivalents carry none. That fact is invisible in the HTML page, and two independent research passes measured it identically. Every availability figure in these nine parts came from those headers, fetched on 2026-09-23.
The second technique is duller, and it is how part 4 found a figure the current reference does not print. The 3 KB guidance appears twice on Adopting Handoff, revised 2016-04-01, and not at all on About Handoff. That title is close enough that one of our own passes fetched the wrong page, came back empty and marked the figure unresolved. Part 4 keeps the figure as a labelled historical footnote.
The rules the series ran on are portable to any framework you write about: availability from machine-readable headers, never from prose; archived pages quoted with Apple's revision date attached; community reports labelled as evidence about experience, never promoted to API facts; and a question asked and not answered written down as a finding.
Which brings the series back to where part 1 started it. Continuity is three contracts wearing one brand name, and Handoff is the only one with an API to get right. It repays a modest amount of rigour: a parse that returns verdicts, a routing test that admits what it does not prove, and a six-condition checklist a tester can run. What we could not settle is in the list above, with what would settle it.
Previously (Part 8): Continuity Camera and the Features With No API
Start of the series (Part 1): Apple Continuity Is Three Different Contracts

Comments
Loading comments…