~16 min · proof-and-fleet, devicectl, diagnostics, crash-logs, share-extension
Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Silence was the cost of a whole day, twice."
A Share That Did Nothing
The training app's share extension did nothing for a PDF shared from Mail on the owner's iPhone. The app appeared in the share sheet, the compose sheet opened, he typed a note and posted, and no item ever reached the app. The Simulator walk (a PDF from Safari into the app) had passed, and the next build fixed a real defect the Simulator had shown, which was not the phone's problem. After two builds of theory, the session stopped guessing and read the phone over the cable, with the TestFlight install untouched and no debugger attached.
Three Reads That Settled It
Crash logs copied from the device: 87 reports, none from the extension. It had not crashed.
The process list from devicectl device info processes: the extension's executable was still running an hour after the share, beside the app. An extension exits shortly after it completes or cancels its request, so one that is still alive never did either. That turned "failed" into "hung".
The source, read with that fact in hand: the extension awaited NSItemProvider.loadItem inside a checked continuation, and for Mail's PDF provider on the device that completion never came. In the Simulator, Safari's provider answered.
The fix loaded files the way file-borne providers want (loadFileRepresentation, reading the bytes inside its completion because the URL is only valid there), put a 30-second deadline on every load as a first-wins race (not a task group, which waits for every child, including the one that never returns), and turned a timeout into an alert and a cancelled request.
The Read That Lied
One read was wrong, and it is the most useful part of the story. The copy of the App Group container came back with no inbox/ folder, which read as "the extension never wrote anything". In fact devicectl device copy from silently omits entries it cannot open, and the inbox was written with complete file protection. Absence in a copy is evidence about the copy. The reliable signals were the ones that could not be filtered: a process that was still running, and a crash list with nothing in it.
Design the App to Leave a Trail
Some device reads need root, like collecting the system log from an attached device, so an agent session cannot rely on them. The durable answer is in the app. The extension now appends one line per step to a small file in the shared container: which kinds it detected, the registered type identifiers, byte counts, ids and errors, never the note or the file itself, capped so it keeps only the tail. The app's Settings shows the tail, and a cabled Mac can copy it. The next "nothing happened" names its own step.
Code
Reading a TestFlight phone over the cable, without a debugger·bash
device=Example-iPhone # a name, UDID or identifier from: xcrun devicectl list devices
out=build/device-read; mkdir -p "$out"
# Is the extension still alive long after its sheet closed? Then it never finished: a hang.
xcrun devicectl device info processes --device "$device" | grep -i spark
# Did anything crash? (--source "" is required: a subpath is refused with "File paths cannot contain '..'")
xcrun devicectl device copy from --device "$device" --domain-type systemCrashLogs \
--source "" --destination "$out/crashes"
# What did the app and its extension leave in the shared container?
xcrun devicectl device copy from --device "$device" --domain-type appGroupDataContainer \
--domain-identifier group.com.example.spark --source "" --destination "$out/group"
tail -20 "$out/group/share-trail.log"
# Caution: the copy silently OMITS files it cannot open. A file written with complete
# protection may be missing from the copy while it exists on the phone.
A step trail: facts only, capped to its tail, readable from Settings and the cable·swift
import Foundation
/// One line per step, in a file a cabled Mac and the app's own Settings can both read.
/// Facts only (kinds, type identifiers, byte counts, ids, errors), never the user's content.
struct StepTrail: Sendable {
let url: URL
var cap = 16 * 1024 // keep the tail; a trail that grows forever becomes the next problem
init(directory: URL, name: String = "share-trail.log") {
url = directory.appending(path: name)
}
func record(_ step: String, now: Date = .now) {
let line = "\(now.formatted(.iso8601)) \(step)\n"
var data = (try? Data(contentsOf: url)) ?? Data()
data.append(Data(line.utf8))
if data.count > cap {
let tail = data.suffix(cap)
// Start the kept tail at a line boundary so the first line is never half a line.
data = tail.firstIndex(of: UInt8(ascii: "\n")).map { Data(tail[tail.index(after: $0)...]) } ?? Data(tail)
}
#if os(iOS)
try? data.write(to: url, options: [.atomic, .completeFileProtection])
#else
try? data.write(to: url, options: .atomic)
#endif
}
func tail(lines: Int = 20) -> [String] {
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [] }
return Array(text.split(separator: "\n").suffix(lines).map(String.init))
}
}
Add StepTrail to SparkMobile's share extension and record a line at each step of a share: the kinds detected, each load's type identifier and byte count, the write, and the completion or cancellation. Show the last twenty lines in Settings. Then, with a device on the cable, run the three reads above and write down what each proved and what it could not prove. Finally, make the trail itself complete-protected and repeat the container copy to see whether the file appears.
Hint
Keep content out of the trail by logging counts and identifiers only. If the copy command returns nothing for the trail file while Settings shows it, you have reproduced the silent omission, not a missing write.
Progress
Progress is local-only — sign in to sync across devices.