diff --git a/Sources/ICloudCLICore/CommandLine.swift b/Sources/ICloudCLICore/CommandLine.swift index 6d9a8cc..825e75f 100644 --- a/Sources/ICloudCLICore/CommandLine.swift +++ b/Sources/ICloudCLICore/CommandLine.swift @@ -107,6 +107,32 @@ public struct DevicesListOptions: Equatable, Sendable { } } +public struct WalletPassesOptions: Equatable, Sendable { + public var format: OutputFormat + public var passesDirectory: URL + public var type: WalletPassType? + public var activeOnly: Bool + + public init(format: OutputFormat = .json, passesDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Passes"), type: WalletPassType? = nil, activeOnly: Bool = false) { + self.format = format + self.passesDirectory = passesDirectory + self.type = type + self.activeOnly = activeOnly + } +} + +public struct HandoffListOptions: Equatable, Sendable { + public var format: OutputFormat + public var handoffDirectory: URL + public var limit: Int + + public init(format: OutputFormat = .json, handoffDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/com.apple.handoff"), limit: Int = 10) { + self.format = format + self.handoffDirectory = handoffDirectory + self.limit = limit + } +} + public struct CloudTabsProbeOptions: Equatable, Sendable { public var format: OutputFormat public var safariDirectory: URL @@ -123,12 +149,14 @@ public enum CLICommand: Equatable, Sendable { case driveContainers(DriveContainersOptions) case driveList(DriveListOptions) case focusStatus(FocusStatusOptions) + case handoffList(HandoffListOptions) case safariBookmarks(SafariBookmarksOptions) case safariFrequentlyVisited(SafariFrequentlyVisitedOptions) case safariReadingList(SafariBookmarksOptions) case safariTabs(SafariTabsOptions) case shortcutsList(ShortcutsListOptions) case storageStatus(StorageStatusOptions) + case walletPasses(WalletPassesOptions) case help case version } @@ -138,6 +166,7 @@ public enum CLIParseError: Error, LocalizedError, Equatable { case missingValue(String) case invalidSource(String) case invalidFormat(String) + case invalidPassType(String) public var errorDescription: String? { switch self { @@ -145,6 +174,7 @@ public enum CLIParseError: Error, LocalizedError, Equatable { case .missingValue(let option): return "Missing value for \(option)" case .invalidSource(let source): return "Invalid Safari tabs source: \(source)" case .invalidFormat(let format): return "Invalid output format: \(format)" + case .invalidPassType(let type): return "Invalid wallet pass type: \(type)" } } } @@ -173,6 +203,16 @@ public struct CLIParser: Sendable { tokens.removeFirst() return .devicesList(try parseDevicesListOptions(tokens)) } + if topCommand == "wallet" { + guard tokens.first == "passes" else { throw CLIParseError.unknownCommand((["wallet"] + tokens).joined(separator: " ")) } + tokens.removeFirst() + return .walletPasses(try parseWalletPassesOptions(tokens)) + } + if topCommand == "handoff" { + guard tokens.first == "list" else { throw CLIParseError.unknownCommand((["handoff"] + tokens).joined(separator: " ")) } + tokens.removeFirst() + return .handoffList(try parseHandoffListOptions(tokens)) + } if topCommand == "drive" { guard let driveCommand = tokens.first else { throw CLIParseError.unknownCommand("drive") } tokens.removeFirst() @@ -349,6 +389,40 @@ public struct CLIParser: Sendable { return options } + private func parseWalletPassesOptions(_ tokens: [String]) throws -> WalletPassesOptions { + var options = WalletPassesOptions(); var index = 0 + while index < tokens.count { + let token = tokens[index] + switch token { + case "--format": options.format = try parseFormat(after: token, in: tokens, at: &index) + case "--passes-dir": options.passesDirectory = try parseURL(after: token, in: tokens, at: &index) + case "--type": + let raw = try value(after: token, in: tokens, at: &index) + guard let type = WalletPassType(rawValue: raw) else { throw CLIParseError.invalidPassType(raw) } + options.type = type + case "--active-only": options.activeOnly = true + default: throw CLIParseError.unknownCommand(token) + } + index += 1 + } + return options + } + + private func parseHandoffListOptions(_ tokens: [String]) throws -> HandoffListOptions { + var options = HandoffListOptions(); var index = 0 + while index < tokens.count { + let token = tokens[index] + switch token { + case "--format": options.format = try parseFormat(after: token, in: tokens, at: &index) + case "--handoff-dir": options.handoffDirectory = try parseURL(after: token, in: tokens, at: &index) + case "--limit": options.limit = Int(try value(after: token, in: tokens, at: &index)) ?? options.limit + default: throw CLIParseError.unknownCommand(token) + } + index += 1 + } + return options + } + private func parseCloudTabsProbeOptions(_ tokens: [String]) throws -> CloudTabsProbeOptions { var options = CloudTabsProbeOptions(); var index = 0 while index < tokens.count { @@ -392,6 +466,8 @@ Usage: icloud-cli storage status [--format json|text] [--cache-file PATH] icloud-cli focus status [--format json|text] [--focus-dir PATH] icloud-cli devices list [--format json|text] [--cache-file PATH] + icloud-cli wallet passes [--type PASS_TYPE] [--active-only] [--format json|text] [--passes-dir PATH] + icloud-cli handoff list [--limit N] [--format json|text] [--handoff-dir PATH] icloud-cli drive list [--path PATH] [--depth N] [--format json|text] [--icloud-root PATH] icloud-cli drive containers [--sort-by size|modified|name] [--format json|text] [--icloud-root PATH] icloud-cli shortcuts list [--name PATTERN] [--format json|text] [--shortcuts-dir PATH] @@ -405,6 +481,8 @@ Commands: storage status Report locally cached iCloud storage quota. focus status Report locally cached Focus / Do Not Disturb status. devices list List locally cached iCloud registered devices. + wallet passes List local Wallet pass metadata without barcode or payment credential data. + handoff list List local Handoff recent-activity metadata. drive list List files under the local iCloud Drive root without reading file contents. drive containers List top-level iCloud app containers. diff --git a/Sources/ICloudCLICore/CommandRunner.swift b/Sources/ICloudCLICore/CommandRunner.swift index 95292d7..c0a3e5b 100644 --- a/Sources/ICloudCLICore/CommandRunner.swift +++ b/Sources/ICloudCLICore/CommandRunner.swift @@ -45,6 +45,10 @@ public struct CommandRunner: Sendable { let status = try FocusStatusReader(focusDirectory: options.focusDirectory).readStatus() output(try render(status, format: options.format)) return 0 + case .handoffList(let options): + let activities = try HandoffActivityReader(handoffDirectory: options.handoffDirectory).listActivities(limit: options.limit) + output(try render(activities, format: options.format)) + return 0 case .safariBookmarks(let options): let bookmarks = try SafariBookmarksReader(safariDirectory: options.safariDirectory).readBookmarks() output(try render(bookmarks, format: options.format)) @@ -69,6 +73,10 @@ public struct CommandRunner: Sendable { let status = try ICloudStorageStatusReader(cacheFile: options.cacheFile).readStatus() output(try render(status, format: options.format)) return 0 + case .walletPasses(let options): + let passes = try WalletPassesReader(passesDirectory: options.passesDirectory).listPasses(type: options.type, activeOnly: options.activeOnly) + output(try render(passes, format: options.format)) + return 0 } } catch { errorOutput(error.localizedDescription) @@ -233,6 +241,36 @@ public struct CommandRunner: Sendable { } } + public func render(_ passes: [WalletPass], format: OutputFormat) throws -> String { + switch format { + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return String(decoding: try encoder.encode(passes), as: UTF8.self) + case .text: + return passes.map { pass in + let date = pass.relevantDate.map { ISO8601DateFormatter().string(from: $0) } ?? "no relevant date" + return "\(pass.passType.rawValue): \(pass.organizationName) - \(pass.description) (\(date))" + }.joined(separator: "\n") + } + } + + public func render(_ activities: [HandoffActivity], format: OutputFormat) throws -> String { + switch format { + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return String(decoding: try encoder.encode(activities), as: UTF8.self) + case .text: + return activities.map { activity in + let title = activity.title.map { " - \($0)" } ?? "" + return "\(activity.deviceName): \(activity.appName) [\(activity.activityType)]\(title)" + }.joined(separator: "\n") + } + } + public func render(_ report: CloudTabsProbeReport, format: OutputFormat) throws -> String { switch format { case .json: diff --git a/Sources/ICloudCLICore/WalletAndHandoff.swift b/Sources/ICloudCLICore/WalletAndHandoff.swift new file mode 100644 index 0000000..bb7f44b --- /dev/null +++ b/Sources/ICloudCLICore/WalletAndHandoff.swift @@ -0,0 +1,136 @@ +import Foundation + +public enum WalletPassType: String, Codable, CaseIterable, Sendable { + case boardingPass, eventTicket, coupon, storeCard, generic +} + +public struct WalletPass: Codable, Equatable, Sendable { + public let passType: WalletPassType + public let description: String + public let organizationName: String + public let relevantDate: Date? + public let expirationDate: Date? + public let serialNumber: String +} + +public enum WalletPassesError: Error, LocalizedError, Equatable { + case unreadable(String) + public var errorDescription: String? { if case .unreadable(let path) = self { return "Wallet passes directory is unreadable: \(path)" }; return nil } +} + +public struct WalletPassesReader: Sendable { + public let passesDirectory: URL + public init(passesDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Passes")) { self.passesDirectory = passesDirectory } + + public func listPasses(type: WalletPassType? = nil, activeOnly: Bool = false, now: Date = Date()) throws -> [WalletPass] { + guard let children = try? FileManager.default.contentsOfDirectory(at: passesDirectory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else { throw WalletPassesError.unreadable(passesDirectory.path) } + let passes = children.filter { $0.pathExtension == "pkpass" }.compactMap(readPass).filter { pass in + (type == nil || pass.passType == type) && (!activeOnly || pass.expirationDate.map { $0 >= now } ?? true) + } + return passes.sorted { ($0.relevantDate ?? .distantPast) > ($1.relevantDate ?? .distantPast) } + } + + private func readPass(_ url: URL) -> WalletPass? { + let manifestURL = url.appendingPathComponent("pass.json") + let data: Data? + if let values = try? url.resourceValues(forKeys: [.isDirectoryKey]), values.isDirectory == true { + data = try? Data(contentsOf: manifestURL) + } else { + data = Self.readZippedPassJSON(url) + } + guard let data, let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + guard let typeRaw = object["passType"] as? String ?? object["type"] as? String, + let passType = WalletPassType(rawValue: typeRaw), + let description = object["description"] as? String, + let organizationName = object["organizationName"] as? String, + let serialNumber = object["serialNumber"] as? String else { return nil } + return WalletPass(passType: passType, description: description, organizationName: organizationName, relevantDate: dateValue(object["relevantDate"]), expirationDate: dateValue(object["expirationDate"]), serialNumber: serialNumber) + } + + private static func readZippedPassJSON(_ url: URL) -> Data? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-p", url.path, "pass.json"] + let pipe = Pipe(); process.standardOutput = pipe; process.standardError = Pipe() + do { try process.run(); process.waitUntilExit() } catch { return nil } + guard process.terminationStatus == 0 else { return nil } + return pipe.fileHandleForReading.readDataToEndOfFile() + } +} + +public struct HandoffActivity: Codable, Equatable, Sendable { + public let deviceName: String + public let appBundleId: String + public let appName: String + public let activityType: String + public let title: String? + public let url: String? + public let updatedAt: Date +} + +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 } +} + +private struct HandoffActivitiesEnvelope: Codable { let activities: [HandoffActivity] } + +public struct HandoffActivityReader: Sendable { + public let handoffDirectory: URL + public init(handoffDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/com.apple.handoff")) { self.handoffDirectory = handoffDirectory } + + 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] = [] + for case let url as URL in enumerator where ["json", "plist"].contains(url.pathExtension) { + activities.append(contentsOf: readActivities(from: url)) + } + 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 [] } + 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 [] + } + + private func parseActivities(_ value: Any) -> [HandoffActivity] { + if let array = value as? [Any] { return array.flatMap(parseActivities) } + if let dict = value as? NSDictionary { + var swiftDict: [String: Any] = [:] + for (key, child) in dict { if let key = key as? String { swiftDict[key] = child } } + return parseActivities(swiftDict) + } + if let dict = value as? [String: Any] { + if let nested = dict["activities"] ?? dict["items"] { return parseActivities(nested) } + guard let device = stringValue(dict["deviceName"] ?? dict["device"]), + 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 [] } + return [HandoffActivity(deviceName: device, appBundleId: bundle, appName: app, activityType: type, title: stringValue(dict["title"]), url: stringValue(dict["url"]), updatedAt: updated)] + } + return [] + } +} + +private func dateValue(_ value: Any?) -> Date? { + if let date = value as? Date { return date } + if let number = value as? NSNumber { return Date(timeIntervalSince1970: number.doubleValue) } + if let string = value as? String { + if let date = ISO8601DateFormatter().date(from: string) { return date } + if let timestamp = Double(string) { return Date(timeIntervalSince1970: timestamp) } + } + return nil +} + +private func stringValue(_ value: Any?) -> String? { + if let string = value as? String, !string.isEmpty { return string } + return nil +} diff --git a/Tests/Fixtures/Handoff/activities.json b/Tests/Fixtures/Handoff/activities.json new file mode 100644 index 0000000..6781d23 --- /dev/null +++ b/Tests/Fixtures/Handoff/activities.json @@ -0,0 +1,20 @@ +{ + "activities": [ + { + "deviceName": "Example iPhone", + "appBundleId": "com.apple.mobilesafari", + "appName": "Safari", + "activityType": "NSUserActivityTypeBrowsingWeb", + "title": "Example Page", + "url": "https://example.com/context", + "updatedAt": "2026-05-14T12:00:00Z" + }, + { + "deviceName": "Example iPad", + "appBundleId": "com.apple.Notes", + "appName": "Notes", + "activityType": "com.apple.notes.activity", + "updatedAt": "2026-05-14T11:00:00Z" + } + ] +} diff --git a/Tests/Fixtures/Wallet/ExpiredCoupon.pkpass/pass.json b/Tests/Fixtures/Wallet/ExpiredCoupon.pkpass/pass.json new file mode 100644 index 0000000..0bbd1d5 --- /dev/null +++ b/Tests/Fixtures/Wallet/ExpiredCoupon.pkpass/pass.json @@ -0,0 +1,7 @@ +{ + "passType": "coupon", + "description": "Synthetic coupon", + "organizationName": "Example Store", + "expirationDate": "2020-01-01T00:00:00Z", + "serialNumber": "SYNTH-COUPON-001" +} diff --git a/Tests/Fixtures/Wallet/SampleBoarding.pkpass/pass.json b/Tests/Fixtures/Wallet/SampleBoarding.pkpass/pass.json new file mode 100644 index 0000000..56a728c --- /dev/null +++ b/Tests/Fixtures/Wallet/SampleBoarding.pkpass/pass.json @@ -0,0 +1,9 @@ +{ + "passType": "boardingPass", + "description": "Flight AB123", + "organizationName": "Example Air", + "relevantDate": "2026-06-01T12:00:00Z", + "expirationDate": "2026-06-02T12:00:00Z", + "serialNumber": "SYNTH-BOARD-001", + "barcode": { "message": "DO-NOT-EMIT" } +} diff --git a/Tests/ICloudCLICoreTests/CLIParserTests.swift b/Tests/ICloudCLICoreTests/CLIParserTests.swift index bd56c0e..2c48e51 100644 --- a/Tests/ICloudCLICoreTests/CLIParserTests.swift +++ b/Tests/ICloudCLICoreTests/CLIParserTests.swift @@ -176,3 +176,27 @@ import Testing #expect(options.format == .text) #expect(options.cacheFile.path == "/tmp/mobileme.plist") } + + +@Test func parsesWalletPassesCommand() throws { + let command = try CLIParser().parse(arguments: ["icloud-cli", "wallet", "passes", "--type", "boardingPass", "--active-only", "--passes-dir", "/tmp/passes", "--format", "text"]) + guard case .walletPasses(let options) = command else { + Issue.record("Expected wallet passes command") + return + } + #expect(options.type == .boardingPass) + #expect(options.activeOnly == true) + #expect(options.passesDirectory.path == "/tmp/passes") + #expect(options.format == .text) +} + +@Test func parsesHandoffListCommand() throws { + let command = try CLIParser().parse(arguments: ["icloud-cli", "handoff", "list", "--limit", "5", "--handoff-dir", "/tmp/handoff", "--format", "json"]) + guard case .handoffList(let options) = command else { + Issue.record("Expected handoff list command") + return + } + #expect(options.limit == 5) + #expect(options.handoffDirectory.path == "/tmp/handoff") + #expect(options.format == .json) +} diff --git a/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift b/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift new file mode 100644 index 0000000..9c4c31b --- /dev/null +++ b/Tests/ICloudCLICoreTests/WalletAndHandoffTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import ICloudCLICore + +private let fixtures = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Fixtures") + +@Test func readsWalletPassFixtures() throws { + let passes = try WalletPassesReader(passesDirectory: fixtures.appendingPathComponent("Wallet")).listPasses() + #expect(passes.count == 2) + #expect(passes.first?.passType == .boardingPass) + #expect(passes.first?.organizationName == "Example Air") + #expect(passes.first?.serialNumber == "SYNTH-BOARD-001") +} + +@Test func filtersWalletPassesByTypeAndActiveStatus() throws { + let passes = try WalletPassesReader(passesDirectory: fixtures.appendingPathComponent("Wallet")).listPasses(type: .boardingPass, activeOnly: true, now: ISO8601DateFormatter().date(from: "2026-05-14T00:00:00Z")!) + #expect(passes.count == 1) + #expect(passes[0].passType == .boardingPass) +} + +@Test func readsHandoffActivityFixture() throws { + let activities = try HandoffActivityReader(handoffDirectory: fixtures.appendingPathComponent("Handoff")).listActivities(limit: 1) + #expect(activities.count == 1) + #expect(activities[0].deviceName == "Example iPhone") + #expect(activities[0].appBundleId == "com.apple.mobilesafari") +} + +@Test func rendersWalletAndHandoffText() throws { + let runner = CommandRunner() + let pass = WalletPass(passType: .eventTicket, description: "Synthetic Event", organizationName: "Example Venue", relevantDate: nil, expirationDate: nil, serialNumber: "SERIAL") + #expect(try runner.render([pass], format: .text).contains("eventTicket: Example Venue")) + let activity = HandoffActivity(deviceName: "Example iPhone", appBundleId: "com.example", appName: "Example", activityType: "test", title: "Draft", url: nil, updatedAt: Date()) + #expect(try runner.render([activity], format: .text).contains("Example iPhone: Example")) +} diff --git a/docs/privacy.md b/docs/privacy.md index 86d9a0a..4527e93 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -40,6 +40,8 @@ OpenClaw integrations should default to local retention. Exporting raw browsing | `icloud-cli storage status` | Locally cached iCloud quota metadata, currently `~/Library/Preferences/MobileMeAccounts.plist` when available | Normal user file access. The command is read-only and makes no live network requests. Account email is direct operator output only; logs should redact to `user@…`. | | `icloud-cli focus status` | Local Focus / Do Not Disturb preference plists under `~/Library/DoNotDisturb` | Normal user file access. The command is read-only and does not modify Focus state. | | `icloud-cli devices list` | Locally cached iCloud registered-device metadata, currently `~/Library/Preferences/MobileMeAccounts.plist` when available | Normal user file access. Device names may be personally identifying; logs should report only count/model summary. | +| `icloud-cli wallet passes` | Local Wallet pass bundles under `~/Library/Passes`, reading pass manifests only | Normal user file access or Full Disk Access depending on macOS privacy posture. The command is read-only, emits no barcode or payment credential payloads, and logs should omit serial numbers. | +| `icloud-cli handoff list` | Local Handoff cache files under `~/Library/Application Support/com.apple.handoff` | Normal user file access. The command is read-only and does not use Bluetooth, network, or Continuity APIs. Titles and URLs are sensitive and should be redacted in logs. | | Future iCloud settings commands | Local Apple account or system settings state | Document per-command read surfaces before implementation; do not require Automation unless a command actually controls an app. | Automation permission is not required for the current Safari tab reader because it reads local files. Any future command that controls Safari, System Settings, or another app must document the Automation prompt and failure mode before merge. @@ -73,3 +75,10 @@ This keeps static privacy checks and Swift tests ahead of the standalone build. ## Local iCloud status surfaces `icloud-cli storage status`, `icloud-cli focus status`, and `icloud-cli devices list` read cached local metadata only. They do not contact iCloud, mutate system settings, or require Automation permission. Storage and devices currently use the local MobileMe/iCloud account preferences cache when present; Focus reads the local Do Not Disturb preference directory. Direct command output may include the real account email and device names because the operator requested them. OpenClaw logs and PR summaries should redact account emails and avoid listing device names unless explicitly requested. + + +## Wallet and Handoff inventory + +`icloud-cli wallet passes` reads local pass manifests from `~/Library/Passes` and emits only pass metadata: type, description, organization, relevant/expiration dates, and serial number. It intentionally does not emit barcode payloads, NFC/payment material, images, signatures, or full ZIP contents. Serial numbers are identifiers and should be hidden from OpenClaw status summaries unless the operator explicitly requests raw command output. + +`icloud-cli handoff list` reads cached local Handoff activity metadata from `~/Library/Application Support/com.apple.handoff`. The cache schema is treated as best-effort and may vary by macOS release, so the reader accepts JSON/plist fixtures and common key aliases rather than promising a private Apple schema. Activity titles and URLs are sensitive cross-device context; logs should summarize by app/device/count only.