Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions Sources/ICloudCLICore/WalletAndHandoff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,16 @@ public struct HandoffActivity: Codable, Equatable, Sendable {

public enum HandoffError: Error, LocalizedError, Equatable {
case unreadable(String)
public var errorDescription: String? { if case .unreadable(let path) = self { return "Handoff cache directory is unreadable: \(path)" }; return nil }
case unparseable(String)

public var errorDescription: String? {
switch self {
case .unreadable(let path):
return "Handoff cache directory is unreadable: \(path)"
case .unparseable(let path):
return "Handoff cache contains JSON or plist files, but none could be parsed: \(path)"
}
}
}

private struct HandoffActivitiesEnvelope: Codable { let activities: [HandoffActivity] }
Expand All @@ -82,26 +91,42 @@ public struct HandoffActivityReader: Sendable {
public func listActivities(limit: Int = 10) throws -> [HandoffActivity] {
guard let enumerator = FileManager.default.enumerator(at: handoffDirectory, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else { throw HandoffError.unreadable(handoffDirectory.path) }
var activities: [HandoffActivity] = []
var candidateFileCount = 0
var recoveredActivityCount = 0
var malformedCandidateCount = 0
for case let url as URL in enumerator where ["json", "plist"].contains(url.pathExtension) {
activities.append(contentsOf: readActivities(from: url))
candidateFileCount += 1
if let parsedActivities = readActivities(from: url) {
activities.append(contentsOf: parsedActivities)
recoveredActivityCount += parsedActivities.count
} else {
malformedCandidateCount += 1
}
}
if candidateFileCount > 0, recoveredActivityCount == 0, malformedCandidateCount > 0 {
throw HandoffError.unparseable(handoffDirectory.path)
}
return Array(activities.sorted { $0.updatedAt > $1.updatedAt }.prefix(max(0, limit)))
}

private func readActivities(from url: URL) -> [HandoffActivity] {
guard let data = try? Data(contentsOf: url) else { return [] }
private func readActivities(from url: URL) -> [HandoffActivity]? {
guard let data = try? Data(contentsOf: url) else { return nil }
if url.pathExtension == "json" {
let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
if let wrapped = try? decoder.decode(HandoffActivitiesEnvelope.self, from: data) { return wrapped.activities }
if let array = try? decoder.decode([HandoffActivity].self, from: data) { return array }
if let object = try? JSONSerialization.jsonObject(with: data) { return parseActivities(object) }
}
if let object = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) { return parseActivities(object) }
return []
return nil
}

private func parseActivities(_ value: Any) -> [HandoffActivity] {
if let array = value as? [Any] { return array.flatMap(parseActivities) }
private func parseActivities(_ value: Any) -> [HandoffActivity]? {
if let array = value as? [Any] {
if array.isEmpty { return [] }
let parsed = array.compactMap(parseActivities).flatMap { $0 }
return parsed.isEmpty ? nil : parsed
}
if let dict = value as? NSDictionary {
var swiftDict: [String: Any] = [:]
for (key, child) in dict { if let key = key as? String { swiftDict[key] = child } }
Expand All @@ -113,10 +138,10 @@ public struct HandoffActivityReader: Sendable {
let bundle = stringValue(dict["appBundleId"] ?? dict["bundleIdentifier"] ?? dict["bundleId"]),
let app = stringValue(dict["appName"] ?? dict["localizedAppName"]),
let type = stringValue(dict["activityType"] ?? dict["type"]),
let updated = dateValue(dict["updatedAt"] ?? dict["lastUpdated"] ?? dict["timestamp"]) else { return [] }
let updated = dateValue(dict["updatedAt"] ?? dict["lastUpdated"] ?? dict["timestamp"]) else { return nil }
return [HandoffActivity(deviceName: device, appBundleId: bundle, appName: app, activityType: type, title: stringValue(dict["title"]), url: stringValue(dict["url"]), updatedAt: updated)]
}
return []
return nil
}
}

Expand Down
1 change: 1 addition & 0 deletions Tests/Fixtures/HandoffEmpty/activities.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"activities":[]}
1 change: 1 addition & 0 deletions Tests/Fixtures/HandoffEmptyMixed/activities.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"activities":[]}
1 change: 1 addition & 0 deletions Tests/Fixtures/HandoffEmptyMixed/malformed.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
not a plist
1 change: 1 addition & 0 deletions Tests/Fixtures/HandoffMalformedJSON/malformed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"activities":[
2 changes: 2 additions & 0 deletions Tests/Fixtures/HandoffMalformedPlist/malformed.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><array></dict></plist>
12 changes: 12 additions & 0 deletions Tests/Fixtures/HandoffMixed/activities.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"activities": [
{
"deviceName": "Example Mac",
"appBundleId": "com.apple.Safari",
"appName": "Safari",
"activityType": "NSUserActivityTypeBrowsingWeb",
"title": "Synthetic Activity",
"updatedAt": "2026-07-11T10:00:00Z"
}
]
}
1 change: 1 addition & 0 deletions Tests/Fixtures/HandoffMixed/malformed.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
not a plist
33 changes: 33 additions & 0 deletions Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,39 @@ private let fixtures = URL(fileURLWithPath: #filePath)
#expect(activities[0].appBundleId == "com.apple.mobilesafari")
}

@Test func reportsMalformedHandoffJSON() {
let directory = fixtures.appendingPathComponent("HandoffMalformedJSON")
#expect(throws: HandoffError.unparseable(directory.path)) {
try HandoffActivityReader(handoffDirectory: directory).listActivities()
}
}

@Test func reportsMalformedHandoffPlist() {
let directory = fixtures.appendingPathComponent("HandoffMalformedPlist")
#expect(throws: HandoffError.unparseable(directory.path)) {
try HandoffActivityReader(handoffDirectory: directory).listActivities()
}
}

@Test func validEmptyHandoffCacheRemainsEmpty() throws {
let directory = fixtures.appendingPathComponent("HandoffEmpty")
#expect(try HandoffActivityReader(handoffDirectory: directory).listActivities().isEmpty)
}

@Test func emptyHandoffCacheDoesNotHideMalformedNeighbor() {
let directory = fixtures.appendingPathComponent("HandoffEmptyMixed")
#expect(throws: HandoffError.unparseable(directory.path)) {
try HandoffActivityReader(handoffDirectory: directory).listActivities()
}
}

@Test func validHandoffActivitySurvivesMalformedNeighbor() throws {
let directory = fixtures.appendingPathComponent("HandoffMixed")
let activities = try HandoffActivityReader(handoffDirectory: directory).listActivities()
#expect(activities.count == 1)
#expect(activities[0].deviceName == "Example Mac")
}

@Test func rendersWalletAndHandoffText() throws {
let runner = CommandRunner()
let pass = WalletPass(passType: .eventTicket, description: "Synthetic Event", organizationName: "Example Venue", relevantDate: nil, expirationDate: nil, serialNumber: "SERIAL")
Expand Down
Loading