import Foundation import Security final class ServerListStore { static let shared = ServerListStore() private let groupId = "group.cat.voice.VoiceCat" private let keychainService = "cat.voice.VoiceCatiOS" // MARK: - App Group Container var appGroupContainer: URL { guard let url = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: groupId) else { // Fall back to app-only support dir if App Groups are unavailable (e.g. simulator // without entitlements). TOFU pins won't be shared with the broadcast extension, // but connect + auth still work. return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! } return url } private var voicecatDir: URL { let dir = appGroupContainer.appendingPathComponent("voicecat", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir } var tofuStorePath: String { voicecatDir.appendingPathComponent("tofu_pins.txt").path } private var serversURL: URL { voicecatDir.appendingPathComponent("servers.json") } // MARK: - Server list persistence func load() -> [SavedServer] { guard let data = try? Data(contentsOf: serversURL), let list = try? JSONDecoder().decode([SavedServer].self, from: data) else { return [] } return list } func save(_ servers: [SavedServer]) { guard let data = try? JSONEncoder().encode(servers) else { return } try? data.write(to: serversURL, options: .atomic) } // MARK: - Keychain func savePassword(_ password: String, tag: String) { let data = Data(password.utf8) let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: keychainService, kSecAttrAccount: tag, kSecAttrAccessGroup: groupId, ] SecItemDelete(query as CFDictionary) var add = query add[kSecValueData] = data SecItemAdd(add as CFDictionary, nil) } func loadPassword(tag: String) -> String? { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: keychainService, kSecAttrAccount: tag, kSecAttrAccessGroup: groupId, kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, ] var result: AnyObject? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } func deletePassword(tag: String) { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: keychainService, kSecAttrAccount: tag, kSecAttrAccessGroup: groupId, ] SecItemDelete(query as CFDictionary) } }