import AppKit @MainActor struct ColorScheme { var background: NSColor var text: NSColor var secondaryText: NSColor var accent: NSColor var glow: NSColor var keyBackground: NSColor var error: NSColor static let sunset = ColorScheme( background: NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 1), text: NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1), secondaryText: NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1), accent: NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1), glow: NSColor(srgbRed: 0.76, green: 0.27, blue: 0.18, alpha: 1), keyBackground: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 1), error: .systemRed ) static var configurationDirectory: URL { let base: URL if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], xdg.hasPrefix("/") { base = URL(fileURLWithPath: xdg, isDirectory: true) } else { base = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config", isDirectory: true) } return base.appendingPathComponent("conjure", isDirectory: true) } static func load(from directory: URL) throws -> ColorScheme { let configURL = directory.appendingPathComponent("config.json") guard let config = try readObject(at: configURL, missingAllowed: true) else { return sunset } try validateKeys(config, allowed: ["theme", "colors"], at: configURL, prefix: "") let theme: String if let value = config["theme"] { guard let name = value as? String, ["sunset", "tint"].contains(name) else { throw ColorSchemeError(path: configURL.path, reason: "theme must be \"sunset\" or \"tint\".") } theme = name } else { theme = "sunset" } var result = sunset if theme == "tint" { let themeURL = directory.appendingPathComponent("theme.json") if let generated = try readObject(at: themeURL, missingAllowed: false) { try validateKeys(generated, allowed: ["colors"], at: themeURL, prefix: "") try result.applyColors(from: generated, at: themeURL) } } try result.applyColors(from: config, at: configURL) return result } private static let colorFields: [String: WritableKeyPath] = [ "background": \.background, "text": \.text, "secondaryText": \.secondaryText, "accent": \.accent, "glow": \.glow, "keyBackground": \.keyBackground, "error": \.error, ] private mutating func applyColors(from object: [String: Any], at url: URL) throws { guard let value = object["colors"] else { return } guard let colors = value as? [String: Any] else { throw ColorSchemeError(path: url.path, reason: "colors must be an object.") } try Self.validateKeys(colors, allowed: Set(Self.colorFields.keys), at: url, prefix: "colors.") for (name, value) in colors { guard let hex = value as? String, hex.utf8.count == 7, hex.first == "#", hex.utf8.dropFirst().allSatisfy({ (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }), let rgb = UInt32(hex.dropFirst(), radix: 16) else { throw ColorSchemeError(path: url.path, reason: "colors.\(name) must be #RRGGBB ASCII hex (no alpha).") } if let field = Self.colorFields[name] { self[keyPath: field] = NSColor( srgbRed: CGFloat((rgb >> 16) & 0xff) / 255, green: CGFloat((rgb >> 8) & 0xff) / 255, blue: CGFloat(rgb & 0xff) / 255, alpha: 1 ) } } } private static func validateKeys(_ object: [String: Any], allowed: Set, at url: URL, prefix: String) throws { if let key = object.keys.sorted().first(where: { !allowed.contains($0) }) { throw ColorSchemeError(path: url.path, reason: "Unknown key \"\(prefix)\(key)\".") } } private static func readObject(at url: URL, missingAllowed: Bool) throws -> [String: Any]? { let data: Data do { data = try Data(contentsOf: url) } catch { let cocoa = error as NSError if missingAllowed, cocoa.domain == NSCocoaErrorDomain, cocoa.code == NSFileReadNoSuchFileError { return nil } throw ColorSchemeError(path: url.path, reason: error.localizedDescription) } let value: Any do { value = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) } catch { throw ColorSchemeError(path: url.path, reason: "Invalid JSON: \(error.localizedDescription)") } guard let object = value as? [String: Any] else { throw ColorSchemeError(path: url.path, reason: "Expected a JSON object.") } return object } } private struct ColorSchemeError: LocalizedError { let path: String let reason: String var errorDescription: String? { "\(path): \(reason)" } }