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:
2026-06-19 02:10:25 +02:00
parent dbf732ca91
commit e26e7db5b1
46 changed files with 2929 additions and 22 deletions

View File

@@ -0,0 +1,153 @@
import SwiftUI
import VoiceCatCore
struct AccountsView: View {
@Bindable var session: SessionState
@State private var showCreateAccount = false
@State private var newUsername = ""
@State private var newPassword = ""
@State private var accountToDelete: Account?
@State private var showResetPassword = false
@State private var resetForAccount: Account?
@State private var resetPassword = ""
var body: some View {
List {
ForEach(session.accounts, id: \.username) { account in
AccountRowView(account: account)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
accountToDelete = account
} label: {
Label("Delete", systemImage: "trash")
}
Button {
resetForAccount = account
showResetPassword = true
} label: {
Label("Reset PW", systemImage: "key")
}
.tint(.orange)
}
}
}
.navigationTitle("Accounts")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showCreateAccount = true
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Create account")
}
}
.refreshable {
session.fetchAccountList()
}
.onAppear {
session.fetchAccountList()
}
.confirmationDialog("Delete account?", isPresented: Binding(
get: { accountToDelete != nil },
set: { if !$0 { accountToDelete = nil } }
)) {
if let a = accountToDelete {
Button("Delete \(a.username)", role: .destructive) {
session.deleteAccount(username: a.username)
accountToDelete = nil
}
}
Button("Cancel", role: .cancel) { accountToDelete = nil }
}
.sheet(isPresented: $showCreateAccount) {
CreateAccountSheet(session: session)
}
.alert("Reset Password", isPresented: $showResetPassword) {
SecureField("New password", text: $resetPassword)
.accessibilityLabel("New password for account")
Button("Reset") {
if let a = resetForAccount, !resetPassword.isEmpty {
session.resetPassword(username: a.username, newPassword: resetPassword)
}
resetPassword = ""
resetForAccount = nil
}
Button("Cancel", role: .cancel) {
resetPassword = ""
resetForAccount = nil
}
} message: {
Text("Enter a new password for \(resetForAccount?.username ?? "").")
}
}
}
private struct AccountRowView: View {
let account: Account
private var joinedDate: String {
let date = Date(timeIntervalSince1970: Double(account.createdAtUnixMs) / 1000)
return date.formatted(.dateTime.year().month().day())
}
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(account.username)
.fontWeight(.medium)
if account.isAdmin {
Text("admin")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(.orange.opacity(0.2), in: Capsule())
.foregroundStyle(.orange)
}
}
Text("Created \(joinedDate)")
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(account.username)\(account.isAdmin ? ", administrator" : ""), created \(joinedDate)")
}
}
private struct CreateAccountSheet: View {
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
@State private var username = ""
@State private var password = ""
var body: some View {
NavigationStack {
Form {
Section {
TextField("Username", text: $username)
.textContentType(.username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Username")
SecureField("Password", text: $password)
.textContentType(.newPassword)
.accessibilityLabel("Password")
}
}
.navigationTitle("Create Account")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
session.createAccount(username: username, password: password)
dismiss()
}
.disabled(username.isEmpty || password.isEmpty)
}
}
}
}
}

View File

