Files
voice-cat/clients/apple/iOS/VoiceCatiOS/ServerListStore.swift
Talon e26e7db5b1 feat(ios): ship iOS SwiftUI client (VoiceCatiOS)
Full SwiftUI app at clients/apple/iOS/VoiceCatiOS.xcodeproj:
- 24 Swift source files: AppState + SessionState (@Observable @MainActor),
  AudioSessionManager (AVAudioSession owner + interruption/route handling),
  ServerListStore/SavedServer (App Group container + Keychain sharing),
  and 14 SwiftUI views covering the full feature set
- NavigationSplitView on iPad, TabView on iPhone (horizontalSizeClass)
- Channel tree via OutlineGroup, user list with context menu admin actions
- PTT via DragGesture(minimumDistance: 0) + @GestureState
- onEvent closures hop to MainActor via Task { @MainActor in ... }
- App Group: group.cat.voice.VoiceCat (shared with future ReplayKit extension)

C ABI: add vc_audio_suspend / vc_audio_resume (AudioEngine::suspend/resume)
called by AudioSessionManager on AVAudioSession interruption events.

XCFramework: add ios-arm64 and ios-arm64-simulator slices to build-xcframework.sh;
Package.swift gains .iOS(.v17) platform; CMakePresets.json adds apple-ios /
apple-ios-sim presets with arm64-ios / arm64-ios-simulator vcpkg triplets.

Verified: xcodebuild -target VoiceCatiOS -sdk iphonesimulator26.5 BUILD SUCCEEDED.
2026-06-19 02:10:25 +02:00

107 lines
3.7 KiB
Swift

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)
}
// MARK: - Broadcast credentials (shared with ReplayKit extension)
func writeBroadcastCredentials(server: SavedServer, nickname: String?) {
let creds = BroadcastCredentials(
host: server.host,
port: Int(server.port),
authMode: server.authMode.rawValue,
username: server.authMode == .password ? server.savedUsername : (nickname ?? ""),
tofuPinsPath: tofuStorePath
)
guard let data = try? JSONEncoder().encode(creds) else { return }
let url = voicecatDir.appendingPathComponent("broadcast_credentials.json")
try? data.write(to: url, options: .atomic)
}
}