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
531 changes: 516 additions & 15 deletions Sources/ICloudCLICore/AppleMetadataInventories.swift

Large diffs are not rendered by default.

32 changes: 22 additions & 10 deletions Sources/ICloudCLICore/CommandLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,12 @@ public struct PhotosScreenshotsOptions: Equatable, Sendable {
public struct PhotosListOptions: Equatable, Sendable {
public var format: OutputFormat
public var photosLibrary: URL
public var limit: Int

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

Expand Down Expand Up @@ -195,7 +197,7 @@ public struct RemindersListOptions: Equatable, Sendable {
public var dueAfter: String?
public var includeCompleted: Bool

public init(format: OutputFormat = .json, store: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Reminders/reminders.sqlite"), list: String? = nil, dueBefore: String? = nil, dueAfter: String? = nil, includeCompleted: Bool = false) {
public init(format: OutputFormat = .json, store: URL = AppleRemindersStoreResolver().database() ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Reminders/reminders.sqlite"), list: String? = nil, dueBefore: String? = nil, dueAfter: String? = nil, includeCompleted: Bool = false) {
self.format = format
self.store = store
self.list = list
Expand Down Expand Up @@ -266,7 +268,7 @@ public struct MapsOptions: Equatable, Sendable {
public var store: URL
public var limit: Int

public init(format: OutputFormat = .json, store: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Containers/com.apple.Maps/Data/Library/Maps/Maps.sqlite"), limit: Int = 20) {
public init(format: OutputFormat = .json, store: URL = AppleMapsStoreResolver().database() ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Containers/com.apple.Maps/Data/Library/Maps/Maps.sqlite"), limit: Int = 20) {
self.format = format
self.store = store
self.limit = limit
Expand Down Expand Up @@ -433,6 +435,7 @@ public struct MetadataOptions: Equatable, Sendable {
public var since: String?
public var until: String?
public var limit: Int
public var driveStatusLimit: Int?
public var confirmSensitive: Bool
public var includeAttendees: Bool
public var includeCoordinates: Bool
Expand Down Expand Up @@ -465,6 +468,7 @@ public struct MetadataOptions: Equatable, Sendable {
since: String? = nil,
until: String? = nil,
limit: Int = 50,
driveStatusLimit: Int? = nil,
confirmSensitive: Bool = false,
includeAttendees: Bool = false,
includeCoordinates: Bool = false,
Expand Down Expand Up @@ -496,6 +500,7 @@ public struct MetadataOptions: Equatable, Sendable {
self.since = since
self.until = until
self.limit = limit
self.driveStatusLimit = driveStatusLimit
self.confirmSensitive = confirmSensitive
self.includeAttendees = includeAttendees
self.includeCoordinates = includeCoordinates
Expand Down Expand Up @@ -1018,7 +1023,10 @@ public struct CLIParser: Sendable {
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
case "--limit":
let rawLimit = try value(after: token, in: tokens, at: &index)
guard let limit = Int(rawLimit) else { throw CLIParseError.missingValue(token) }
options.limit = limit
default: throw CLIParseError.unknownCommand(token)
}
index += 1
Expand Down Expand Up @@ -1061,6 +1069,7 @@ public struct CLIParser: Sendable {
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 "--limit": options.limit = Int(try value(after: token, in: tokens, at: &index)) ?? options.limit
default: throw CLIParseError.unknownCommand(token)
}
index += 1
Expand Down Expand Up @@ -1239,7 +1248,7 @@ public struct CLIParser: Sendable {
let token = tokens[index]
switch token {
case "--format": options.format = try parseFormat(after: token, in: tokens, at: &index)
case "--store", "--metadata-store", "--calendar-store", "--findmy-store", "--mail-store", "--books-store", "--health-store", "--notes-store", "--photos-store", "--safari-store", "--music-store", "--weather-store", "--stocks-store", "--freeform-store", "--home-store", "--voice-memos-store":
case "--store", "--metadata-store", "--calendar-store", "--findmy-store", "--mail-store", "--books-store", "--health-store", "--notes-store", "--photos-store", "--reminders-store", "--safari-store", "--music-store", "--weather-store", "--stocks-store", "--freeform-store", "--home-store", "--voice-memos-store":
options.store = try parseURL(after: token, in: tokens, at: &index)
case "--cache-file":
options.store = try parseURL(after: token, in: tokens, at: &index)
Expand Down Expand Up @@ -1270,7 +1279,10 @@ public struct CLIParser: Sendable {
case "--tag": options.tag = try value(after: token, in: tokens, at: &index)
case "--since": options.since = try value(after: token, in: tokens, at: &index)
case "--until": options.until = try value(after: token, in: tokens, at: &index)
case "--limit": options.limit = Int(try value(after: token, in: tokens, at: &index)) ?? options.limit
case "--limit":
let limit = Int(try value(after: token, in: tokens, at: &index)) ?? options.limit
options.limit = limit
options.driveStatusLimit = limit
case "--confirm-sensitive": options.confirmSensitive = true
case "--include-attendees": options.includeAttendees = true
case "--include-coordinates": options.includeCoordinates = true
Expand Down Expand Up @@ -1325,13 +1337,13 @@ Usage:
icloud-cli handoff list [--limit N] [--format json|text] [--handoff-dir PATH]
icloud-cli drive list [--path PATH] [--depth N] [--show-status] [--format json|text] [--icloud-root PATH]
icloud-cli drive containers [--sort-by size|modified|name] [--format json|text] [--icloud-root PATH]
icloud-cli drive status [--path PATH] [--format json|text] [--icloud-root PATH]
icloud-cli drive errors [--path PATH] [--format json|text] [--icloud-root PATH]
icloud-cli drive shared [--path PATH] [--format json|text] [--icloud-root PATH]
icloud-cli drive status [--path PATH] [--limit N] [--format json|text] [--icloud-root PATH]
icloud-cli drive errors [--path PATH] [--limit N] [--format json|text] [--icloud-root PATH]
icloud-cli drive shared [--path PATH] [--limit N] [--format json|text] [--icloud-root PATH]
icloud-cli drive recents [--since ISO8601] [--limit 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 [--format json|text] [--photos-library PATH]
icloud-cli photos list [--limit N] [--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
12 changes: 6 additions & 6 deletions Sources/ICloudCLICore/CommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ 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()
let photos = try PhotosInventoryReader(photosLibraryDirectory: options.photosLibrary).listPhotos(limit: options.limit)
output(try render(photos, format: options.format))
return 0
case .photosScreenshots(let options):
Expand Down Expand Up @@ -198,22 +198,22 @@ public struct CommandRunner: Sendable {
return rendered
case .driveStatus:
let reader = ICloudDriveInventoryReader(rootDirectory: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents"))
return try render(try reader.syncStatus(path: options.path), format: options.format)
return try render(try reader.syncStatus(path: options.path, limit: options.driveStatusLimit), format: options.format)
case .driveErrors:
let reader = ICloudDriveInventoryReader(rootDirectory: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents"))
return try render(try reader.errorFiles(path: options.path), format: options.format)
return try render(try reader.errorFiles(path: options.path, limit: options.limit), format: options.format)
case .driveShared:
let reader = ICloudDriveInventoryReader(rootDirectory: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents"))
return try render(try reader.sharedItems(path: options.path), format: options.format)
return try render(try reader.sharedItems(path: options.path, limit: options.limit), format: options.format)
case .driveRecents:
let reader = ICloudDriveInventoryReader(rootDirectory: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents"))
return try render(try reader.recentFiles(since: options.since, limit: options.limit), format: options.format)
case .tagsList:
let reader = FinderTagsReader(preferencesFile: options.store ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/SyncedPreferences/com.apple.finder.plist"), driveRoot: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents"))
let reader = FinderTagsReader(preferencesFile: options.store ?? FinderTagsStoreResolver().resolvedPreferencesFile(), driveRoot: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents"))
return try render(try reader.listTags(), format: options.format)
case .taggedItems:
guard let tag = options.tag else { throw CLIParseError.missingValue("--tag") }
let reader = FinderTagsReader(preferencesFile: options.store ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/SyncedPreferences/com.apple.finder.plist"), driveRoot: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents"))
let reader = FinderTagsReader(preferencesFile: options.store ?? FinderTagsStoreResolver().resolvedPreferencesFile(), driveRoot: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents"))
return try render(try reader.items(tag: tag, path: options.path, limit: options.limit), format: options.format)
default:
let store = options.store ?? LocalMetadataStoreReader.defaultStore(for: command)
Expand Down
55 changes: 43 additions & 12 deletions Sources/ICloudCLICore/DriveInventory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,22 @@ public struct ICloudDriveInventoryReader: Sendable {
self.rootDirectory = rootDirectory.standardizedFileURL
}

public func listFiles(path requestedPath: String? = nil, depth: Int = 2) throws -> [ICloudDriveFile] {
public func listFiles(path requestedPath: String? = nil, depth: Int = 2, limit: Int? = nil) throws -> [ICloudDriveFile] {
guard FileManager.default.fileExists(atPath: rootDirectory.path) else { throw DriveInventoryError.missingRoot(rootDirectory.path) }
let startURL = try scopedURL(for: requestedPath)
let startValues = try? startURL.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey])
if startValues?.isDirectory != true {
return [fileEntry(for: startURL, values: startValues)]
}
let maxDepth = max(0, depth)
let maxFiles = limit.map { max(1, $0) }
var result: [ICloudDriveFile] = []
try walkFiles(at: startURL, currentDepth: 0, maxDepth: maxDepth, into: &result)
try walkFiles(at: startURL, currentDepth: 0, maxDepth: maxDepth, maxFiles: maxFiles, into: &result)
return result.sorted { lhs, rhs in lhs.path.localizedStandardCompare(rhs.path) == .orderedAscending }
}

public func syncStatus(path requestedPath: String? = nil) throws -> ICloudDriveSyncSummary {
let files = try listFiles(path: requestedPath, depth: Int.max)
public func syncStatus(path requestedPath: String? = nil, limit: Int? = nil) throws -> ICloudDriveSyncSummary {
let files = try listFiles(path: requestedPath, depth: Int.max, limit: limit)
return ICloudDriveSyncSummary(
downloadedCount: files.filter { $0.iCloudStatus == .downloaded }.count,
cloudOnlyCount: files.filter { $0.iCloudStatus == .evicted || $0.iCloudStatus == .notDownloaded }.count,
Expand All @@ -100,15 +101,19 @@ public struct ICloudDriveInventoryReader: Sendable {
)
}

public func errorFiles(path requestedPath: String? = nil) throws -> [ICloudDriveErrorEntry] {
try listFiles(path: requestedPath, depth: Int.max)
public func errorFiles(path requestedPath: String? = nil, limit: Int = 500, scanLimit: Int? = nil) throws -> [ICloudDriveErrorEntry] {
let resultLimit = max(1, limit)
let traversalLimit = scanLimit ?? filteredDriveScanLimit(for: resultLimit)
return try listFiles(path: requestedPath, depth: Int.max, limit: traversalLimit)
.filter { $0.iCloudStatus == .error }
.prefix(resultLimit)
.map { ICloudDriveErrorEntry(path: $0.path, category: "icloud-sync-error") }
}

public func recentFiles(since: String? = nil, limit: Int = 50) throws -> [ICloudDriveFile] {
let floor = since.flatMap { ISO8601DateFormatter().date(from: $0) }
return try listFiles(depth: Int.max)
let scanLimit = max(200, bounded(limit, defaultValue: 50, max: 1_000) * 5)
return try listFiles(depth: Int.max, limit: scanLimit)
.filter { file in
guard let floor else { return true }
return file.modifiedAt.map { $0 >= floor } ?? false
Expand All @@ -118,9 +123,12 @@ public struct ICloudDriveInventoryReader: Sendable {
.map { $0 }
}

public func sharedItems(path requestedPath: String? = nil) throws -> [ICloudDriveSharedItem] {
try listFiles(path: requestedPath, depth: Int.max)
public func sharedItems(path requestedPath: String? = nil, limit: Int = 500, scanLimit: Int? = nil) throws -> [ICloudDriveSharedItem] {
let resultLimit = max(1, limit)
let traversalLimit = scanLimit ?? filteredDriveScanLimit(for: resultLimit)
return try listFiles(path: requestedPath, depth: Int.max, limit: traversalLimit)
.filter { $0.path.localizedCaseInsensitiveContains(".shared") }
.prefix(resultLimit)
.map { file in
ICloudDriveSharedItem(path: file.path, owner: nil, role: nil, dateShared: file.modifiedAt, iCloudStatus: file.iCloudStatus)
}
Expand All @@ -133,7 +141,7 @@ public struct ICloudDriveInventoryReader: Sendable {
for child in children {
let values = try? child.resourceValues(forKeys: [.isDirectoryKey])
guard values?.isDirectory == true else { continue }
let stats = directoryStats(child)
let stats: (sizeBytes: Int64?, modifiedAt: Date?) = shouldComputeContainerStats(for: sortBy) ? directoryStats(child) : (nil, nil)
containers.append(ICloudDriveContainer(bundleId: child.lastPathComponent, displayName: displayName(for: child.lastPathComponent), sizeBytes: stats.sizeBytes, modifiedAt: stats.modifiedAt))
}
return sort(containers, by: sortBy)
Expand All @@ -149,17 +157,27 @@ public struct ICloudDriveInventoryReader: Sendable {
return standardized
}

private func walkFiles(at directory: URL, currentDepth: Int, maxDepth: Int, into result: inout [ICloudDriveFile]) throws {
private func filteredDriveScanLimit(for resultLimit: Int) -> Int {
max(200, bounded(resultLimit, defaultValue: 500, max: 2_000) * 5)
}

private func walkFiles(at directory: URL, currentDepth: Int, maxDepth: Int, maxFiles: Int?, into result: inout [ICloudDriveFile]) throws {
if let maxFiles, result.count >= maxFiles {
return
}
let directoryValues = try? directory.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey])
if directoryValues?.isDirectory != true {
result.append(fileEntry(for: directory, values: directoryValues))
return
}
let children = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], options: [])
for child in children {
if let maxFiles, result.count >= maxFiles {
return
}
let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey])
if values?.isDirectory == true {
if currentDepth < maxDepth { try walkFiles(at: child, currentDepth: currentDepth + 1, maxDepth: maxDepth, into: &result) }
if currentDepth < maxDepth { try walkFiles(at: child, currentDepth: currentDepth + 1, maxDepth: maxDepth, maxFiles: maxFiles, into: &result) }
continue
}
result.append(fileEntry(for: child, values: values))
Expand All @@ -173,6 +191,7 @@ public struct ICloudDriveInventoryReader: Sendable {

private func status(for url: URL) -> ICloudFileStatus {
let name = url.lastPathComponent
if name.hasPrefix(".broken") && name.hasSuffix(".icloud") { return .error }
if name.hasPrefix(".") && name.hasSuffix(".icloud") { return .evicted }
if name.hasSuffix(".icloud") { return .uploading }
return .downloaded
Expand Down Expand Up @@ -218,6 +237,13 @@ public struct ICloudDriveInventoryReader: Sendable {
bundleId.replacingOccurrences(of: "com~apple~", with: "Apple ").replacingOccurrences(of: "~", with: ".")
}

private func shouldComputeContainerStats(for sortBy: DriveSortKey) -> Bool {
switch sortBy {
case .size, .modified: return true
case .name: return false
}
}

private func sort(_ containers: [ICloudDriveContainer], by key: DriveSortKey) -> [ICloudDriveContainer] {
containers.sorted { lhs, rhs in
switch key {
Expand All @@ -228,3 +254,8 @@ public struct ICloudDriveInventoryReader: Sendable {
}
}
}

private func bounded(_ value: Int, defaultValue: Int, max: Int) -> Int {
guard value > 0 else { return defaultValue }
return Swift.min(value, max)
}
Loading
Loading