this repo has no description
1import Foundation
2
3#if canImport(UIKit)
4import UIKit
5#endif
6
7/// Metadata about a completed backup, saved to track backup history and device information
8struct BackupMetadata: Codable {
9 /// When the backup completed
10 let completedAt: Date
11
12 /// Start time of the backup
13 let startedAt: Date
14
15 /// Duration of the backup in seconds
16 var duration: TimeInterval {
17 completedAt.timeIntervalSince(startedAt)
18 }
19
20 // MARK: - Account Information
21
22 /// The account's DID (Decentralized Identifier)
23 let did: String
24
25 /// The account's handle (e.g., user.bsky.social)
26 let handle: String
27
28 /// PDS (Personal Data Server) URL used for this backup
29 let pds: String
30
31 // MARK: - Backup Information
32
33 /// Name of the .car file containing the repository snapshot
34 let carFileName: String
35
36 /// Total number of blobs in this backup
37 let totalBlobs: Int
38
39 /// Number of blobs that were newly downloaded (vs already existed)
40 let newBlobsDownloaded: Int
41
42 /// Device information
43 let deviceInfo: DeviceInfo
44
45 /// Format the duration as a human-readable string
46 var formattedDuration: String {
47 let formatter = DateComponentsFormatter()
48 formatter.allowedUnits = [.hour, .minute, .second]
49 formatter.unitsStyle = .abbreviated
50 formatter.zeroFormattingBehavior = .dropAll
51 return formatter.string(from: duration) ?? "\(Int(duration))s"
52 }
53
54 struct DeviceInfo: Codable {
55 /// Device model (e.g., "iPhone 15 Pro", "Mac")
56 let model: String
57
58 /// OS version (e.g., "iOS 18.0", "macOS 15.0")
59 let osVersion: String
60
61 /// App version
62 let appVersion: String
63
64 /// User's device name (e.g., "Corey's iPhone")
65 let deviceName: String
66
67 #if os(iOS)
68 /// Battery level at backup completion (0.0-1.0), iOS only
69 let batteryLevel: Float?
70
71 /// Whether device was charging during backup, iOS only
72 let wasCharging: Bool?
73 #endif
74
75 /// Unique device identifier (for tracking which device performed backup)
76 let deviceIdentifier: String
77
78 static func current() -> DeviceInfo {
79 #if os(iOS)
80 let device = UIDevice.current
81 let batteryMonitoringWasEnabled = device.isBatteryMonitoringEnabled
82 device.isBatteryMonitoringEnabled = true
83
84 let batteryLevel: Float? = device.batteryState != .unknown ? device.batteryLevel : nil
85 let wasCharging: Bool? = device.batteryState == .charging || device.batteryState == .full
86
87 // Restore battery monitoring state
88 device.isBatteryMonitoringEnabled = batteryMonitoringWasEnabled
89
90 let model = Self.deviceModelName()
91 let osVersion = "iOS \(device.systemVersion)"
92 let deviceName = device.name
93 #else
94 let model = Self.macModelName()
95 let osVersion = Self.macOSVersion()
96 let deviceName = Host.current().localizedName ?? "Mac"
97 let batteryLevel: Float? = nil
98 let wasCharging: Bool? = nil
99 #endif
100
101 let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
102
103 #if os(iOS)
104 let deviceIdentifier = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
105
106 return DeviceInfo(
107 model: model,
108 osVersion: osVersion,
109 appVersion: appVersion,
110 deviceName: deviceName,
111 batteryLevel: batteryLevel,
112 wasCharging: wasCharging,
113 deviceIdentifier: deviceIdentifier
114 )
115 #else
116 // For macOS, generate a stable identifier based on hardware UUID
117 let deviceIdentifier = Self.macDeviceIdentifier()
118
119 return DeviceInfo(
120 model: model,
121 osVersion: osVersion,
122 appVersion: appVersion,
123 deviceName: deviceName,
124 deviceIdentifier: deviceIdentifier
125 )
126 #endif
127 }
128
129 #if os(iOS)
130 private static func deviceModelName() -> String {
131 var systemInfo = utsname()
132 uname(&systemInfo)
133 let machineMirror = Mirror(reflecting: systemInfo.machine)
134 let identifier = machineMirror.children.reduce("") { identifier, element in
135 guard let value = element.value as? Int8, value != 0 else { return identifier }
136 return identifier + String(UnicodeScalar(UInt8(value)))
137 }
138
139 // Map common identifiers to friendly names
140 let modelMap: [String: String] = [
141 "iPhone14,2": "iPhone 13 Pro",
142 "iPhone14,3": "iPhone 13 Pro Max",
143 "iPhone14,4": "iPhone 13 mini",
144 "iPhone14,5": "iPhone 13",
145 "iPhone15,2": "iPhone 14 Pro",
146 "iPhone15,3": "iPhone 14 Pro Max",
147 "iPhone15,4": "iPhone 15",
148 "iPhone15,5": "iPhone 15 Plus",
149 "iPhone16,1": "iPhone 15 Pro",
150 "iPhone16,2": "iPhone 15 Pro Max",
151 ]
152
153 return modelMap[identifier] ?? identifier
154 }
155 #else
156 private static func macModelName() -> String {
157 var size = 0
158 sysctlbyname("hw.model", nil, &size, nil, 0)
159 var model = [CChar](repeating: 0, count: size)
160 sysctlbyname("hw.model", &model, &size, nil, 0)
161 return String(cString: model)
162 }
163
164 private static func macOSVersion() -> String {
165 let version = ProcessInfo.processInfo.operatingSystemVersion
166 return "macOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
167 }
168
169 private static func macDeviceIdentifier() -> String {
170 // Try to get hardware UUID via IOKit
171 var size = 0
172 sysctlbyname("kern.uuid", nil, &size, nil, 0)
173 var uuid = [CChar](repeating: 0, count: size)
174 sysctlbyname("kern.uuid", &uuid, &size, nil, 0)
175 return String(cString: uuid)
176 }
177 #endif
178 }
179
180 /// Save metadata to a JSON file
181 func save(to directory: URL) throws {
182 let fileURL = directory.appendingPathComponent("backup-metadata.json")
183 let encoder = JSONEncoder()
184 encoder.dateEncodingStrategy = .iso8601
185 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
186
187 let data = try encoder.encode(self)
188 try data.write(to: fileURL)
189 }
190
191 /// Load metadata from a JSON file
192 static func load(from directory: URL) throws -> BackupMetadata? {
193 let fileURL = directory.appendingPathComponent("backup-metadata.json")
194
195 guard FileManager.default.fileExists(atPath: fileURL.path) else {
196 return nil
197 }
198
199 let data = try Data(contentsOf: fileURL)
200 let decoder = JSONDecoder()
201 decoder.dateDecodingStrategy = .iso8601
202
203 return try decoder.decode(BackupMetadata.self, from: data)
204 }
205}