Skip to content

Instantly share code, notes, and snippets.

@hewigovens
Created April 18, 2025 22:52
Show Gist options
  • Save hewigovens/766a8ecacd176b82c6f6fdb0c623bfab to your computer and use it in GitHub Desktop.
Save hewigovens/766a8ecacd176b82c6f6fdb0c623bfab to your computer and use it in GitHub Desktop.
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