diff --git a/Sources/ICloudCLICore/WalletAndHandoff.swift b/Sources/ICloudCLICore/WalletAndHandoff.swift index bb7f44b..842554e 100644 --- a/Sources/ICloudCLICore/WalletAndHandoff.swift +++ b/Sources/ICloudCLICore/WalletAndHandoff.swift @@ -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] } @@ -82,14 +91,26 @@ 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 } @@ -97,11 +118,15 @@ public struct HandoffActivityReader: Sendable { 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 } } @@ -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 } } diff --git a/Tests/Fixtures/HandoffEmpty/activities.json b/Tests/Fixtures/HandoffEmpty/activities.json new file mode 100644 index 0000000..74f002c --- /dev/null +++ b/Tests/Fixtures/HandoffEmpty/activities.json @@ -0,0 +1 @@ +{"activities":[]} diff --git a/Tests/Fixtures/HandoffEmptyMixed/activities.json b/Tests/Fixtures/HandoffEmptyMixed/activities.json new file mode 100644 index 0000000..74f002c --- /dev/null +++ b/Tests/Fixtures/HandoffEmptyMixed/activities.json @@ -0,0 +1 @@ +{"activities":[]} diff --git a/Tests/Fixtures/HandoffEmptyMixed/malformed.plist b/Tests/Fixtures/HandoffEmptyMixed/malformed.plist new file mode 100644 index 0000000..0876d31 --- /dev/null +++ b/Tests/Fixtures/HandoffEmptyMixed/malformed.plist @@ -0,0 +1 @@ +not a plist diff --git a/Tests/Fixtures/HandoffMalformedJSON/malformed.json b/Tests/Fixtures/HandoffMalformedJSON/malformed.json new file mode 100644 index 0000000..791bb4e --- /dev/null +++ b/Tests/Fixtures/HandoffMalformedJSON/malformed.json @@ -0,0 +1 @@ +{"activities":[ diff --git a/Tests/Fixtures/HandoffMalformedPlist/malformed.plist b/Tests/Fixtures/HandoffMalformedPlist/malformed.plist new file mode 100644 index 0000000..a5aadd9 --- /dev/null +++ b/Tests/Fixtures/HandoffMalformedPlist/malformed.plist @@ -0,0 +1,2 @@ + + diff --git a/Tests/Fixtures/HandoffMixed/activities.json b/Tests/Fixtures/HandoffMixed/activities.json new file mode 100644 index 0000000..2ff544d --- /dev/null +++ b/Tests/Fixtures/HandoffMixed/activities.json @@ -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" + } + ] +} diff --git a/Tests/Fixtures/HandoffMixed/malformed.plist b/Tests/Fixtures/HandoffMixed/malformed.plist new file mode 100644 index 0000000..0876d31 --- /dev/null +++ b/Tests/Fixtures/HandoffMixed/malformed.plist @@ -0,0 +1 @@ +not a plist diff --git a/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift b/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift index 9c4c31b..413d2a7 100644 --- a/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift +++ b/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift @@ -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")