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
96 changes: 96 additions & 0 deletions Sources/ICloudCLICore/CommandLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,36 @@ public struct ShortcutsListOptions: Equatable, Sendable {
}
}

public struct StorageStatusOptions: Equatable, Sendable {
public var format: OutputFormat
public var cacheFile: URL

public init(format: OutputFormat = .json, cacheFile: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Preferences/MobileMeAccounts.plist")) {
self.format = format
self.cacheFile = cacheFile
}
}

public struct FocusStatusOptions: Equatable, Sendable {
public var format: OutputFormat
public var focusDirectory: URL

public init(format: OutputFormat = .json, focusDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/DoNotDisturb")) {
self.format = format
self.focusDirectory = focusDirectory
}
}

public struct DevicesListOptions: Equatable, Sendable {
public var format: OutputFormat
public var cacheFile: URL

public init(format: OutputFormat = .json, cacheFile: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Preferences/MobileMeAccounts.plist")) {
self.format = format
self.cacheFile = cacheFile
}
}

public struct CloudTabsProbeOptions: Equatable, Sendable {
public var format: OutputFormat
public var safariDirectory: URL
Expand All @@ -89,13 +119,16 @@ public struct CloudTabsProbeOptions: Equatable, Sendable {

public enum CLICommand: Equatable, Sendable {
case cloudTabsProbe(CloudTabsProbeOptions)
case devicesList(DevicesListOptions)
case driveContainers(DriveContainersOptions)
case driveList(DriveListOptions)
case focusStatus(FocusStatusOptions)
case safariBookmarks(SafariBookmarksOptions)
case safariFrequentlyVisited(SafariFrequentlyVisitedOptions)
case safariReadingList(SafariBookmarksOptions)
case safariTabs(SafariTabsOptions)
case shortcutsList(ShortcutsListOptions)
case storageStatus(StorageStatusOptions)
case help
case version
}
Expand Down Expand Up @@ -125,6 +158,21 @@ public struct CLIParser: Sendable {
if tokens == ["--version"] || tokens == ["-V"] { return .version }

let topCommand = tokens.removeFirst()
if topCommand == "storage" {
guard tokens.first == "status" else { throw CLIParseError.unknownCommand((["storage"] + tokens).joined(separator: " ")) }
tokens.removeFirst()
return .storageStatus(try parseStorageStatusOptions(tokens))
}
if topCommand == "focus" {
guard tokens.first == "status" else { throw CLIParseError.unknownCommand((["focus"] + tokens).joined(separator: " ")) }
tokens.removeFirst()
return .focusStatus(try parseFocusStatusOptions(tokens))
}
if topCommand == "devices" {
guard tokens.first == "list" else { throw CLIParseError.unknownCommand((["devices"] + tokens).joined(separator: " ")) }
tokens.removeFirst()
return .devicesList(try parseDevicesListOptions(tokens))
}
if topCommand == "drive" {
guard let driveCommand = tokens.first else { throw CLIParseError.unknownCommand("drive") }
tokens.removeFirst()
Expand Down Expand Up @@ -259,6 +307,48 @@ public struct CLIParser: Sendable {
return options
}

private func parseStorageStatusOptions(_ tokens: [String]) throws -> StorageStatusOptions {
var options = StorageStatusOptions(); 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 "--cache-file": options.cacheFile = try parseURL(after: token, in: tokens, at: &index)
default: throw CLIParseError.unknownCommand(token)
}
index += 1
}
return options
}

private func parseFocusStatusOptions(_ tokens: [String]) throws -> FocusStatusOptions {
var options = FocusStatusOptions(); 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 "--focus-dir": options.focusDirectory = try parseURL(after: token, in: tokens, at: &index)
default: throw CLIParseError.unknownCommand(token)
}
index += 1
}
return options
}

private func parseDevicesListOptions(_ tokens: [String]) throws -> DevicesListOptions {
var options = DevicesListOptions(); 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 "--cache-file": options.cacheFile = try parseURL(after: token, in: tokens, at: &index)
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 @@ -299,6 +389,9 @@ public enum CLIHelp {
icloud-cli \(version)

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 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 @@ -309,6 +402,9 @@ Usage:
icloud-cli safari cloud-tabs probe [--format json|text] [--safari-dir PATH]

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.
drive list List files under the local iCloud Drive root without reading file contents.
drive containers
List top-level iCloud app containers.
Expand Down
62 changes: 62 additions & 0 deletions Sources/ICloudCLICore/CommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public struct CommandRunner: Sendable {
let report = CloudTabsProbe(safariDirectory: options.safariDirectory).probe()
output(try render(report, format: options.format))
return 0
case .devicesList(let options):
let devices = try ICloudDevicesReader(cacheFile: options.cacheFile).listDevices()
output(try render(devices, format: options.format))
return 0
case .driveContainers(let options):
let containers = try ICloudDriveInventoryReader(rootDirectory: options.rootDirectory).listContainers(sortBy: options.sortBy)
output(try render(containers, format: options.format))
Expand All @@ -37,6 +41,10 @@ public struct CommandRunner: Sendable {
let files = try ICloudDriveInventoryReader(rootDirectory: options.rootDirectory).listFiles(path: options.path, depth: options.depth)
output(try render(files, format: options.format))
return 0
case .focusStatus(let options):
let status = try FocusStatusReader(focusDirectory: options.focusDirectory).readStatus()
output(try render(status, 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 @@ -57,6 +65,10 @@ public struct CommandRunner: Sendable {
let shortcuts = try ShortcutsInventoryReader(shortcutsDirectory: options.shortcutsDirectory).listShortcuts(namePattern: options.namePattern)
output(try render(shortcuts, format: options.format))
return 0
case .storageStatus(let options):
let status = try ICloudStorageStatusReader(cacheFile: options.cacheFile).readStatus()
output(try render(status, format: options.format))
return 0
}
} catch {
errorOutput(error.localizedDescription)
Expand Down Expand Up @@ -171,6 +183,56 @@ public struct CommandRunner: Sendable {
}
}

public func render(_ status: ICloudStorageStatus, format: OutputFormat) throws -> String {
switch format {
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
return String(decoding: try encoder.encode(status), as: UTF8.self)
case .text:
let used = Double(status.usedBytes) / 1_000_000_000
let total = Double(status.totalBytes) / 1_000_000_000
let available = Double(status.availableBytes) / 1_000_000_000
let account = status.accountEmail ?? "unknown account"
return String(format: "iCloud storage: %.1f GB used of %.1f GB, %.1f GB available (%@)", used, total, available, account)
}
}

public func render(_ status: FocusStatus, format: OutputFormat) throws -> String {
switch format {
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
return String(decoding: try encoder.encode(status), as: UTF8.self)
case .text:
guard let active = status.activeFocus else { return "Focus: none" }
if let endsAt = status.endsAt {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return "Focus: \(active) (until \(formatter.string(from: endsAt)))"
}
return "Focus: \(active)"
}
}

public func render(_ devices: [ICloudDevice], format: OutputFormat) throws -> String {
switch format {
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
return String(decoding: try encoder.encode(devices), as: UTF8.self)
case .text:
return devices.map { device in
let current = device.isCurrentDevice ? " current" : ""
let os = device.osVersion.map { " (\($0))" } ?? ""
return "\(device.name) - \(device.model)\(os)\(current)"
}.joined(separator: "\n")
}
}

public func render(_ report: CloudTabsProbeReport, format: OutputFormat) throws -> String {
switch format {
case .json:
Expand Down
Loading
Loading