Created
April 18, 2025 22:52
-
-
Save hewigovens/766a8ecacd176b82c6f6fdb0c623bfab to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
// MARK: - Errors | |
public enum BGIParserError: Error { | |
case fileNotFound(String) | |
case readFailed(Error) | |
case propertyListError(Error) | |
case unsupportedFormat | |
} | |
// MARK: - Internal Helpers | |
func isUID(_ obj: Any) -> Bool { | |
String(describing: obj).hasPrefix("<CFKeyedArchiverUID") | |
} | |
func uidValue(_ obj: Any) -> Int? { | |
let desc = String(describing: obj) | |
guard let range = desc.range(of: "value = ") else { return nil } | |
let remainder = desc[range.upperBound...] | |
let digits = remainder.prefix { "0123456789".contains($0) } | |
return Int(digits) | |
} | |
func resolveAny(_ obj: Any, objects: [Any]) -> Any { | |
if isUID(obj), let idx = uidValue(obj), idx >= 0, idx < objects.count { | |
return resolveAny(objects[idx], objects: objects) | |
} | |
if let arr = obj as? [Any] { | |
return arr.map { resolveAny($0, objects: objects) } | |
} | |
if let dict = obj as? [AnyHashable: Any] { | |
if let keys = dict["NS.keys"] as? [Any], | |
let vals = dict["NS.objects"] as? [Any], | |
keys.count == vals.count { | |
var map = [String: Any]() | |
for i in keys.indices { | |
let key = resolveAny(keys[i], objects: objects) | |
let val = resolveAny(vals[i], objects: objects) | |
if let kstr = key as? String { | |
map[kstr] = val | |
} | |
} | |
return map | |
} | |
var map = [String: Any]() | |
for (kAny, vAny) in dict { | |
let key = String(describing: kAny) | |
if key == "$class" { continue } | |
map[key] = resolveAny(vAny, objects: objects) | |
} | |
return map | |
} | |
return obj | |
} | |
func deserializeKeyedArchive(_ plist: [AnyHashable: Any]) throws -> [Any] { | |
guard let top = plist["$top"] as? [AnyHashable: Any], | |
let objects = plist["$objects"] as? [Any] else { | |
throw BGIParserError.unsupportedFormat | |
} | |
var result = [Any]() | |
// version | |
if let ver = top["version"] as? Int { | |
result.append(["version": ver]) | |
} else if let verObj = top["version"], isUID(verObj), | |
let vidx = uidValue(verObj), | |
let num = objects[vidx] as? NSNumber { | |
result.append(["version": num.intValue]) | |
} else { | |
throw BGIParserError.unsupportedFormat | |
} | |
// store | |
guard let storePtr = top["store"], isUID(storePtr), let sidx = uidValue(storePtr) else { | |
throw BGIParserError.unsupportedFormat | |
} | |
let storeDecoded = resolveAny(objects[sidx], objects: objects) | |
result.append(["store": storeDecoded]) | |
return result | |
} | |
// MARK: - Version 3 Parser | |
public func parseVersion3(_ array: [Any]) -> [String: [[String: Any]]] { | |
var results = [String: [[String: Any]]]() | |
guard array.count > 1, | |
let second = array[1] as? [String: Any], | |
let store = second["store"] as? [String: Any], | |
let byUser = store["itemsByUserIdentifier"] as? [String: Any] else { | |
return results | |
} | |
for (uuid, raw) in byUser { | |
var itemsRaw: [Any] = [] | |
if let arr = raw as? [Any] { | |
itemsRaw = arr | |
} else if let dictRaw = raw as? [String: Any], let arr = dictRaw["NS.objects"] as? [Any] { | |
itemsRaw = arr | |
} else { | |
continue | |
} | |
var list = [[String: Any]]() | |
for case let item as [String: Any] in itemsRaw { | |
var entry = [String: Any]() | |
for (k, v) in item { | |
if k == "bookmark" || k == "lightweightRequirement" { continue } | |
// uuid | |
if k == "uuid", | |
let dictV = v as? [String: Any], | |
let dataV = dictV["NS.uuidbytes"] as? Data, dataV.count == 16 { | |
let val = UUID(uuid: ( | |
dataV[0], dataV[1], dataV[2], dataV[3], | |
dataV[4], dataV[5], dataV[6], dataV[7], | |
dataV[8], dataV[9], dataV[10], dataV[11], | |
dataV[12], dataV[13], dataV[14], dataV[15] | |
)) | |
entry[k] = val.uuidString | |
continue | |
} | |
// sha256 | |
if let dataVal = v as? Data, k == "sha256" { | |
entry[k] = dataVal.map { String(format: "%02x", $0) }.joined() | |
continue | |
} | |
// items | |
if let dictV = v as? [String: Any], let arr = dictV["NS.objects"] as? [Any] { | |
entry[k] = arr.compactMap { $0 as? String } | |
continue | |
} | |
// url | |
if k == "url", let dictV = v as? [String: Any] { | |
if let rel = dictV["NS.relative"] as? String, !rel.isEmpty { | |
entry[k] = rel | |
} else if let base = dictV["NS.base"] as? String, !base.isEmpty { | |
entry[k] = base | |
} | |
continue | |
} | |
// skip other Data | |
if v is Data { continue } | |
// primitives | |
if let s = v as? String { | |
entry[k] = (s == "$null" ? "" : s) | |
} else if let num = v as? NSNumber { | |
entry[k] = num | |
} else if let sArr = v as? [String] { | |
entry[k] = sArr.map { $0 == "$null" ? "" : $0 } | |
} else { | |
entry[k] = v | |
} | |
} | |
if !entry.isEmpty { | |
list.append(entry) | |
} | |
} | |
if !list.isEmpty { | |
results[uuid] = list | |
} | |
} | |
return results | |
} | |
// MARK: - Public API | |
/// Parses a BackgroundItems-v*.btm file (macOS 13+) and returns per-user login items | |
public func parseBackgroundItems(filePath: String) throws -> [String: [[String: Any]]] { | |
let url = URL(fileURLWithPath: filePath) | |
guard FileManager.default.fileExists(atPath: url.path) else { | |
throw BGIParserError.fileNotFound(filePath) | |
} | |
let data: Data | |
do { data = try Data(contentsOf: url) } catch { throw BGIParserError.readFailed(error) } | |
var format = PropertyListSerialization.PropertyListFormat.binary | |
let plistAny: Any | |
do { plistAny = try PropertyListSerialization.propertyList(from: data, options: [], format: &format) } | |
catch { throw BGIParserError.propertyListError(error) } | |
guard let dict = plistAny as? [AnyHashable: Any] else { | |
throw BGIParserError.unsupportedFormat | |
} | |
let keyed = try deserializeKeyedArchive(dict) | |
guard let first = keyed.first as? [String: Any], let ver = first["version"] as? Int, ver >= 3 else { | |
throw BGIParserError.unsupportedFormat | |
} | |
return parseVersion3(keyed) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment