Skip to content
Merged
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
78 changes: 78 additions & 0 deletions Sources/ICloudCLICore/CommandLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -138,13 +166,15 @@ public enum CLIParseError: Error, LocalizedError, Equatable {
case missingValue(String)
case invalidSource(String)
case invalidFormat(String)
case invalidPassType(String)

public var errorDescription: String? {
switch self {
case .unknownCommand(let command): return "Unknown command: \(command)"
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)"
}
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand All @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions Sources/ICloudCLICore/CommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
136 changes: 136 additions & 0 deletions Sources/ICloudCLICore/WalletAndHandoff.swift
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 20 additions & 0 deletions Tests/Fixtures/Handoff/activities.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
7 changes: 7 additions & 0 deletions Tests/Fixtures/Wallet/ExpiredCoupon.pkpass/pass.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"passType": "coupon",
"description": "Synthetic coupon",
"organizationName": "Example Store",
"expirationDate": "2020-01-01T00:00:00Z",
"serialNumber": "SYNTH-COUPON-001"
}
9 changes: 9 additions & 0 deletions Tests/Fixtures/Wallet/SampleBoarding.pkpass/pass.json
Original file line number Diff line number Diff line change
@@ -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" }
}
Loading
Loading