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.
This commit is contained in:
252
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
252
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
@@ -0,0 +1,252 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import VoiceCatCore
|
||||
|
||||
// MARK: - Helper types
|
||||
|
||||
struct ChatMessage: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let senderName: String
|
||||
let text: String
|
||||
let scope: VoiceCatTextScope
|
||||
}
|
||||
|
||||
struct ActivityEntry: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct VoiceState {
|
||||
var micActive = false
|
||||
var selfMuted = false
|
||||
var selfDeafened = false
|
||||
var inputMode: VoiceCatInputMode = .voiceActivation
|
||||
var vadThreshold: Float = 0.025
|
||||
var level: Float = 0.0
|
||||
var currentDeviceId: String?
|
||||
var localStreamId: UInt32 = 0
|
||||
}
|
||||
|
||||
// MARK: - SessionState
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SessionState {
|
||||
let client: VoiceCatClient
|
||||
let selfUserId: UInt32
|
||||
|
||||
var channels: [Channel] = []
|
||||
var users: [User] = []
|
||||
var currentChannelId: UInt32 = 0
|
||||
var messages: [ChatMessage] = []
|
||||
var activityLog: [ActivityEntry] = []
|
||||
var voiceState = VoiceState()
|
||||
var permissions: Permissions
|
||||
var accounts: [Account] = []
|
||||
var devices: [Device] = []
|
||||
|
||||
init(client: VoiceCatClient, selfUserId: UInt32, permissions: Permissions) {
|
||||
self.client = client
|
||||
self.selfUserId = selfUserId
|
||||
self.permissions = permissions
|
||||
AudioSessionManager.shared.client = client
|
||||
refreshChannels()
|
||||
refreshUsers()
|
||||
refreshDevices()
|
||||
client.onEvent = { [weak self] ev in
|
||||
Task { @MainActor [weak self] in self?.handleEvent(ev) }
|
||||
}
|
||||
client.onLevel = { [weak self] _, rms in
|
||||
Task { @MainActor [weak self] in self?.voiceState.level = rms }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
MainActor.assumeIsolated {
|
||||
AudioSessionManager.shared.client = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event dispatch
|
||||
|
||||
func handleEvent(_ ev: VoiceCatEvent) {
|
||||
switch ev.type {
|
||||
case .channelList:
|
||||
refreshChannels()
|
||||
case .userJoined, .userLeft, .userUpdated:
|
||||
refreshUsers()
|
||||
case .textMessage:
|
||||
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
|
||||
messages.append(ChatMessage(
|
||||
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
|
||||
senderName: sender,
|
||||
text: ev.text ?? "",
|
||||
scope: ev.textScope))
|
||||
case .talkState:
|
||||
let talking = ev.u32a != 0
|
||||
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
|
||||
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
|
||||
case .streamStarted:
|
||||
addActivity("Stream started (user \(ev.userId))")
|
||||
case .streamStopped:
|
||||
addActivity("Stream stopped (user \(ev.userId))")
|
||||
case .joinResult:
|
||||
if ev.result == .ok {
|
||||
currentChannelId = ev.channelId
|
||||
addActivity("Joined channel")
|
||||
refreshUsers()
|
||||
} else {
|
||||
addActivity("Join failed: \(ev.result.description)")
|
||||
}
|
||||
case .error:
|
||||
addActivity("Error: \(ev.text ?? ev.result.description)")
|
||||
case .genericResult:
|
||||
if ev.result != .ok {
|
||||
addActivity("Operation failed: \(ev.result.description)")
|
||||
}
|
||||
case .accountList:
|
||||
accounts = client.listAccounts()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func addActivity(_ text: String) {
|
||||
activityLog.append(ActivityEntry(timestamp: Date(), text: text))
|
||||
if activityLog.count > 500 { activityLog.removeFirst() }
|
||||
}
|
||||
|
||||
// MARK: - Data refresh
|
||||
|
||||
func refreshChannels() { channels = client.listChannels() }
|
||||
func refreshUsers() { users = client.listUsers() }
|
||||
func refreshDevices() { devices = client.listDevices(.input) }
|
||||
|
||||
// MARK: - Voice controls
|
||||
|
||||
func joinChannel(_ channelId: UInt32, password: String = "") {
|
||||
client.joinChannel(channelId, password: password.isEmpty ? nil : password)
|
||||
}
|
||||
|
||||
func leaveChannel() {
|
||||
client.leaveChannel()
|
||||
currentChannelId = 0
|
||||
}
|
||||
|
||||
func startMicStream() {
|
||||
AVAudioApplication.requestRecordPermission { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if granted {
|
||||
self.doStartMicStream()
|
||||
} else {
|
||||
self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func doStartMicStream() {
|
||||
do {
|
||||
try AudioSessionManager.shared.activateForStreaming()
|
||||
} catch {
|
||||
addActivity("AVAudioSession activate failed: \(error)")
|
||||
return
|
||||
}
|
||||
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic")
|
||||
let (result, streamId) = client.startStream(desc)
|
||||
if result == .ok {
|
||||
voiceState.micActive = true
|
||||
voiceState.localStreamId = streamId
|
||||
} else {
|
||||
addActivity("Failed to start mic: \(result.description)")
|
||||
}
|
||||
}
|
||||
|
||||
func stopMicStream() {
|
||||
if voiceState.localStreamId != 0 {
|
||||
client.stopStream(voiceState.localStreamId)
|
||||
voiceState.localStreamId = 0
|
||||
}
|
||||
voiceState.micActive = false
|
||||
voiceState.level = 0
|
||||
AudioSessionManager.shared.deactivateAfterStreaming()
|
||||
}
|
||||
|
||||
func setMute(_ muted: Bool, deafened: Bool) {
|
||||
client.setSelfMute(micMuted: muted, deafened: deafened)
|
||||
voiceState.selfMuted = muted
|
||||
voiceState.selfDeafened = deafened
|
||||
}
|
||||
|
||||
func setInputMode(_ mode: VoiceCatInputMode) {
|
||||
client.setInputMode(mode)
|
||||
voiceState.inputMode = mode
|
||||
}
|
||||
|
||||
func setVadThreshold(_ threshold: Float) {
|
||||
client.setVadThreshold(threshold)
|
||||
voiceState.vadThreshold = threshold
|
||||
}
|
||||
|
||||
func setPushToTalk(_ active: Bool) {
|
||||
client.setPushToTalk(active)
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
func sendText(_ text: String, scope: VoiceCatTextScope, targetId: UInt32 = 0) {
|
||||
client.sendText(scope: scope, targetId: targetId, text: text)
|
||||
}
|
||||
|
||||
// MARK: - Admin
|
||||
|
||||
func kickUser(_ userId: UInt32, reason: String) {
|
||||
client.kickUser(userId, reason: reason.isEmpty ? nil : reason)
|
||||
}
|
||||
|
||||
func banUser(_ userId: UInt32, reason: String, expiresUnixMs: UInt64) {
|
||||
client.banUser(userId, reason: reason.isEmpty ? nil : reason, expiresUnixMs: expiresUnixMs)
|
||||
}
|
||||
|
||||
func moveUser(_ userId: UInt32, toChannel channelId: UInt32) {
|
||||
client.moveUser(userId, toChannel: channelId)
|
||||
}
|
||||
|
||||
func setPermissions(_ userId: UInt32, perms: Permissions) {
|
||||
client.setPermission(userId, perms: perms)
|
||||
}
|
||||
|
||||
func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) {
|
||||
client.setServerMute(userId, muted: muted, deafened: deafened)
|
||||
}
|
||||
|
||||
func createChannel(name: String, topic: String) {
|
||||
let info = ChannelEdit(id: 0, parentId: 0, name: name, topic: topic,
|
||||
passwordProtected: false, password: nil,
|
||||
maxUsers: 0, sortOrder: 0, audio: AudioConfig())
|
||||
client.createChannel(info)
|
||||
}
|
||||
|
||||
func deleteChannel(_ channelId: UInt32) {
|
||||
client.deleteChannel(channelId)
|
||||
}
|
||||
|
||||
func fetchAccountList() {
|
||||
client.requestAccountList()
|
||||
}
|
||||
|
||||
func createAccount(username: String, password: String) {
|
||||
client.createAccount(username, password: password)
|
||||
}
|
||||
|
||||
func deleteAccount(username: String) {
|
||||
client.deleteAccount(username)
|
||||
}
|
||||
|
||||
func resetPassword(username: String, newPassword: String) {
|
||||
client.resetPassword(username, newPassword: newPassword)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user