@@ -0,0 +1,49 @@
import SwiftUI
struct ActivityLogView: View {
@Bindable var session: SessionState
private static let timeFormatter: DateFormatter = {
let fmt = DateFormatter()
fmt.dateStyle = .none
fmt.timeStyle = .medium
return fmt
}()
var body: some View {
ScrollViewReader { proxy in
List(session.activityLog) { entry in
HStack(alignment: .top, spacing: 8) {
Text(Self.timeFormatter.string(from: entry.timestamp))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
.frame(width: 64, alignment: .leading)
Text(entry.text)
.font(.caption)
}
.id(entry.id)
.listRowSeparator(.hidden)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(Self.timeFormatter.string(from: entry.timestamp)): \(entry.text)")
}
.listStyle(.plain)
.onChange(of: session.activityLog.count) { _, _ in
if let last = session.activityLog.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
.navigationTitle("Activity")
.navigationBarTitleDisplayMode(.inline)
.overlay {
if session.activityLog.isEmpty {
ContentUnavailableView(
"No Activity",
systemImage: "bell.slash",
description: Text("Events will appear here as they happen.")
)
}
}
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
struct AddServerView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
let editing: SavedServer?
@State private var host = ""
@State private var port = "7878"
@State private var authMode = SavedServer.AuthMode.guest
@State private var username = ""
@State private var password = ""
@State private var savePassword = false
var body: some View {
NavigationStack {
Form {
Section("Server") {
TextField("Hostname or IP", text: $host)
.textContentType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Server hostname or IP address")
TextField("Port", text: $port)
.keyboardType(.numberPad)
.accessibilityLabel("Port number")
}
Section("Authentication") {
Picker("Mode", selection: $authMode) {
Text("Guest").tag(SavedServer.AuthMode.guest)
Text("Account").tag(SavedServer.AuthMode.password)
}
.pickerStyle(.segmented)
.accessibilityLabel("Authentication mode")
if authMode == .password {
TextField("Username", text: $username)
.textContentType(.username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Username")
SecureField("Password (optional)", text: $password)
.textContentType(.password)
.accessibilityLabel("Password, optional, leave blank to enter at connect time")
Toggle("Save password in Keychain", isOn: $savePassword)
}
}
}
.navigationTitle(editing == nil ? "Add Server" : "Edit Server")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty
|| UInt16(port) == nil)
}
}
}
.onAppear {
if let s = editing {
host = s.host
port = "\(s.port)"
authMode = s.authMode
username = s.savedUsername
}
}
}
private func save() {
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
if var s = editing {
s.host = trimmedHost
s.port = portNum
s.authMode = authMode
s.savedUsername = authMode == .password ? username : ""
appState.updateServer(s, password: pw)
} else {
let s = SavedServer(host: trimmedHost, port: portNum,
authMode: authMode,
savedUsername: authMode == .password ? username : "")
appState.addServer(s, password: pw)
}
dismiss()
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
import VoiceCatCore
struct BanUserView: View {
let user: User
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
@State private var reason = ""
@State private var permanent = true
@State private var duration: Double = 60 // minutes
var body: some View {
NavigationStack {
Form {
Section("Ban \(user.nickname)") {
TextField("Reason (optional)", text: $reason)
.accessibilityLabel("Ban reason, optional")
Toggle("Permanent", isOn: $permanent)
.accessibilityLabel("Permanent ban")
if !permanent {
HStack {
Text("Duration")
Slider(value: $duration, in: 1...10080, step: 1)
.accessibilityLabel("Ban duration in minutes")
Text(formattedDuration)
.monospacedDigit()
.frame(width: 60, alignment: .trailing)
}
}
}
}
.navigationTitle("Ban User")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Ban", role: .destructive) {
let expiresMs: UInt64 = permanent ? 0
: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(duration * 60 * 1000)
session.banUser(user.id, reason: reason, expiresUnixMs: expiresMs)
dismiss()
}
}
}
}
}
private var formattedDuration: String {
let mins = Int(duration)
if mins < 60 { return "\(mins)m" }
let hours = mins / 60
if hours < 24 { return "\(hours)h" }
return "\(hours / 24)d"
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct ChannelEditView: View {
let channelId: UInt32?
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var topic = ""
var body: some View {
NavigationStack {
Form {
Section("Channel Info") {
TextField("Name", text: $name)
.autocorrectionDisabled()
.accessibilityLabel("Channel name")
TextField("Topic (optional)", text: $topic)
.accessibilityLabel("Channel topic, optional")
}
}
.navigationTitle(channelId == nil ? "New Channel" : "Edit Channel")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
session.createChannel(name: name, topic: topic)
dismiss()
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
}

View File

@@ -0,0 +1,131 @@
import SwiftUI
import VoiceCatCore
// ChannelNode wraps Channel for OutlineGroup; childrenOrNil must be nil (not empty [])
// for leaf channels so OutlineGroup doesn't render expand buttons.
struct ChannelNode: Identifiable {
let channel: Channel
let children: [ChannelNode]?
var id: UInt32 { channel.id }
}
struct ChannelTreeView: View {
@Bindable var session: SessionState
@State private var showCreateChannel = false
@State private var channelPassword = ""
@State private var passwordChannelId: UInt32?
var body: some View {
List(channelTree, children: \.children) { node in
ChannelRowView(node: node, session: session)
.onTapGesture {
if node.channel.passwordProtected {
passwordChannelId = node.channel.id
} else {
session.joinChannel(node.channel.id)
}
}
.swipeActions(edge: .trailing) {
if session.permissions.isAdmin {
Button(role: .destructive) {
session.deleteChannel(node.channel.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.sidebar)
.toolbar {
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
ToolbarItem(placement: .primaryAction) {
Button {
showCreateChannel = true
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Create channel")
}
}
if session.currentChannelId != 0 {
ToolbarItem(placement: .topBarLeading) {
Button("Leave", systemImage: "arrow.left.circle") {
session.leaveChannel()
}
.accessibilityLabel("Leave current channel")
}
}
}
.sheet(isPresented: $showCreateChannel) {
ChannelEditView(channelId: nil, session: session)
}
.alert("Channel Password", isPresented: Binding(
get: { passwordChannelId != nil },
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }
)) {
SecureField("Password", text: $channelPassword)
.accessibilityLabel("Channel password")
Button("Join") {
if let cid = passwordChannelId {
session.joinChannel(cid, password: channelPassword)
}
passwordChannelId = nil
channelPassword = ""
}
Button("Cancel", role: .cancel) {
passwordChannelId = nil
channelPassword = ""
}
}
}
private var channelTree: [ChannelNode] {
buildTree(parentId: 0, channels: session.channels)
}
private func buildTree(parentId: UInt32, channels: [Channel]) -> [ChannelNode] {
channels
.filter { $0.parentId == parentId }
.map { ch in
let kids = buildTree(parentId: ch.id, channels: channels)
return ChannelNode(channel: ch, children: kids.isEmpty ? nil : kids)
}
.sorted { $0.channel.name < $1.channel.name }
}
}
private struct ChannelRowView: View {
let node: ChannelNode
let session: SessionState
var body: some View {
let ch = node.channel
let isCurrent = session.currentChannelId == ch.id
let usersHere = session.users.filter { $0.channelId == ch.id }
HStack(spacing: 8) {
Image(systemName: ch.passwordProtected ? "lock.fill" : "number")
.foregroundStyle(isCurrent ? .blue : .secondary)
.imageScale(.small)
VStack(alignment: .leading, spacing: 1) {
Text(ch.name)
.fontWeight(isCurrent ? .semibold : .regular)
if !ch.topic.isEmpty {
Text(ch.topic)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
if !usersHere.isEmpty {
Text("\(usersHere.count)")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("\(usersHere.count) users")
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(ch.name)\(isCurrent ? ", current" : "")\(ch.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
import VoiceCatCore
struct ChatView: View {
@Bindable var session: SessionState
@State private var composeText = ""
@State private var scope: VoiceCatTextScope = .channel
@State private var privateTargetId: UInt32 = 0
var body: some View {
VStack(spacing: 0) {
// Message list
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(session.messages) { msg in
ChatBubble(message: msg)
.id(msg.id)
}
}
.padding()
}
.onChange(of: session.messages.count) { _, _ in
if let last = session.messages.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
Divider()
// Compose bar
HStack(spacing: 8) {
TextField("Message…", text: $composeText, axis: .vertical)
.lineLimit(1...5)
.textFieldStyle(.roundedBorder)
.accessibilityLabel("Message text field")
.onSubmit { sendMessage() }
Button {
sendMessage()
} label: {
Image(systemName: "arrow.up.circle.fill")
.imageScale(.large)
}
.disabled(composeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| session.currentChannelId == 0)
.accessibilityLabel("Send message")
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.navigationTitle("Chat")
.navigationBarTitleDisplayMode(.inline)
}
private func sendMessage() {
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
session.sendText(text, scope: .channel)
composeText = ""
}
}
private struct ChatBubble: View {
let message: ChatMessage
private var timeString: String {
let fmt = DateFormatter()
fmt.dateStyle = .none
fmt.timeStyle = .short
return fmt.string(from: message.timestamp)
}
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(message.senderName)
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
Text(timeString)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Text(message.text)
.font(.body)
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(message.senderName) at \(timeString): \(message.text)")
}
}

View File

@@ -0,0 +1,72 @@
import SwiftUI
struct MainView: View {
@Environment(AppState.self) private var appState
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
if let session = appState.session {
if sizeClass == .regular {
iPadMainView(session: session)
} else {
iPhoneMainView(session: session)
}
} else {
ServerListView()
}
}
}
// MARK: - iPhone layout: TabView
private struct iPhoneMainView: View {
let session: SessionState
var body: some View {
TabView {
ChannelTreeView(session: session)
.tabItem {
Label("Channels", systemImage: "list.bullet.indent")
}
ChatView(session: session)
.tabItem {
Label("Chat", systemImage: "message")
}
ActivityLogView(session: session)
.tabItem {
Label("Activity", systemImage: "bell")
}
SettingsView(session: session)
.tabItem {
Label("Settings", systemImage: "gear")
}
}
.overlay(alignment: .bottom) {
VoiceControlsView(session: session)
.padding(.bottom, 56) // above tab bar
}
}
}
// MARK: - iPad layout: NavigationSplitView
private struct iPadMainView: View {
let session: SessionState
@State private var columnVisibility = NavigationSplitViewVisibility.all
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
ChannelTreeView(session: session)
.navigationTitle("Channels")
} content: {
UserListView(session: session)
.navigationTitle("Users")
} detail: {
VStack(spacing: 0) {
ChatView(session: session)
VoiceControlsView(session: session)
}
.navigationTitle("Chat")
}
}
}

View File

@@ -0,0 +1,48 @@
import SwiftUI
import VoiceCatCore
struct MoveUserView: View {
let user: User
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
@State private var selectedChannelId: UInt32 = 0
var body: some View {
NavigationStack {
List(session.channels) { channel in
HStack {
Text(channel.name)
Spacer()
if channel.id == selectedChannelId {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
.accessibilityHidden(true)
}
}
.contentShape(Rectangle())
.onTapGesture { selectedChannelId = channel.id }
.accessibilityElement(children: .combine)
.accessibilityLabel("\(channel.name)\(channel.id == selectedChannelId ? ", selected" : "")")
.accessibilityAddTraits(channel.id == selectedChannelId ? .isSelected : [])
}
.navigationTitle("Move \(user.nickname)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Move") {
session.moveUser(user.id, toChannel: selectedChannelId)
dismiss()
}
.disabled(selectedChannelId == 0)
}
}
}
.onAppear {
selectedChannelId = user.channelId
}
}
}

View File

@@ -0,0 +1,65 @@
import SwiftUI
struct PasswordPromptView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
@State private var username = ""
@State private var password = ""
var body: some View {
NavigationStack {
Form {
Section {
if let server = appState.connectingServer {
Text("Connecting to \(server.displayString)")
.font(.caption)
.foregroundStyle(.secondary)
}
if appState.connectingServer?.authMode == .password {
TextField("Username", text: $username)
.textContentType(.username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Username")
}
SecureField("Password", text: $password)
.textContentType(.password)
.accessibilityLabel("Password")
}
if !appState.connectStatus.isEmpty && appState.connectStatus.lowercased().contains("failed") {
Section {
Text(appState.connectStatus)
.foregroundStyle(.red)
.accessibilityLabel("Error: \(appState.connectStatus)")
}
}
}
.navigationTitle("Sign In")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
appState.cancelConnect()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Connect") {
dismiss()
let uname = appState.connectingServer?.savedUsername.isEmpty == false
? appState.connectingServer!.savedUsername
: username
appState.authenticateUser(username: uname, password: password)
}
.disabled(password.isEmpty)
}
}
}
.onAppear {
username = appState.connectingServer?.savedUsername ?? ""
}
.interactiveDismissDisabled()
}
}

View File

@@ -0,0 +1,71 @@
import SwiftUI
import VoiceCatCore
struct PerUserTuningView: View {
let user: User
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
@State private var gain: Float = 1.0
@State private var muted = false
@State private var noiseReduction = false
private var streamsForUser: [StreamSummary] {
session.client.listUserStreams(user.id)
}
var body: some View {
NavigationStack {
Form {
Section("Volume") {
HStack {
Text("Gain")
Slider(value: $gain, in: 0...2, step: 0.05) { _ in
applyToAllStreams()
}
.accessibilityLabel("Volume gain for \(user.nickname)")
Text(String(format: "%.0f%%", gain * 100))
.monospacedDigit()
.frame(width: 44, alignment: .trailing)
}
Toggle("Mute", isOn: $muted)
.onChange(of: muted) { _, _ in applyToAllStreams() }
.accessibilityLabel("Mute \(user.nickname)")
}
Section("Audio Processing") {
Toggle("Noise Reduction", isOn: $noiseReduction)
.onChange(of: noiseReduction) { _, _ in applyToAllStreams() }
.accessibilityLabel("Noise reduction for \(user.nickname)")
}
}
.navigationTitle(user.nickname)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.onAppear {
// Load from first stream if available
let streams = streamsForUser
if let first = streams.first {
let (_, state) = session.client.getRemoteStream(userId: user.id, streamId: first.id)
if let s = state {
gain = s.gain
muted = s.muted
noiseReduction = s.noiseReduction
}
}
}
}
private func applyToAllStreams() {
for stream in streamsForUser {
session.client.setRemoteStream(
userId: user.id, streamId: stream.id,
gain: gain, muted: muted, noiseReduction: noiseReduction)
}
}
}

View File

@@ -0,0 +1,63 @@
import SwiftUI
import VoiceCatCore
struct PermissionsView: View {
let user: User
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
@State private var canCreateTempChannel = false
@State private var canKick = false
@State private var canBan = false
@State private var canMoveUsers = false
@State private var canAdminAccounts = false
@State private var isAdmin = false
var body: some View {
NavigationStack {
Form {
Section("Permissions for \(user.nickname)") {
Toggle("Create Temp Channels", isOn: $canCreateTempChannel)
.accessibilityLabel("Can create temporary channels")
Toggle("Kick Users", isOn: $canKick)
.accessibilityLabel("Can kick users")
Toggle("Ban Users", isOn: $canBan)
.accessibilityLabel("Can ban users")
Toggle("Move Users", isOn: $canMoveUsers)
.accessibilityLabel("Can move users between channels")
Toggle("Manage Accounts", isOn: $canAdminAccounts)
.accessibilityLabel("Can manage server accounts")
}
Section {
Toggle("Administrator", isOn: $isAdmin)
.foregroundStyle(isAdmin ? .orange : .primary)
.accessibilityLabel("Full administrator access")
} footer: {
Text("Administrators bypass all permission checks.")
.font(.caption)
}
}
.navigationTitle("Permissions")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let perms = Permissions(
canCreateTempChannel: canCreateTempChannel,
canKick: canKick,
canBan: canBan,
canMoveUsers: canMoveUsers,
canAdminAccounts: canAdminAccounts,
isAdmin: isAdmin)
session.setPermissions(user.id, perms: perms)
dismiss()
}
}
}
}
}
}

View File

@@ -0,0 +1,75 @@
import SwiftUI
import VoiceCatCore
struct ServerIdentityView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
let identity: PendingIdentity
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if identity.tofuStatus == .mismatch {
Label("Server Identity Mismatch", systemImage: "exclamationmark.triangle.fill")
.font(.headline)
.foregroundStyle(.red)
.accessibilityLabel("Warning: server identity mismatch")
Text("The server's identity has changed since your last connection. This may indicate a man-in-the-middle attack, or that the server was reinstalled. Do NOT accept unless you know why the identity changed.")
.foregroundStyle(.primary)
} else {
Label("New Server Identity", systemImage: "lock.badge.questionmark")
.font(.headline)
.accessibilityLabel("New server identity")
Text("This is the first time you are connecting to this server. Verify the fingerprint below with the server administrator before accepting.")
.foregroundStyle(.primary)
}
Divider()
VStack(alignment: .leading, spacing: 4) {
Text("Server fingerprint")
.font(.caption)
.foregroundStyle(.secondary)
Text(identity.displayText.isEmpty ? "(not available)" : identity.displayText)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.accessibilityLabel("Server fingerprint: \(identity.displayText)")
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
Spacer(minLength: 24)
VStack(spacing: 12) {
Button {
dismiss()
appState.confirmServerIdentity(accept: true)
} label: {
Text("Accept")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(identity.tofuStatus == .mismatch ? .orange : .blue)
.accessibilityLabel("Accept server identity and continue")
Button(role: .destructive) {
dismiss()
appState.confirmServerIdentity(accept: false)
} label: {
Text("Reject — Disconnect")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.accessibilityLabel("Reject server identity and disconnect")
}
}
.padding()
}
.navigationTitle("Server Identity")
.navigationBarTitleDisplayMode(.inline)
}
.interactiveDismissDisabled()
}
}

View File

@@ -0,0 +1,120 @@
import SwiftUI
struct ServerListView: View {
@Environment(AppState.self) private var appState
@State private var serverToDelete: SavedServer?
var body: some View {
@Bindable var state = appState
NavigationStack {
Group {
if appState.servers.isEmpty {
ContentUnavailableView(
"No Servers",
systemImage: "server.rack",
description: Text("Tap + to add a server.")
)
} else {
List {
ForEach(appState.servers) { server in
Button {
appState.connectTo(server)
} label: {
ServerRowView(server: server)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
serverToDelete = server
} label: {
Label("Delete", systemImage: "trash")
}
Button {
appState.editingServer = server
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
}
}
}
.navigationTitle("Servers")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
appState.showAddServer = true
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Add server")
}
}
.sheet(isPresented: $state.showAddServer) {
AddServerView(editing: nil)
}
.sheet(item: $state.editingServer) { server in
AddServerView(editing: server)
}
.sheet(item: $state.pendingIdentity) { identity in
ServerIdentityView(identity: identity)
}
.sheet(isPresented: $state.showPasswordPrompt) {
PasswordPromptView()
}
.overlay {
if appState.isConnecting {
ConnectingOverlay()
}
}
.confirmationDialog("Delete server?", isPresented: Binding(
get: { serverToDelete != nil },
set: { if !$0 { serverToDelete = nil } }
)) {
if let s = serverToDelete {
Button("Delete \(s.displayString)", role: .destructive) {
appState.removeServer(s)
serverToDelete = nil
}
}
Button("Cancel", role: .cancel) { serverToDelete = nil }
}
}
}
}
private struct ServerRowView: View {
let server: SavedServer
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(server.displayString)
.font(.body)
Text(server.authMode == .guest
? "Guest"
: "Account: \(server.savedUsername)")
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(server.displayString), \(server.authMode == .guest ? "guest" : "account \(server.savedUsername)")")
}
}
private struct ConnectingOverlay: View {
@Environment(AppState.self) private var appState
var body: some View {
ZStack {
Color.black.opacity(0.3).ignoresSafeArea()
VStack(spacing: 16) {
ProgressView()
Text(appState.connectStatus)
.foregroundStyle(.white)
Button("Cancel") { appState.cancelConnect() }
.buttonStyle(.bordered)
.tint(.white)
}
.padding(24)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
import VoiceCatCore
struct SettingsView: View {
@Environment(AppState.self) private var appState
@Bindable var session: SessionState
var body: some View {
NavigationStack {
Form {
// Voice input section
Section("Voice Input") {
Picker("Input Mode", selection: Binding(
get: { session.voiceState.inputMode },
set: { session.setInputMode($0) }
)) {
Text("Voice Activation").tag(VoiceCatInputMode.voiceActivation)
Text("Push to Talk").tag(VoiceCatInputMode.pushToTalk)
Text("Always On").tag(VoiceCatInputMode.alwaysOn)
}
.accessibilityLabel("Voice input mode")
if session.voiceState.inputMode == .voiceActivation {
VStack(alignment: .leading, spacing: 4) {
Text("VAD Threshold: \(String(format: "%.3f", session.voiceState.vadThreshold))")
.font(.caption)
Slider(
value: Binding(
get: { Double(session.voiceState.vadThreshold) },
set: { session.setVadThreshold(Float($0)) }
),
in: 0.001...0.1, step: 0.001
)
.accessibilityLabel("Voice activation threshold")
}
}
}
// Audio device section
if !session.devices.isEmpty {
Section("Input Device") {
Picker("Microphone", selection: Binding(
get: { session.voiceState.currentDeviceId ?? "" },
set: { id in
session.voiceState.currentDeviceId = id.isEmpty ? nil : id
if session.voiceState.localStreamId != 0 {
session.client.setInputDevice(
streamId: session.voiceState.localStreamId,
deviceId: id.isEmpty ? nil : id)
}
}
)) {
Text("Default").tag("")
ForEach(session.devices) { dev in
Text(dev.name).tag(dev.id)
}
}
.accessibilityLabel("Microphone selection")
}
}
// Admin section
if session.permissions.canAdminAccounts || session.permissions.isAdmin {
Section("Administration") {
NavigationLink("Manage Accounts") {
AccountsView(session: session)
}
.accessibilityLabel("Manage server accounts")
}
}
// Server info
Section("Server") {
Button(role: .destructive) {
session.stopMicStream()
appState.disconnect()
} label: {
Label("Disconnect", systemImage: "phone.down")
.foregroundStyle(.red)
}
.accessibilityLabel("Disconnect from server")
}
Section("About") {
Text(VoiceCatClient.versionString)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("Version: \(VoiceCatClient.versionString)")
}
}
.navigationTitle("Settings")
}
}
}

View File

@@ -0,0 +1,144 @@
import SwiftUI
import VoiceCatCore
struct UserListView: View {
@Bindable var session: SessionState
@State private var selectedUser: User?
@State private var showPerUserTuning = false
@State private var showBanUser = false
@State private var showMoveUser = false
@State private var showPermissions = false
var body: some View {
let channelUsers = session.currentChannelId == 0
? session.users
: session.users.filter { $0.channelId == session.currentChannelId }
List(channelUsers) { user in
UserRowView(user: user, isSelf: user.id == session.selfUserId)
.contextMenu {
userContextMenu(user: user)
}
}
.listStyle(.plain)
.overlay {
if channelUsers.isEmpty {
ContentUnavailableView(
"No Users",
systemImage: "person.slash",
description: Text(session.currentChannelId == 0 ? "Join a channel to see users." : "No one else here yet.")
)
}
}
.sheet(item: $selectedUser) { user in
if showPerUserTuning {
PerUserTuningView(user: user, session: session)
} else if showBanUser {
BanUserView(user: user, session: session)
} else if showMoveUser {
MoveUserView(user: user, session: session)
} else if showPermissions {
PermissionsView(user: user, session: session)
}
}
}
@ViewBuilder
private func userContextMenu(user: User) -> some View {
if user.id != session.selfUserId {
Button {
selectedUser = user
showPerUserTuning = true
showBanUser = false; showMoveUser = false; showPermissions = false
} label: {
Label("Volume / NR", systemImage: "speaker.wave.2")
}
if session.permissions.canKick || session.permissions.isAdmin {
Divider()
Button {
session.kickUser(user.id, reason: "")
} label: {
Label("Kick", systemImage: "person.fill.xmark")
}
if session.permissions.canBan || session.permissions.isAdmin {
Button(role: .destructive) {
selectedUser = user
showBanUser = true
showPerUserTuning = false; showMoveUser = false; showPermissions = false
} label: {
Label("Ban…", systemImage: "nosign")
}
}
}
if session.permissions.canMoveUsers || session.permissions.isAdmin {
Button {
selectedUser = user
showMoveUser = true
showPerUserTuning = false; showBanUser = false; showPermissions = false
} label: {
Label("Move to channel…", systemImage: "arrow.right.circle")
}
}
if session.permissions.isAdmin {
Divider()
let muted = user.serverMuted
Button {
session.setServerMute(user.id, muted: !muted, deafened: user.serverDeafened)
} label: {
Label(muted ? "Unmute" : "Server Mute", systemImage: muted ? "mic" : "mic.slash")
}
Button {
selectedUser = user
showPermissions = true
showPerUserTuning = false; showBanUser = false; showMoveUser = false
} label: {
Label("Permissions…", systemImage: "lock.shield")
}
}
}
}
}
private struct UserRowView: View {
let user: User
let isSelf: Bool
var body: some View {
HStack(spacing: 10) {
Image(systemName: user.selfMicMuted || user.serverMuted ? "mic.slash.fill" : "mic.fill")
.foregroundStyle(user.selfMicMuted || user.serverMuted ? .red : .green)
.imageScale(.small)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(user.nickname)
.fontWeight(isSelf ? .semibold : .regular)
if isSelf {
Text("(you)")
.font(.caption2)
.foregroundStyle(.secondary)
}
if user.isGuest {
Text("guest")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if user.serverMuted || user.serverDeafened {
Text(user.serverDeafened ? "server deafened" : "server muted")
.font(.caption2)
.foregroundStyle(.orange)
}
}
Spacer()
if user.selfDeafened {
Image(systemName: "headphones.slash")
.imageScale(.small)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(user.nickname)\(isSelf ? ", you" : "")\(user.isGuest ? ", guest" : "")\(user.selfMicMuted ? ", muted" : "")\(user.serverMuted ? ", server muted" : "")")
}
}

View File

@@ -0,0 +1,130 @@
import SwiftUI
import VoiceCatCore
struct VoiceControlsView: View {
@Bindable var session: SessionState
var body: some View {
HStack(spacing: 20) {
// Mic toggle / PTT button
if session.voiceState.inputMode == .pushToTalk {
PTTButton(session: session)
} else {
Button {
if session.voiceState.micActive {
session.stopMicStream()
} else {
session.startMicStream()
}
} label: {
Image(systemName: session.voiceState.micActive ? "mic.fill" : "mic.slash.fill")
.font(.title2)
.frame(width: 44, height: 44)
.background(session.voiceState.micActive ? Color.green : Color(.systemGray4), in: Circle())
.foregroundStyle(session.voiceState.micActive ? .white : .primary)
}
.disabled(session.currentChannelId == 0)
.accessibilityLabel(session.voiceState.micActive ? "Stop microphone" : "Start microphone")
}
// Level meter
LevelMeterView(level: session.voiceState.level)
.frame(width: 80, height: 8)
.accessibilityHidden(true)
Spacer()
// Self mute
Button {
session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)
} label: {
Image(systemName: session.voiceState.selfMuted ? "mic.slash" : "mic")
.font(.title3)
.foregroundStyle(session.voiceState.selfMuted ? .red : .primary)
}
.accessibilityLabel(session.voiceState.selfMuted ? "Unmute microphone" : "Mute microphone")
// Self deafen
Button {
session.setMute(session.voiceState.selfMuted, deafened: !session.voiceState.selfDeafened)
} label: {
Image(systemName: session.voiceState.selfDeafened ? "headphones.slash" : "headphones")
.font(.title3)
.foregroundStyle(session.voiceState.selfDeafened ? .red : .primary)
}
.accessibilityLabel(session.voiceState.selfDeafened ? "Undeafen" : "Deafen")
// Disconnect
Button(role: .destructive) {
session.stopMicStream()
session.client.disconnect()
} label: {
Image(systemName: "phone.down.fill")
.font(.title3)
.foregroundStyle(.red)
}
.accessibilityLabel("Disconnect from server")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.bar)
}
}
// MARK: - PTT Button (DragGesture instead of NSEvent on iOS)
private struct PTTButton: View {
@Bindable var session: SessionState
@GestureState private var isPressing = false
var body: some View {
Circle()
.fill(isPressing ? Color.blue : Color(.systemGray4))
.frame(width: 44, height: 44)
.overlay {
Image(systemName: "mic.fill")
.foregroundStyle(isPressing ? .white : .primary)
}
.gesture(
DragGesture(minimumDistance: 0)
.updating($isPressing) { _, state, _ in state = true }
.onChanged { _ in
if !isPressing { return }
if !session.voiceState.micActive { session.startMicStream() }
session.setPushToTalk(true)
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
.onEnded { _ in
session.setPushToTalk(false)
session.stopMicStream()
}
)
.accessibilityLabel("Push to talk, hold to transmit")
.accessibilityAddTraits(.isButton)
}
}
// MARK: - Level Meter
private struct LevelMeterView: View {
let level: Float
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
RoundedRectangle(cornerRadius: 4)
.fill(levelColor)
.frame(width: geo.size.width * CGFloat(min(level * 10, 1.0)))
.animation(.linear(duration: 0.05), value: level)
}
}
}
private var levelColor: Color {
if level > 0.15 { return .orange }
if level > 0.05 { return .green }
return .green.opacity(0.5)
}
}