Files
voice-cat/clients/apple/iOS/VoiceCatiOS/AppState.swift
Talon 50416c33a2 feat(clients): event sound effects + optional text-to-speech
Add audible cues and optional spoken announcements for session events
(join/leave, channel + PM sent/recv, login, logout/connection-lost,
mic on/off, voice-activity, PTT) across all three clients, driven off
the shared C ABI vc_event stream so the mapping stays consistent.

TTS is off by default; when enabled it announces events and reads
message/PM bodies aloud. Master toggles + a sound-volume slider; the
per-utterance voice-activity and PTT cues default off. WAVs ship from
assets/sounds/.

Windows (built + verified): new VoiceCat.App/Notifications/ layer
(FeedbackSettings -> %AppData%\VoiceCat\feedback.json, SoundPlayerPool
via System.Media.SoundPlayer, SpeechAnnouncer via Prismatoid 0.3.0,
EventFeedback dispatcher); MainForm hooks; NotificationSettingsForm
under Settings > Notifications; csproj adds the Prismatoid PackageRef
and copies the WAVs into sounds\.

macOS + iOS (written, not yet built -- needs a Mac): shared
VoiceCatCore/Feedback/ (SoundEvent, EventFeedback = AVAudioPlayer pool
+ native AVSpeechSynthesizer, FeedbackSettings over UserDefaults); WAVs
bundled via Package.swift resources (.process). Hooks in SessionState/
AppState (iOS) and MainWindowController (macOS); settings UI in
SettingsView (iOS) and SettingsWindowController (macOS).

No core/server code touched; ctest --preset dev unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:20:31 +02:00

192 lines
6.9 KiB
Swift

import Foundation
import VoiceCatCore
struct PendingIdentity: Identifiable {
let id = UUID()
let displayText: String
let tofuStatus: VoiceCatTofuStatus
}
@Observable
@MainActor
final class AppState {
var servers: [SavedServer] = ServerListStore.shared.load()
var session: SessionState?
// Connect-flow state
var isConnecting = false
var connectStatus = ""
var showAddServer = false
var editingServer: SavedServer?
var showPasswordPrompt = false
var pendingIdentity: PendingIdentity?
private var connectingClient: VoiceCatClient?
private(set) var connectingServer: SavedServer?
private var identityHandled = false
// MARK: - Server list management
func addServer(_ server: SavedServer, password: String?) {
if let pw = password, !pw.isEmpty {
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
}
servers.append(server)
ServerListStore.shared.save(servers)
}
func updateServer(_ server: SavedServer, password: String?) {
if let pw = password, !pw.isEmpty {
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
}
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
servers[idx] = server
}
ServerListStore.shared.save(servers)
}
func removeServer(_ server: SavedServer) {
ServerListStore.shared.deletePassword(tag: server.keychainTag)
servers.removeAll(where: { $0.id == server.id })
ServerListStore.shared.save(servers)
}
// MARK: - Connect flow
func connectTo(_ server: SavedServer) {
guard !isConnecting else { return }
isConnecting = true
connectStatus = "Connecting…"
connectingServer = server
identityHandled = false
let config = VoiceCatConfig(
clientName: "VoiceCat-iOS",
clientVersion: "0.0.1",
logLevel: .info,
tofuStorePath: ServerListStore.shared.tofuStorePath)
let client = VoiceCatClient(config: config)
connectingClient = client
client.onEvent = { [weak self] ev in
Task { @MainActor [weak self] in self?.handleConnectEvent(ev, server: server) }
}
client.connect(host: server.host, port: server.port)
// Auth is queued immediately the core serialises it behind TLS + TOFU.
switch server.authMode {
case .guest:
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
client.authenticateGuest(nick)
case .password:
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
if let pw = savedPw, !pw.isEmpty {
client.authenticateUser(server.savedUsername, password: pw)
} else {
showPasswordPrompt = true
}
}
}
func disconnect() {
session?.stopMicStream()
session?.client.disconnect()
AudioSessionManager.shared.deactivateSession()
session = nil
connectingClient?.disconnect()
connectingClient = nil
connectingServer = nil
isConnecting = false
connectStatus = ""
showPasswordPrompt = false
pendingIdentity = nil
}
// MARK: - Auth actions (called from prompt sheets)
func authenticateUser(username: String, password: String) {
connectingClient?.authenticateUser(username, password: password)
showPasswordPrompt = false
}
func confirmServerIdentity(accept: Bool) {
connectingClient?.confirmServerIdentity(accept: accept)
pendingIdentity = nil
if !accept { cancelConnect() }
}
func cancelConnect() {
connectingClient?.disconnect()
connectingClient = nil
connectingServer = nil
isConnecting = false
connectStatus = ""
showPasswordPrompt = false
pendingIdentity = nil
}
// MARK: - Connect event handler
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer) {
switch ev.type {
case .connectionState:
switch ev.connectionState {
case .connecting: connectStatus = "Connecting…"
case .tlsHandshake: connectStatus = "TLS handshake…"
case .authenticating: connectStatus = "Authenticating…"
case .verifyingIdentity: connectStatus = "Verifying server identity…"
case .connected: connectStatus = "Connected"
default: break
}
case .serverIdentity:
guard !identityHandled else { break }
let tofuStatus = ev.tofuStatus ?? .firstConnect
if tofuStatus == .matched {
connectingClient?.confirmServerIdentity(accept: true)
} else {
identityHandled = true
let displayText = connectingClient?.getServerIdentityDisplay() ?? ""
pendingIdentity = PendingIdentity(displayText: displayText, tofuStatus: tofuStatus)
}
case .authResult:
if ev.result == .ok {
guard let client = connectingClient else { break }
let perms = client.getPermissions()
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
connectingClient = nil
isConnecting = false
connectStatus = ""
showPasswordPrompt = false
self.session = newSession
EventFeedback.shared.play(.login)
EventFeedback.shared.speak("Connected")
// Activate the audio session now, while connected NOT lazily when the first
// remote stream arrives. The core opens its miniaudio playback device the moment
// a remote stream starts and only THEN emits .streamStarted; if we waited for
// that event to activate, the playback device would open against an inactive
// AVAudioSession and produce no sound (the "can't hear anyone" bug). Activating
// here guarantees the session is live before any device opens.
do {
try AudioSessionManager.shared.ensureSessionActive()
} catch {
print("Audio session activate on connect failed: \(error)")
}
} else {
connectStatus = "Auth failed: \(ev.result.description)"
showPasswordPrompt = true
}
case .disconnected:
if session == nil { cancelConnect() }
else {
AudioSessionManager.shared.deactivateSession()
session = nil; isConnecting = false
}
case .error:
connectStatus = ev.text ?? "Unknown error"
if session == nil { isConnecting = false }
default:
break
}
}
}