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:
174
clients/apple/iOS/VoiceCatiOS/AppState.swift
Normal file
174
clients/apple/iOS/VoiceCatiOS/AppState.swift
Normal file
@@ -0,0 +1,174 @@
|
||||
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.savedUsername.isEmpty ? "iOS User" : server.savedUsername
|
||||
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?.client.disconnect()
|
||||
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)
|
||||
ServerListStore.shared.writeBroadcastCredentials(server: server, nickname: nil)
|
||||
connectingClient = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
self.session = newSession
|
||||
} else {
|
||||
connectStatus = "Auth failed: \(ev.result.description)"
|
||||
showPasswordPrompt = true
|
||||
}
|
||||
case .disconnected:
|
||||
if session == nil { cancelConnect() }
|
||||
else { session = nil; isConnecting = false }
|
||||
case .error:
|
||||
connectStatus = ev.text ?? "Unknown error"
|
||||
if session == nil { isConnecting = false }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user