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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ let package = Package(
.executable(name: "icloud-cli", targets: ["icloud-cli"]),
],
targets: [
.target(name: "ICloudCLICore", linkerSettings: [.linkedFramework("EventKit")]),
.target(name: "ICloudCLICore", linkerSettings: [.linkedFramework("EventKit"), .linkedFramework("Photos")]),
.executableTarget(
name: "icloud-cli",
dependencies: ["ICloudCLICore"]
Expand Down
15 changes: 12 additions & 3 deletions Sources/ICloudCLICore/AppleMetadataInventories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1022,9 +1022,14 @@ public struct PermissionProbe: Codable, Equatable, Sendable {

public struct PermissionsDoctor: Sendable {
public let remindersAuthorization: RemindersAuthorizationState
public let photoAuthorization: PhotoKitAuthorizationState

public init(remindersAuthorization: RemindersAuthorizationState = SystemReminderEventKitClient.authorizationState()) {
public init(
remindersAuthorization: RemindersAuthorizationState = SystemReminderEventKitClient.authorizationState(),
photoAuthorization: PhotoKitAuthorizationState = SystemPhotoKitClient.authorizationState()
) {
self.remindersAuthorization = remindersAuthorization
self.photoAuthorization = photoAuthorization
}

public func diagnose() -> [PermissionProbe] {
Expand All @@ -1044,7 +1049,7 @@ public struct PermissionsDoctor: Sendable {
}
return PermissionProbe(command: item.command, paths: redacted, status: status, hint: hint(for: status))
}
return pathProbes + [remindersProbe()]
return pathProbes + [remindersProbe(), photoProbe()]
}

private func remindersProbe() -> PermissionProbe {
Expand All @@ -1068,10 +1073,14 @@ public struct PermissionsDoctor: Sendable {
("mail recent", [home.appendingPathComponent("Library/Mail").path], true),
("notes list", [home.appendingPathComponent("Library/Group Containers/group.com.apple.notes/NoteStore.sqlite").path], false),
("drive list", [home.appendingPathComponent("Library/Mobile Documents").path], false),
("photos list", [home.appendingPathComponent("Pictures/Photos Library.photoslibrary").path], false),
]
}

private func photoProbe() -> PermissionProbe {
let readable = photoAuthorization == .authorized || photoAuthorization == .limited
return PermissionProbe(command: "photos list", paths: [], status: "photokit-\(photoAuthorization.rawValue)", hint: readable ? "Photos metadata is authorized through PhotoKit." : "Grant Photos access in System Settings > Privacy & Security > Photos.")
}

private func hint(for status: String) -> String {
switch status {
case "missing-fda": return "Grant Full Disk Access to the calling terminal or agent process."
Expand Down
34 changes: 29 additions & 5 deletions Sources/ICloudCLICore/CommandLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,13 @@ public struct PhotosListOptions: Equatable, Sendable {
public var format: OutputFormat
public var photosLibrary: URL
public var limit: Int
public var degradedFilesystem: Bool

public init(format: OutputFormat = .json, photosLibrary: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Pictures/Photos Library.photoslibrary"), limit: Int = 200) {
public init(format: OutputFormat = .json, photosLibrary: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Pictures/Photos Library.photoslibrary"), limit: Int = 200, degradedFilesystem: Bool = false) {
self.format = format
self.photosLibrary = photosLibrary
self.limit = limit
self.degradedFilesystem = degradedFilesystem
}
}

Expand Down Expand Up @@ -613,6 +615,7 @@ public enum CLICommand: Equatable, Sendable {
case newsTopics(NewsOptions)
case notesList(NotesListOptions)
case photosList(PhotosListOptions)
case photosAuthorization(OutputFormat)
case photosScreenshots(PhotosScreenshotsOptions)
case providersExternalManifest(OutputFormat)
case providersList(OutputFormat)
Expand All @@ -638,6 +641,7 @@ public enum CLIParseError: Error, LocalizedError, Equatable {
case invalidSource(String)
case invalidFormat(String)
case invalidPassType(String)
case invalidOptionCombination(String)

public var errorDescription: String? {
switch self {
Expand All @@ -646,6 +650,7 @@ public enum CLIParseError: Error, LocalizedError, Equatable {
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)"
case .invalidOptionCombination(let message): return message
}
}
}
Expand Down Expand Up @@ -861,6 +866,7 @@ public struct CLIParser: Sendable {
guard let photosCommand = tokens.first else { throw CLIParseError.unknownCommand("photos") }
tokens.removeFirst()
switch photosCommand {
case "authorization": return .photosAuthorization(try parseOutputFormatOnly(tokens))
case "screenshots": return .photosScreenshots(try parsePhotosScreenshotsOptions(tokens))
case "list": return .photosList(try parsePhotosListOptions(tokens))
case "shared-albums": return .metadata(.photosSharedAlbums, try parseMetadataOptions(tokens))
Expand Down Expand Up @@ -1231,20 +1237,37 @@ public struct CLIParser: Sendable {
}

private func parsePhotosListOptions(_ tokens: [String]) throws -> PhotosListOptions {
var options = PhotosListOptions(); var index = 0
var options = PhotosListOptions(); var index = 0; var specifiesPhotosLibrary = false
while index < tokens.count {
let token = tokens[index]
switch token {
case "--format": options.format = try parseFormat(after: token, in: tokens, at: &index)
case "--photos-library": options.photosLibrary = try parseURL(after: token, in: tokens, at: &index)
case "--photos-library":
options.photosLibrary = try parseURL(after: token, in: tokens, at: &index)
specifiesPhotosLibrary = true
case "--limit": options.limit = Int(try value(after: token, in: tokens, at: &index)) ?? options.limit
case "--degraded-filesystem": options.degradedFilesystem = true
default: throw CLIParseError.unknownCommand(token)
}
index += 1
}
if specifiesPhotosLibrary && !options.degradedFilesystem {
throw CLIParseError.invalidOptionCombination("--photos-library requires --degraded-filesystem")
}
return options
}

private func parseOutputFormatOnly(_ tokens: [String]) throws -> OutputFormat {
var format = OutputFormat.json; var index = 0
while index < tokens.count {
let token = tokens[index]
guard token == "--format" else { throw CLIParseError.unknownCommand(token) }
format = try parseFormat(after: token, in: tokens, at: &index)
index += 1
}
return format
}

private func parseNotesListOptions(_ tokens: [String]) throws -> NotesListOptions {
var options = NotesListOptions(); var index = 0
while index < tokens.count {
Expand Down Expand Up @@ -1610,7 +1633,8 @@ Usage:
icloud-cli drive recents [--since ISO8601] [--limit N] [--scan-limit N] [--timeout-ms N] [--format json|text] [--icloud-root PATH]
icloud-cli shortcuts list [--name PATTERN] [--format json|text] [--shortcuts-dir PATH]
icloud-cli photos screenshots [--format json|text] [--screenshots-dir PATH]
icloud-cli photos list [--limit N] [--format json|text] [--photos-library PATH]
icloud-cli photos authorization [--format json|text]
icloud-cli photos list [--limit N] [--degraded-filesystem] [--format json|text] [--photos-library PATH]
icloud-cli photos shared-albums [--format json|text] [--photos-store PATH]
icloud-cli photos shared-library [--format json|text] [--photos-store PATH]
icloud-cli notes list [--folder NAME] [--modified-since ISO8601] [--include-body] [--format json|text] [--notes-store PATH]
Expand Down Expand Up @@ -1681,7 +1705,7 @@ Commands:
shortcuts list List local Shortcuts metadata without executing shortcuts.
photos screenshots
List screenshot file metadata without reading pixels.
photos list List local photo/video asset metadata without exporting media.
photos list List PhotoKit asset facts without exporting or downloading media.
notes list List Notes titles and dates; body requires --include-body.
reminders list List reminder metadata from a read-only local store.
contacts list List contact cards from local metadata.
Expand Down
15 changes: 14 additions & 1 deletion Sources/ICloudCLICore/CommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ public struct CommandRunner: Sendable {
private let remindersClient: any ReminderEventKitClient
private let output: @Sendable (String) -> Void
private let errorOutput: @Sendable (String) -> Void
private let photoKitClient: any PhotoKitClient

public init(
parser: CLIParser = CLIParser(),
remindersClient: any ReminderEventKitClient = SystemReminderEventKitClient(),
photoKitClient: any PhotoKitClient = SystemPhotoKitClient(),
output: @escaping @Sendable (String) -> Void = { print($0) },
errorOutput: @escaping @Sendable (String) -> Void = { FileHandle.standardError.write(Data(($0 + "\n").utf8)) }
) {
self.parser = parser
self.remindersClient = remindersClient
self.photoKitClient = photoKitClient
self.output = output
self.errorOutput = errorOutput
}
Expand Down Expand Up @@ -123,9 +126,19 @@ public struct CommandRunner: Sendable {
output(try render(notes, format: options.format))
return 0
case .photosList(let options):
let photos = try PhotosInventoryReader(photosLibraryDirectory: options.photosLibrary).listPhotos(limit: options.limit)
let photos: [PhotoEvidenceAsset]
if options.degradedFilesystem {
photos = try PhotosInventoryReader(photosLibraryDirectory: options.photosLibrary).listPhotos(limit: options.limit).map {
PhotoEvidenceAsset(id: $0.localIdentifier, facts: .init(filename: $0.filename, mediaType: $0.mediaType, createdAt: $0.createdAt, modifiedAt: $0.modifiedAt, pixelWidth: 0, pixelHeight: 0, isFavorite: $0.isFavorite, isHidden: false, albumNames: $0.albumNames, availability: .unknown), observations: [], provenance: .init(source: "photos-library-filesystem", degraded: true))
}
} else {
photos = try PhotoKitProvider(client: photoKitClient).assets(limit: options.limit)
}
output(try render(photos, format: options.format))
return 0
case .photosAuthorization(let format):
output(try render(PhotoKitProvider(client: photoKitClient).authorization(), format: format))
return 0
case .photosScreenshots(let options):
let screenshots = try PhotosInventoryReader(screenshotsDirectory: options.screenshotsDirectory).listScreenshots()
output(try render(screenshots, format: options.format))
Expand Down
123 changes: 123 additions & 0 deletions Sources/ICloudCLICore/PhotoKitProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Foundation
import Photos

public enum PhotoKitAuthorizationState: String, Codable, Sendable { case notDetermined = "not-determined", restricted, denied, limited, authorized, unknown }
public enum PhotoAvailability: String, Codable, Sendable { case local, cloud, unknown }

public struct PhotoKitAssetFact: Equatable, Sendable {
public let id: String
public let filename: String?
public let mediaType: String
public let createdAt: Date?
public let modifiedAt: Date?
public let pixelWidth: Int
public let pixelHeight: Int
public let isFavorite: Bool
public let isHidden: Bool
public let albumNames: [String]
public let availability: PhotoAvailability

public init(id: String, filename: String?, mediaType: String, createdAt: Date?, modifiedAt: Date?, pixelWidth: Int, pixelHeight: Int, isFavorite: Bool, isHidden: Bool, albumNames: [String], availability: PhotoAvailability) {
self.id = id; self.filename = filename; self.mediaType = mediaType; self.createdAt = createdAt; self.modifiedAt = modifiedAt; self.pixelWidth = pixelWidth; self.pixelHeight = pixelHeight; self.isFavorite = isFavorite; self.isHidden = isHidden; self.albumNames = albumNames; self.availability = availability
}
}

public struct PhotoEvidenceAsset: Codable, Equatable, Sendable {
public struct Facts: Codable, Equatable, Sendable {
public let filename: String?
public let mediaType: String
public let createdAt: Date?
public let modifiedAt: Date?
public let pixelWidth: Int
public let pixelHeight: Int
public let isFavorite: Bool
public let isHidden: Bool
public let albumNames: [String]
public let availability: PhotoAvailability
}
public struct Provenance: Codable, Equatable, Sendable {
public let source: String; public let degraded: Bool
public init(source: String, degraded: Bool) { self.source = source; self.degraded = degraded }
}
public let id: String
public let facts: Facts
public let observations: [String]
public let provenance: Provenance
public init(id: String, facts: Facts, observations: [String], provenance: Provenance) { self.id = id; self.facts = facts; self.observations = observations; self.provenance = provenance }
}

public struct PhotoKitAuthorizationReport: Codable, Equatable, Sendable {
public let provider: String
public let state: PhotoKitAuthorizationState
public let canRead: Bool
public let requestsAccess: Bool
public let nextAction: String?
public let limitations: [String]
public init(state: PhotoKitAuthorizationState, canRead: Bool, nextAction: String?, limitations: [String]) { self.provider = "photokit"; self.state = state; self.canRead = canRead; self.requestsAccess = false; self.nextAction = nextAction; self.limitations = limitations }
}

public protocol PhotoKitClient: Sendable {
func authorizationState() -> PhotoKitAuthorizationState
func fetchAssets(limit: Int) throws -> [PhotoKitAssetFact]
}

public enum PhotoKitProviderError: Error, LocalizedError {
case authorization(PhotoKitAuthorizationState)
public var errorDescription: String? {
switch self { case .authorization(let state): return "Photos authorization is \(state.rawValue). Check with `photos authorization`; no access prompt was requested." }
}
}

public struct PhotoKitProvider: Sendable {
public let client: any PhotoKitClient
public init(client: any PhotoKitClient = SystemPhotoKitClient()) { self.client = client }

public func authorization() -> PhotoKitAuthorizationReport {
let state = client.authorizationState()
let canRead = state == .authorized || state == .limited
return PhotoKitAuthorizationReport(state: state, canRead: canRead, nextAction: canRead ? nil : "Grant Photos access in System Settings > Privacy & Security > Photos.", limitations: ["PhotoKit does not reliably expose local-versus-cloud availability without requesting media.", "No pixels, thumbnails, OCR, or classification are requested."])
}

public func assets(limit: Int) throws -> [PhotoEvidenceAsset] {
let state = client.authorizationState()
guard state == .authorized || state == .limited else { throw PhotoKitProviderError.authorization(state) }
return try client.fetchAssets(limit: min(max(limit, 1), 10_000)).map { fact in
PhotoEvidenceAsset(id: fact.id, facts: .init(filename: fact.filename, mediaType: fact.mediaType, createdAt: fact.createdAt, modifiedAt: fact.modifiedAt, pixelWidth: fact.pixelWidth, pixelHeight: fact.pixelHeight, isFavorite: fact.isFavorite, isHidden: fact.isHidden, albumNames: fact.albumNames.sorted(), availability: fact.availability), observations: [], provenance: .init(source: "photokit", degraded: false))
}
}
}

public struct SystemPhotoKitClient: PhotoKitClient {
public init() {}
public func authorizationState() -> PhotoKitAuthorizationState {
Self.authorizationState()
}
public static func authorizationState() -> PhotoKitAuthorizationState {
switch PHPhotoLibrary.authorizationStatus(for: .readWrite) {
case .notDetermined: .notDetermined
case .restricted: .restricted
case .denied: .denied
case .limited: .limited
case .authorized: .authorized
@unknown default: .unknown
}
}

public func fetchAssets(limit: Int) throws -> [PhotoKitAssetFact] {
let options = PHFetchOptions()
options.fetchLimit = limit
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
let result = PHAsset.fetchAssets(with: options)
var rows: [PhotoKitAssetFact] = []
result.enumerateObjects { asset, _, _ in
let filename = PHAssetResource.assetResources(for: asset).first?.originalFilename
let type = switch asset.mediaType { case .image: "image"; case .video: "video"; case .audio: "audio"; default: "unknown" }
var albumNames: [String] = []
PHAssetCollection.fetchAssetCollectionsContaining(asset, with: .album, options: nil).enumerateObjects { collection, _, _ in
if let title = collection.localizedTitle { albumNames.append(title) }
}
rows.append(.init(id: asset.localIdentifier, filename: filename, mediaType: type, createdAt: asset.creationDate, modifiedAt: asset.modificationDate, pixelWidth: asset.pixelWidth, pixelHeight: asset.pixelHeight, isFavorite: asset.isFavorite, isHidden: asset.isHidden, albumNames: albumNames, availability: .unknown))
}
return rows
}
}
2 changes: 1 addition & 1 deletion Sources/ICloudCLICore/ProviderManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public enum ProviderRegistry {
provider("music", "Music", .beta, .sqlite, .moderate, ["music playlists", "music status", "music tracks"], ["inventory", "playlists", "status"]),
provider("news", "News", .beta, .sqlite, .moderate, ["news history", "news topics"], ["history", "topics", "date-filtering"]),
provider("notes", "Notes", .beta, .sqlite, .high, ["notes accounts", "notes folders", "notes list", "notes shared", "notes tags"], ["accounts", "folders", "inventory", "sharing", "tags"]),
provider("photos", "Photos", .beta, .mixed, .high, ["photos list", "photos screenshots", "photos shared-albums", "photos shared-library"], ["assets", "screenshots", "sharing"]),
provider("photos", "Photos", .beta, .mixed, .high, ["photos authorization", "photos list", "photos screenshots", "photos shared-albums", "photos shared-library"], ["assets", "evidence", "photokit-primary", "screenshots", "sharing"]),
provider("reminders", "Reminders", .beta, .mixed, .high, ["reminders assigned", "reminders authorization", "reminders flagged", "reminders list", "reminders lists", "reminders scheduled", "reminders today"], ["degraded-private-store", "eventkit-primary", "inventory", "lists", "smart-views"], permissionExpectations: ["eventkit-reminders-read", "full-disk-access-only-for-explicit-degraded-fallback"]),
provider("safari", "Safari", .beta, .mixed, .high, ["safari bookmarks", "safari cloud-tabs list", "safari cloud-tabs probe", "safari extensions list", "safari frequently-visited", "safari history", "safari profiles list", "safari reading-list", "safari tabs"], ["bookmarks", "cloud-tabs", "consistent-snapshot", "extensions", "history", "profiles", "tabs"]),
provider("shortcuts", "Shortcuts", .stable, .filesystem, .moderate, ["shortcuts list"], ["archive-metadata", "inventory", "search"], true),
Expand Down
Loading
Loading