Files
voice-cat/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift
Talon 75c2782860 fix(macos): fix split view layout so panels are visible and VoiceOver-reachable
The main window's NSSplitView panels (channel list, user list, chat,
activity log) were collapsing to zero size because:

1. The scroll views inside the split views were missing
   translatesAutoresizingMaskIntoConstraints = false, so Auto Layout
   couldn't manage their sizes.
2. The inner split views were also missing it.
3. The voice panel had no height constraint, so it expanded to fill
   all available space (539px of the 600px window), starving the
   outer split view down to 1px tall.
4. The split views had no initial divider positions, so panels
   collapsed to zero even when the split view had space.

Add translatesAutoresizingMaskIntoConstraints = false to all four
scroll views and both inner split views, give the voice panel a
96px height constraint, and set initial divider positions after the
window is on screen. The panels now get reasonable space, are
visible on screen, and are reachable by VoiceOver.
2026-06-18 17:35:14 +02:00

1208 lines
51 KiB
Swift

import AppKit
import VoiceCatCore
// MARK: - Channel tree node
private final class ChannelNode: NSObject {
let channel: Channel
var children: [ChannelNode]
init(_ channel: Channel, children: [ChannelNode] = []) {
self.channel = channel
self.children = children
}
}
// MARK: - Window controller
final class MainWindowController: NSWindowController, NSWindowDelegate {
// MARK: - Owned client
private let client: VoiceCatClient
private let selfUserId: UInt32
private let nickname: String
// MARK: - State
private var currentChannelId: UInt32 = 0
private var channels: [Channel] = []
private var users: [UInt32: User] = [:]
private var talkingUsers: Set<UInt32> = []
private var ownPermissions = Permissions(canCreateTempChannel: false, canKick: false, canBan: false,
canMoveUsers: false, canAdminAccounts: false, isAdmin: false)
private var micStreamId: UInt32 = 0
private var screenStreamId: UInt32 = 0
private var pttKeyCode: UInt16 = 0x60 // F8
private var pttMonitor: Any?
private var serverMuted = false
private var serverDeafened = false
private var channelTree: [ChannelNode] = []
private var activityLog: [String] = []
private var displayedUsers: [User] = []
private var displayedAccounts: [Account] = []
private var adminMenuItem: NSMenuItem?
// MARK: - UI components
private let channelOutlineView = NSOutlineView()
private let userTableView = NSTableView()
private let chatTextView: NSTextView = {
let tv = NSTextView()
tv.isEditable = false
tv.isSelectable = true
return tv
}()
private let activityTableView = NSTableView()
private let statusLabel = NSTextField(labelWithString: "")
private let micToggleButton = NSButton()
private let screenAudioButton = NSButton()
private let muteCheckbox = NSButton(checkboxWithTitle: "Mute", target: nil, action: nil)
private let deafenCheckbox = NSButton(checkboxWithTitle: "Deafen", target: nil, action: nil)
private let inputModeControl = NSSegmentedControl(labels: ["VAD", "PTT", "Always On"],
trackingMode: .selectOne,
target: nil, action: nil)
private let vadSlider: NSSlider = {
let s = NSSlider(value: 50, minValue: 1, maxValue: 100, target: nil, action: nil)
s.numberOfTickMarks = 0
return s
}()
private let vadLabel = NSTextField(labelWithString: "Sensitivity:")
private let pttKeyLabel = NSTextField(labelWithString: "(F8)")
private let changePttButton = NSButton()
private let devicePicker = NSPopUpButton()
private let refreshDevicesButton = NSButton()
private let levelMeter: NSProgressIndicator = {
let p = NSProgressIndicator()
p.style = .bar
p.isIndeterminate = false
p.minValue = 0
p.maxValue = 100
p.doubleValue = 0
return p
}()
private let scopePicker = NSPopUpButton()
private let composeField = NSTextField()
private let sendButton = NSButton()
// MARK: - Init
init(client: VoiceCatClient, selfUserId: UInt32, nickname: String) {
self.client = client
self.selfUserId = selfUserId
self.nickname = nickname
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 900, height: 600),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "VoiceCat — \(nickname)"
window.minSize = NSSize(width: 640, height: 480)
window.center()
super.init(window: window)
window.delegate = self
buildUI()
wireEvents()
bootstrap()
}
required init?(coder: NSCoder) { fatalError() }
deinit {
NSLog("[VoiceCatMac] MainWindowController deinit — client and event handlers are gone")
}
// MARK: - UI construction
private func buildUI() {
guard let contentView = window?.contentView else { return }
// Outer split: left panel (channels+users) | right panel (chat+activity)
let outerSplit = NSSplitView()
outerSplit.isVertical = true
outerSplit.dividerStyle = .thin
outerSplit.translatesAutoresizingMaskIntoConstraints = false
// Left panel: channel outline + user table stacked vertically
let innerSplit = NSSplitView()
innerSplit.isVertical = false
innerSplit.dividerStyle = .thin
innerSplit.translatesAutoresizingMaskIntoConstraints = false
let channelPanel = buildChannelPanel()
let userPanel = buildUserPanel()
innerSplit.addArrangedSubview(channelPanel)
innerSplit.addArrangedSubview(userPanel)
innerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0)
innerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1)
// Right panel: chat + activity stacked vertically
let rightSplit = NSSplitView()
rightSplit.isVertical = false
rightSplit.dividerStyle = .thin
rightSplit.translatesAutoresizingMaskIntoConstraints = false
let chatPanel = buildChatPanel()
let activityPanel = buildActivityPanel()
rightSplit.addArrangedSubview(chatPanel)
rightSplit.addArrangedSubview(activityPanel)
rightSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 0)
rightSplit.setHoldingPriority(.defaultLow, forSubviewAt: 1)
outerSplit.addArrangedSubview(innerSplit)
outerSplit.addArrangedSubview(rightSplit)
outerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0)
outerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1)
let statusBar = buildStatusBar()
let voicePanel = buildVoicePanel()
let composeBar = buildComposeBar()
contentView.addSubview(outerSplit)
contentView.addSubview(statusBar)
contentView.addSubview(voicePanel)
contentView.addSubview(composeBar)
NSLayoutConstraint.activate([
outerSplit.topAnchor.constraint(equalTo: contentView.topAnchor),
outerSplit.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
outerSplit.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
outerSplit.bottomAnchor.constraint(equalTo: statusBar.topAnchor),
statusBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
statusBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
statusBar.bottomAnchor.constraint(equalTo: voicePanel.topAnchor),
statusBar.heightAnchor.constraint(equalToConstant: 24),
voicePanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
voicePanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
voicePanel.bottomAnchor.constraint(equalTo: composeBar.topAnchor),
composeBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
composeBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
composeBar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
composeBar.heightAnchor.constraint(equalToConstant: 36),
])
// Set initial divider positions so panels get reasonable space instead of
// collapsing to zero. These are applied after the window is on screen.
DispatchQueue.main.async { [weak self, weak outerSplit, weak innerSplit, weak rightSplit] in
// outerSplit is vertical (left|right): divide at 40% from left
outerSplit?.setPosition(360, ofDividerAt: 0)
// innerSplit is horizontal (channels on top, users below): divide at 60% from top
innerSplit?.setPosition(260, ofDividerAt: 0)
// rightSplit is horizontal (chat on top, activity below): divide at 70% from top
rightSplit?.setPosition(280, ofDividerAt: 0)
}
}
private func buildChannelPanel() -> NSView {
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("channel"))
channelOutlineView.addTableColumn(col)
channelOutlineView.headerView = nil
channelOutlineView.outlineTableColumn = col
channelOutlineView.dataSource = self
channelOutlineView.delegate = self
channelOutlineView.doubleAction = #selector(channelDoubleClicked)
channelOutlineView.target = self
channelOutlineView.setAccessibilityLabel("Channel list")
let sv = NSScrollView()
sv.documentView = channelOutlineView
sv.hasVerticalScroller = true
sv.borderType = .noBorder
sv.translatesAutoresizingMaskIntoConstraints = false
let menu = NSMenu()
menu.delegate = self
channelOutlineView.menu = menu
return sv
}
private func buildUserPanel() -> NSView {
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("user"))
userTableView.addTableColumn(col)
userTableView.headerView = nil
userTableView.dataSource = self
userTableView.delegate = self
userTableView.doubleAction = #selector(userDoubleClicked)
userTableView.target = self
userTableView.setAccessibilityLabel("Users in current channel")
let menu = NSMenu()
menu.delegate = self
userTableView.menu = menu
let sv = NSScrollView()
sv.documentView = userTableView
sv.hasVerticalScroller = true
sv.borderType = .noBorder
sv.translatesAutoresizingMaskIntoConstraints = false
return sv
}
private func buildChatPanel() -> NSView {
chatTextView.setAccessibilityLabel("Chat messages")
chatTextView.textContainerInset = NSSize(width: 4, height: 4)
chatTextView.isAutomaticQuoteSubstitutionEnabled = false
let sv = NSScrollView()
sv.documentView = chatTextView
sv.hasVerticalScroller = true
sv.borderType = .noBorder
sv.translatesAutoresizingMaskIntoConstraints = false
return sv
}
private func buildActivityPanel() -> NSView {
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("activity"))
activityTableView.addTableColumn(col)
activityTableView.headerView = nil
activityTableView.dataSource = self
activityTableView.delegate = self
activityTableView.setAccessibilityLabel("Activity log")
let sv = NSScrollView()
sv.documentView = activityTableView
sv.hasVerticalScroller = true
sv.borderType = .noBorder
sv.translatesAutoresizingMaskIntoConstraints = false
return sv
}
private func buildStatusBar() -> NSView {
let bar = NSView()
bar.translatesAutoresizingMaskIntoConstraints = false
statusLabel.translatesAutoresizingMaskIntoConstraints = false
statusLabel.textColor = .secondaryLabelColor
statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
bar.addSubview(statusLabel)
NSLayoutConstraint.activate([
statusLabel.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8),
statusLabel.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -8),
statusLabel.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
])
let sep = NSBox()
sep.boxType = .separator
sep.translatesAutoresizingMaskIntoConstraints = false
bar.addSubview(sep)
NSLayoutConstraint.activate([
sep.topAnchor.constraint(equalTo: bar.topAnchor),
sep.leadingAnchor.constraint(equalTo: bar.leadingAnchor),
sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor),
sep.heightAnchor.constraint(equalToConstant: 1),
])
return bar
}
private func buildVoicePanel() -> NSView {
let panel = NSView()
panel.translatesAutoresizingMaskIntoConstraints = false
let sep = NSBox(); sep.boxType = .separator; sep.translatesAutoresizingMaskIntoConstraints = false
panel.addSubview(sep)
// Mic + Screen buttons
micToggleButton.title = "Join Voice"
micToggleButton.bezelStyle = .rounded
micToggleButton.target = self; micToggleButton.action = #selector(micToggleClicked)
micToggleButton.setAccessibilityLabel("Join Voice — start sending microphone audio")
screenAudioButton.title = "Share Screen Audio"
screenAudioButton.bezelStyle = .rounded
screenAudioButton.target = self; screenAudioButton.action = #selector(screenAudioClicked)
screenAudioButton.setAccessibilityLabel("Share Screen Audio")
// Mute/Deafen
muteCheckbox.target = self; muteCheckbox.action = #selector(muteChanged)
muteCheckbox.setAccessibilityLabel("Mute microphone")
muteCheckbox.isEnabled = false
deafenCheckbox.target = self; deafenCheckbox.action = #selector(deafenChanged)
deafenCheckbox.setAccessibilityLabel("Deafen — mute all incoming audio")
deafenCheckbox.isEnabled = false
// Input mode
inputModeControl.target = self; inputModeControl.action = #selector(inputModeChanged)
inputModeControl.selectedSegment = 0
inputModeControl.setAccessibilityLabel("Input mode: VAD, PTT, or Always On")
// VAD slider
vadLabel.setAccessibilityLabel("VAD sensitivity")
vadSlider.target = self; vadSlider.action = #selector(vadSliderChanged)
vadSlider.setAccessibilityLabel("Voice activation sensitivity")
vadSlider.setAccessibilityHelp("Drag right for more sensitive, left for less")
// PTT
pttKeyLabel.setAccessibilityLabel("Current PTT key")
changePttButton.title = "Change…"
changePttButton.bezelStyle = .rounded
changePttButton.target = self; changePttButton.action = #selector(changePttClicked)
changePttButton.setAccessibilityLabel("Change push-to-talk key")
pttKeyLabel.isHidden = true
changePttButton.isHidden = true
// Device picker
let deviceLabel = NSTextField(labelWithString: "Input:")
devicePicker.setAccessibilityLabel("Input audio device")
refreshDevicesButton.title = ""
refreshDevicesButton.bezelStyle = .rounded
refreshDevicesButton.target = self; refreshDevicesButton.action = #selector(refreshDevicesClicked)
refreshDevicesButton.setAccessibilityLabel("Refresh device list")
refreshDevicesButton.toolTip = "Refresh"
// Level meter
levelMeter.setAccessibilityLabel("Microphone input level")
levelMeter.setAccessibilityHelp("Shows current microphone volume level")
// Layout all voice controls
let row1 = hstack([micToggleButton, screenAudioButton, muteCheckbox, deafenCheckbox])
let row2 = hstack([inputModeControl, vadLabel, vadSlider, pttKeyLabel, changePttButton])
let row3 = hstack([deviceLabel, devicePicker, refreshDevicesButton, levelMeter])
[sep, row1, row2, row3].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
panel.addSubview(v)
}
NSLayoutConstraint.activate([
sep.topAnchor.constraint(equalTo: panel.topAnchor),
sep.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
sep.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
sep.heightAnchor.constraint(equalToConstant: 1),
row1.topAnchor.constraint(equalTo: sep.bottomAnchor, constant: 6),
row1.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8),
row1.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8),
row2.topAnchor.constraint(equalTo: row1.bottomAnchor, constant: 4),
row2.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8),
row2.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8),
row3.topAnchor.constraint(equalTo: row2.bottomAnchor, constant: 4),
row3.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 8),
row3.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -8),
row3.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -6),
levelMeter.widthAnchor.constraint(equalToConstant: 100),
devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
panel.heightAnchor.constraint(equalToConstant: 96),
])
return panel
}
private func buildComposeBar() -> NSView {
let bar = NSView()
bar.translatesAutoresizingMaskIntoConstraints = false
scopePicker.setAccessibilityLabel("Message scope — channel or private")
composeField.placeholderString = "Type a message…"
composeField.setAccessibilityLabel("Compose message")
composeField.target = self; composeField.action = #selector(sendClicked)
sendButton.title = "Send"
sendButton.bezelStyle = .rounded
sendButton.target = self; sendButton.action = #selector(sendClicked)
sendButton.setAccessibilityLabel("Send message")
sendButton.keyEquivalent = "\r"
let sep = NSBox(); sep.boxType = .separator; sep.translatesAutoresizingMaskIntoConstraints = false
[sep, scopePicker, composeField, sendButton].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
bar.addSubview(v)
}
NSLayoutConstraint.activate([
sep.topAnchor.constraint(equalTo: bar.topAnchor),
sep.leadingAnchor.constraint(equalTo: bar.leadingAnchor),
sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor),
sep.heightAnchor.constraint(equalToConstant: 1),
scopePicker.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8),
scopePicker.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
composeField.leadingAnchor.constraint(equalTo: scopePicker.trailingAnchor, constant: 6),
composeField.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -6),
composeField.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
sendButton.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -8),
sendButton.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
sendButton.widthAnchor.constraint(equalToConstant: 60),
])
return bar
}
private func hstack(_ views: [NSView]) -> NSStackView {
let s = NSStackView(views: views)
s.orientation = .horizontal
s.spacing = 8
s.alignment = .centerY
return s
}
// MARK: - Event wiring
private func wireEvents() {
client.onEvent = { [weak self] event in self?.handleEvent(event) }
client.onLevel = { [weak self] streamId, rms in self?.handleLevel(streamId, rms) }
pttMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in
guard let self, self.inputModeControl.selectedSegment == 1,
self.micStreamId != 0, event.keyCode == self.pttKeyCode else { return event }
self.client.setPushToTalk(event.type == .keyDown)
return nil
}
}
// MARK: - Bootstrap
private func bootstrap() {
channels = client.listChannels()
let allUsers = client.listUsers()
users.removeAll()
for u in allUsers {
users[u.id] = u
if u.id == selfUserId { currentChannelId = u.channelId }
}
ownPermissions = client.getPermissions()
NSLog("[VoiceCatMac] bootstrap: channels=%d users=%d perms{admin=%d kick=%d} currentChannelId=%u",
channels.count, allUsers.count,
ownPermissions.isAdmin, ownPermissions.canKick, currentChannelId)
refreshChannelTree()
refreshUserList()
rebuildScopePicker()
updateStatusLabel()
loadInputDevices()
buildAdminMenu()
addActivity("Connected to server as \(nickname)")
}
// MARK: - Event handling
private func handleEvent(_ event: VoiceCatEvent) {
NSLog("[VoiceCatMac] event type=%d result=%d userId=%u channelId=%u streamId=%u text=%@",
event.type.rawValue, event.result.rawValue, event.userId, event.channelId,
event.streamId, event.text ?? "(nil)")
switch event.type {
case .channelList:
channels = client.listChannels()
let allUsers = client.listUsers()
users.removeAll()
for u in allUsers {
users[u.id] = u
if u.id == selfUserId { currentChannelId = u.channelId }
}
refreshChannelTree(); refreshUserList(); rebuildScopePicker()
case .userJoined:
let u = User(id: event.userId,
nickname: event.text ?? "User#\(event.userId)",
isGuest: true,
channelId: event.channelId,
selfMicMuted: false, selfDeafened: false,
serverMuted: false, serverDeafened: false)
users[event.userId] = u
refreshUserList(); rebuildScopePicker()
if event.channelId == currentChannelId && event.userId != selfUserId {
addActivity("\(u.nickname) joined the channel")
}
case .userLeft:
let nick = users[event.userId]?.nickname ?? "User#\(event.userId)"
let wasHere = users[event.userId]?.channelId == currentChannelId && event.userId != selfUserId
users.removeValue(forKey: event.userId)
talkingUsers.remove(event.userId)
refreshUserList(); rebuildScopePicker()
if wasHere { addActivity("\(nick) left the channel") }
case .userUpdated:
let allUsers = client.listUsers()
users.removeAll()
for u in allUsers {
users[u.id] = u
if u.id == selfUserId {
currentChannelId = u.channelId
applyServerMuteState(muted: u.serverMuted, deafened: u.serverDeafened)
}
}
refreshChannelTree(); refreshUserList(); rebuildScopePicker()
case .joinResult:
if event.result == .ok {
currentChannelId = event.channelId
if let self_ = users[selfUserId] {
users[selfUserId] = User(id: self_.id, nickname: self_.nickname, isGuest: self_.isGuest,
channelId: event.channelId,
selfMicMuted: self_.selfMicMuted, selfDeafened: self_.selfDeafened,
serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened)
}
refreshChannelTree(); refreshUserList(); updateStatusLabel()
let name = channels.first(where: { $0.id == event.channelId })?.name ?? "Channel #\(event.channelId)"
addActivity("Joined \(name)")
NSAccessibility.post(element: activityTableView, notification: .announcementRequested,
userInfo: [.announcement: "Joined \(name)", .priority: NSAccessibilityPriorityLevel.medium])
} else {
addActivity("Could not join channel: \(event.text ?? "\(event.result)")")
}
case .genericResult:
let prefix = event.result == .ok ? "Success" : "Failed"
let detail = event.text.map { ": \($0)" } ?? ""
addActivity("\(prefix)\(detail) (\(event.result))")
case .accountList:
addActivity("Account list updated")
case .textMessage:
appendChatMessage(event)
case .talkState:
let talking = event.u32a == 1
if talking { talkingUsers.insert(event.userId) } else { talkingUsers.remove(event.userId) }
refreshUserList()
if talking && event.userId != selfUserId,
let u = users[event.userId], u.channelId == currentChannelId {
addActivity("\(u.nickname) started talking")
NSAccessibility.post(element: activityTableView, notification: .announcementRequested,
userInfo: [.announcement: "\(u.nickname) started talking",
.priority: NSAccessibilityPriorityLevel.medium])
}
case .streamStarted:
guard let u = users[event.userId], u.channelId == currentChannelId else { break }
let streams = client.listUserStreams(event.userId)
let kind = streams.first(where: { $0.id == event.streamId })?.kind
let kindStr = kind == .screenAudio ? "screen audio" : kind == .auxDevice ? "aux device" : "microphone"
addActivity("\(u.nickname) started \(kindStr) stream")
case .streamStopped:
if let u = users[event.userId], u.channelId == currentChannelId {
addActivity("\(u.nickname) stopped a stream")
}
case .disconnected:
handleDisconnected(event)
default:
break
}
}
private func handleLevel(_ streamId: UInt32, _ rms: Float) {
guard streamId == micStreamId else { return }
levelMeter.doubleValue = min(100, Double(rms * 400))
levelMeter.setAccessibilityValue("\(Int(levelMeter.doubleValue)) percent")
}
// MARK: - Chat
private func appendChatMessage(_ event: VoiceCatEvent) {
let time: String
if event.timestampUnixMs > 0 {
let date = Date(timeIntervalSince1970: Double(event.timestampUnixMs) / 1000.0)
time = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short)
} else {
time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short)
}
let sender = nickname(for: event.userId)
let prefix: String
if event.textScope == .private {
prefix = event.userId == selfUserId
? "(private to \(nickname(for: event.channelId))) "
: "(private) "
} else { prefix = "" }
let line = "[\(time)] \(prefix)\(sender): \(event.text ?? "")\n"
chatTextView.textStorage?.append(NSAttributedString(string: line))
chatTextView.scrollToEndOfDocument(nil)
if event.textScope == .private && event.userId != selfUserId {
addActivity("Private message from \(sender)")
}
}
// MARK: - Activity log
private func addActivity(_ text: String) {
let entry = "[\(DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short))] \(text)"
activityLog.append(entry)
if activityLog.count > 200 { activityLog.removeFirst() }
activityTableView.reloadData()
activityTableView.scrollRowToVisible(activityLog.count - 1)
}
// MARK: - UI refresh
private func refreshChannelTree() {
channelTree = buildChannelTree(channels)
channelOutlineView.reloadData()
channelOutlineView.expandItem(nil, expandChildren: true)
}
private func buildChannelTree(_ channels: [Channel]) -> [ChannelNode] {
var nodeMap: [UInt32: ChannelNode] = [:]
for ch in channels { nodeMap[ch.id] = ChannelNode(ch) }
var roots: [ChannelNode] = []
for ch in channels.sorted(by: { $0.name < $1.name }) {
if ch.parentId == 0 {
roots.append(nodeMap[ch.id]!)
} else if let parent = nodeMap[ch.parentId] {
parent.children.append(nodeMap[ch.id]!)
}
}
return roots.sorted { $0.channel.name < $1.channel.name }
}
private func refreshUserList() {
displayedUsers = users.values
.filter { $0.channelId == currentChannelId }
.sorted { $0.nickname < $1.nickname }
userTableView.reloadData()
updateStatusLabel()
}
private func rebuildScopePicker() {
let prev = scopePicker.selectedItem?.representedObject as? UInt32
scopePicker.removeAllItems()
let chItem = NSMenuItem(title: "Channel", action: nil, keyEquivalent: "")
chItem.representedObject = nil as UInt32?
scopePicker.menu?.addItem(chItem)
for u in users.values.sorted(by: { $0.nickname < $1.nickname }) where u.id != selfUserId {
let item = NSMenuItem(title: "Private: \(u.nickname)", action: nil, keyEquivalent: "")
item.representedObject = u.id
scopePicker.menu?.addItem(item)
}
if let prev {
for item in scopePicker.itemArray where (item.representedObject as? UInt32) == prev {
scopePicker.select(item); break
}
} else {
scopePicker.selectItem(at: 0)
}
}
private func updateStatusLabel() {
var suffix = ""
if serverMuted { suffix += " [server muted]" }
if serverDeafened { suffix += " [server deafened]" }
if currentChannelId == 0 {
statusLabel.stringValue = "Connected as \(nickname)\(suffix) — not in a channel."
return
}
let chanName = channels.first(where: { $0.id == currentChannelId })?.name ?? "Channel #\(currentChannelId)"
let count = users.values.filter { $0.channelId == currentChannelId }.count
statusLabel.stringValue = "Connected as \(nickname)\(suffix)\(chanName) (\(count) user\(count == 1 ? "" : "s"))"
}
private func applyServerMuteState(muted: Bool, deafened: Bool) {
if muted && !serverMuted { addActivity("You have been server-muted") }
if deafened && !serverDeafened { addActivity("You have been server-deafened") }
if !muted && serverMuted { addActivity("Server mute cleared") }
if !deafened && serverDeafened { addActivity("Server deafen cleared") }
serverMuted = muted; serverDeafened = deafened
updateStatusLabel()
}
private func handleDisconnected(_ event: VoiceCatEvent) {
let msg = event.text.map { "Disconnected: \($0)" } ?? "Disconnected from server."
statusLabel.stringValue = msg
addActivity(msg)
channelTree = []; channelOutlineView.reloadData()
displayedUsers = []; userTableView.reloadData()
users.removeAll(); talkingUsers.removeAll()
currentChannelId = 0; micStreamId = 0; screenStreamId = 0
composeField.isEnabled = false; sendButton.isEnabled = false
micToggleButton.isEnabled = false; screenAudioButton.isEnabled = false
}
// MARK: - Admin menu
private func buildAdminMenu() {
guard ownPermissions.canAdminAccounts || ownPermissions.isAdmin else { return }
let adminMenu = NSMenu(title: "Admin")
let accountsItem = NSMenuItem(title: "Server accounts…", action: #selector(openAccounts), keyEquivalent: "")
accountsItem.target = self
adminMenu.addItem(accountsItem)
let topItem = NSMenuItem(title: "Admin", action: nil, keyEquivalent: "")
topItem.submenu = adminMenu
adminMenuItem = topItem
NSApp.mainMenu?.addItem(topItem)
}
@objc private func openAccounts() {
presentSheet(AccountsSheet(client: client))
}
// MARK: - Channel actions
@objc private func channelDoubleClicked() {
guard let node = channelOutlineView.item(atRow: channelOutlineView.clickedRow) as? ChannelNode else { return }
joinChannelRequest(node.channel.id)
}
private func joinChannelRequest(_ channelId: UInt32) {
guard channelId != currentChannelId else { return }
let channel = channels.first(where: { $0.id == channelId })
if channel?.passwordProtected == true {
let sheet = PasswordPromptSheet(prompt: "Password for channel \"\(channel!.name)\":")
sheet.onComplete = { [weak self] pw in
self?.client.joinChannel(channelId, password: pw)
}
presentSheet(sheet)
} else {
client.joinChannel(channelId, password: nil)
}
}
// MARK: - User actions
@objc private func userDoubleClicked() {
openUserTuning(row: userTableView.clickedRow)
}
private func openUserTuning(row: Int) {
guard row >= 0, row < displayedUsers.count else { return }
let user = displayedUsers[row]
presentSheet(PerUserTuningSheet(client: client, userId: user.id, nickname: user.nickname))
}
// MARK: - Voice controls
@objc private func micToggleClicked() {
if micStreamId == 0 {
let (result, streamId) = client.startStream(StreamDescriptor(kind: .mic, deviceId: nil, label: "Microphone"))
if result == .ok {
micStreamId = streamId
if let devId = selectedDeviceId(), !isDefaultDevice(devId) {
client.setInputDevice(streamId: streamId, deviceId: devId)
}
client.setInputMode(currentInputMode())
if inputModeControl.selectedSegment == 0 {
client.setVadThreshold(vadThresholdFromSlider())
}
micToggleButton.title = "Leave Voice"
micToggleButton.setAccessibilityLabel("Leave Voice — stop sending microphone audio")
muteCheckbox.isEnabled = true; deafenCheckbox.isEnabled = true
addActivity("Joined voice — microphone active")
NSAccessibility.post(element: activityTableView, notification: .announcementRequested,
userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium])
} else {
addActivity("Failed to start microphone: \(result)")
}
} else {
client.setPushToTalk(false)
client.stopStream(micStreamId)
micStreamId = 0
levelMeter.doubleValue = 0
micToggleButton.title = "Join Voice"
micToggleButton.setAccessibilityLabel("Join Voice — start sending microphone audio")
muteCheckbox.isEnabled = false; deafenCheckbox.isEnabled = false
addActivity("Left voice")
}
}
@objc private func screenAudioClicked() {
if screenStreamId == 0 {
let (result, streamId) = client.startStream(StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Desktop audio"))
if result == .ok {
screenStreamId = streamId
screenAudioButton.title = "Stop Screen Audio"
addActivity("Started sharing screen audio")
} else {
addActivity("Failed to start screen audio: \(result)")
}
} else {
client.stopStream(screenStreamId)
screenStreamId = 0
screenAudioButton.title = "Share Screen Audio"
addActivity("Stopped sharing screen audio")
}
}
@objc private func muteChanged() {
client.setSelfMute(micMuted: muteCheckbox.state == .on, deafened: deafenCheckbox.state == .on)
}
@objc private func deafenChanged() {
client.setSelfMute(micMuted: muteCheckbox.state == .on, deafened: deafenCheckbox.state == .on)
}
@objc private func inputModeChanged() {
let seg = inputModeControl.selectedSegment
vadLabel.isHidden = seg != 0; vadSlider.isHidden = seg != 0
pttKeyLabel.isHidden = seg != 1; changePttButton.isHidden = seg != 1
if micStreamId != 0 {
client.setInputMode(currentInputMode())
if seg == 0 { client.setVadThreshold(vadThresholdFromSlider()) }
if seg == 1 { client.setPushToTalk(false) }
}
}
@objc private func vadSliderChanged() {
if micStreamId != 0 && inputModeControl.selectedSegment == 0 {
client.setVadThreshold(vadThresholdFromSlider())
}
}
@objc private func changePttClicked() {
let sheet = PttKeyCaptureSheet(currentKeyCode: pttKeyCode)
sheet.onComplete = { [weak self] keyCode in
guard let self, let keyCode else { return }
self.pttKeyCode = keyCode
self.pttKeyLabel.stringValue = "(\(keyCodeName(keyCode)))"
}
presentSheet(sheet)
}
@objc private func refreshDevicesClicked() { loadInputDevices() }
private func loadInputDevices() {
let devices = client.listDevices(.input)
devicePicker.removeAllItems()
for d in devices {
let item = NSMenuItem(title: d.name, action: nil, keyEquivalent: "")
item.representedObject = d.id
devicePicker.menu?.addItem(item)
}
if let def = devices.first(where: { $0.isDefault }) {
devicePicker.select(devicePicker.item(withTitle: def.name))
} else if devicePicker.numberOfItems > 0 {
devicePicker.selectItem(at: 0)
}
}
private func selectedDeviceId() -> String? { devicePicker.selectedItem?.representedObject as? String }
private func isDefaultDevice(_ id: String) -> Bool {
client.listDevices(.input).first(where: { $0.id == id })?.isDefault == true
}
private func currentInputMode() -> VoiceCatInputMode {
switch inputModeControl.selectedSegment {
case 1: return .pushToTalk
case 2: return .alwaysOn
default: return .voiceActivation
}
}
private func vadThresholdFromSlider() -> Float {
0.1 * (1.0 - Float(vadSlider.doubleValue - 1.0) / 99.0)
}
// MARK: - Text send
@objc private func sendClicked() {
let msg = composeField.stringValue.trimmingCharacters(in: .whitespaces)
guard !msg.isEmpty else { return }
if let targetId = scopePicker.selectedItem?.representedObject as? UInt32 {
client.sendText(scope: .private, targetId: targetId, text: msg)
} else {
guard currentChannelId != 0 else { return }
client.sendText(scope: .channel, targetId: currentChannelId, text: msg)
}
composeField.stringValue = ""
}
// MARK: - M5: Moderation helpers
private func moveUser(_ user: User) {
let sheet = MoveUserSheet(channels: channels, currentChannelId: user.channelId)
sheet.onComplete = { [weak self] channelId in
guard let channelId else { return }
self?.client.moveUser(user.id, toChannel: channelId)
}
presentSheet(sheet)
}
private func kickUser(_ user: User) {
let sheet = InputSheet(title: "Kick user", prompt: "Reason:", defaultValue: "Kicked by admin")
sheet.onComplete = { [weak self] reason in
self?.client.kickUser(user.id, reason: reason)
}
presentSheet(sheet)
}
private func banUser(_ user: User) {
let sheet = BanUserSheet(nickname: user.nickname)
sheet.onComplete = { [weak self] (reason, expiresMs) in
self?.client.banUser(user.id, reason: reason, expiresUnixMs: expiresMs)
}
presentSheet(sheet)
}
private func setPermissions(_ user: User) {
let sheet = PermissionsSheet(nickname: user.nickname, current: Permissions(
canCreateTempChannel: false, canKick: false, canBan: false,
canMoveUsers: false, canAdminAccounts: false, isAdmin: false))
sheet.onComplete = { [weak self] perms in
guard let perms else { return }
self?.client.setPermission(user.id, perms: perms)
}
presentSheet(sheet)
}
private func toggleServerMute(_ user: User) {
client.setServerMute(user.id, muted: !user.serverMuted, deafened: user.serverDeafened)
}
private func toggleServerDeafen(_ user: User) {
client.setServerMute(user.id, muted: user.serverMuted, deafened: !user.serverDeafened)
}
// MARK: - Helpers
private func nickname(for userId: UInt32) -> String {
if userId == selfUserId { return nickname }
return users[userId]?.nickname ?? "User#\(userId)"
}
private func presentSheet(_ vc: NSViewController) {
if let cvc = window?.contentViewController {
cvc.presentAsSheet(vc)
} else {
let cvc = NSViewController()
cvc.view = window!.contentView!
window?.contentViewController = cvc
cvc.presentAsSheet(vc)
}
}
private func selectedUserInTable() -> User? {
let row = userTableView.selectedRow
guard row >= 0, row < displayedUsers.count else { return nil }
return displayedUsers[row]
}
// MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
if let mon = pttMonitor { NSEvent.removeMonitor(mon) }
if let item = adminMenuItem { NSApp.mainMenu?.removeItem(item) }
client.setPushToTalk(false)
if screenStreamId != 0 { client.stopStream(screenStreamId) }
if micStreamId != 0 { client.stopStream(micStreamId) }
client.onLevel = nil
client.onEvent = nil
client.disconnect()
}
}
// MARK: - NSOutlineView DataSource / Delegate
extension MainWindowController: NSOutlineViewDataSource, NSOutlineViewDelegate {
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if item == nil { return channelTree.count }
return (item as! ChannelNode).children.count
}
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
if item == nil { return channelTree[index] }
return (item as! ChannelNode).children[index]
}
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
!(item as! ChannelNode).children.isEmpty
}
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
let node = item as! ChannelNode
let ch = node.channel
var label = ch.name
if ch.passwordProtected { label += " [pw]" }
if ch.id == currentChannelId { label += "" }
let id = NSUserInterfaceItemIdentifier("channelCell")
let cell = outlineView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
?? makeTextCellView(identifier: id)
cell.textField?.stringValue = label
cell.setAccessibilityLabel(label)
return cell
}
func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { true }
}
// MARK: - NSTableView DataSource / Delegate
extension MainWindowController: NSTableViewDataSource, NSTableViewDelegate {
func numberOfRows(in tableView: NSTableView) -> Int {
tableView === userTableView ? displayedUsers.count : activityLog.count
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let id = NSUserInterfaceItemIdentifier("cell")
let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
?? makeTextCellView(identifier: id)
if tableView === userTableView {
let user = displayedUsers[row]
var label = user.nickname
if user.id == selfUserId { label += " (you)" }
if talkingUsers.contains(user.id) { label += " (talking)" }
if user.selfMicMuted || user.serverMuted { label += " (muted)" }
if user.selfDeafened || user.serverDeafened { label += " (deafened)" }
cell.textField?.stringValue = label
cell.setAccessibilityLabel(label)
} else {
cell.textField?.stringValue = activityLog[row]
}
return cell
}
private func makeTextCellView(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView {
let cell = NSTableCellView()
cell.identifier = identifier
let tf = NSTextField(labelWithString: "")
tf.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(tf); cell.textField = tf
NSLayoutConstraint.activate([
tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4),
tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
])
return cell
}
}
// MARK: - NSMenuDelegate (context menus)
extension MainWindowController: NSMenuDelegate {
func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
if menu === channelOutlineView.menu {
buildChannelContextMenu(menu)
} else if menu === userTableView.menu {
buildUserContextMenu(menu)
}
}
private func buildChannelContextMenu(_ menu: NSMenu) {
let row = channelOutlineView.clickedRow
if row >= 0, let node = channelOutlineView.item(atRow: row) as? ChannelNode {
menu.addItem(withTitle: "Join", action: #selector(channelContextJoin), keyEquivalent: "").target = self
menu.addItem(.separator())
if ownPermissions.isAdmin {
let edit = menu.addItem(withTitle: "Edit channel…", action: #selector(channelContextEdit), keyEquivalent: "")
edit.target = self; edit.representedObject = node
let del = menu.addItem(withTitle: "Delete channel…", action: #selector(channelContextDelete), keyEquivalent: "")
del.target = self; del.representedObject = node
menu.addItem(.separator())
}
}
if ownPermissions.canCreateTempChannel || ownPermissions.isAdmin {
menu.addItem(withTitle: "Create channel…", action: #selector(channelContextCreate), keyEquivalent: "").target = self
}
}
private func buildUserContextMenu(_ menu: NSMenu) {
let row = userTableView.clickedRow
guard row >= 0, row < displayedUsers.count else { return }
let user = displayedUsers[row]
let isSelf = user.id == selfUserId
menu.addItem(withTitle: "Adjust volume and noise settings…",
action: #selector(userContextTune), keyEquivalent: "").target = self
if !isSelf {
menu.addItem(.separator())
if ownPermissions.canMoveUsers || ownPermissions.isAdmin {
menu.addItem(withTitle: "Move to channel…", action: #selector(userContextMove), keyEquivalent: "").target = self
}
if ownPermissions.canKick || ownPermissions.isAdmin {
menu.addItem(withTitle: "Kick…", action: #selector(userContextKick), keyEquivalent: "").target = self
}
if ownPermissions.canBan || ownPermissions.isAdmin {
menu.addItem(withTitle: "Ban…", action: #selector(userContextBan), keyEquivalent: "").target = self
}
if ownPermissions.isAdmin {
menu.addItem(.separator())
menu.addItem(withTitle: user.serverMuted ? "Server unmute" : "Server mute",
action: #selector(userContextServerMute), keyEquivalent: "").target = self
menu.addItem(withTitle: user.serverDeafened ? "Server undeafen" : "Server deafen",
action: #selector(userContextServerDeafen), keyEquivalent: "").target = self
menu.addItem(withTitle: "Set permissions…", action: #selector(userContextPermissions), keyEquivalent: "").target = self
}
}
}
@objc private func channelContextJoin() {
guard let node = channelOutlineView.item(atRow: channelOutlineView.clickedRow) as? ChannelNode else { return }
joinChannelRequest(node.channel.id)
}
@objc private func channelContextCreate() {
let sheet = ChannelEditSheet(channels: channels, editing: nil)
sheet.onComplete = { [weak self] info in
guard let info else { return }
self?.client.createChannel(info)
}
presentSheet(sheet)
}
@objc private func channelContextEdit(_ sender: NSMenuItem) {
guard let node = sender.representedObject as? ChannelNode else { return }
let ch = node.channel
let info = ChannelEdit(id: ch.id, parentId: ch.parentId, name: ch.name,
topic: ch.topic, passwordProtected: ch.passwordProtected,
password: nil, maxUsers: ch.maxUsers,
sortOrder: 0, audio: AudioConfig())
let sheet = ChannelEditSheet(channels: channels, editing: info)
sheet.onComplete = { [weak self] edited in
guard let edited else { return }
self?.client.editChannel(edited)
}
presentSheet(sheet)
}
@objc private func channelContextDelete(_ sender: NSMenuItem) {
guard let node = sender.representedObject as? ChannelNode else { return }
let alert = NSAlert()
alert.messageText = "Delete channel?"
alert.informativeText = "Delete \"\(node.channel.name)\"? This cannot be undone."
alert.addButton(withTitle: "Delete"); alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
guard let window else { return }
alert.beginSheetModal(for: window) { [weak self] response in
if response == .alertFirstButtonReturn {
self?.client.deleteChannel(node.channel.id)
}
}
}
@objc private func userContextTune() { openUserTuning(row: userTableView.clickedRow) }
@objc private func userContextMove() {
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
moveUser(u)
}
@objc private func userContextKick() {
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
kickUser(u)
}
@objc private func userContextBan() {
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
banUser(u)
}
@objc private func userContextServerMute() {
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
toggleServerMute(u)
}
@objc private func userContextServerDeafen() {
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
toggleServerDeafen(u)
}
@objc private func userContextPermissions() {
guard let u = selectedUserAt(userTableView.clickedRow) else { return }
setPermissions(u)
}
private func selectedUserAt(_ row: Int) -> User? {
guard row >= 0, row < displayedUsers.count else { return nil }
return displayedUsers[row]
}
}
// MARK: - PTT key name helper
private func keyCodeName(_ keyCode: UInt16) -> String {
let map: [UInt16: String] = [
0x60: "F5", 0x61: "F6", 0x62: "F7", 0x63: "F3", 0x64: "F8", 0x65: "F9",
0x67: "F11", 0x69: "F13", 0x6A: "F16", 0x6B: "F14", 0x6D: "F10", 0x6F: "F12",
0x71: "F15", 0x72: "Help", 0x73: "Home", 0x74: "PgUp", 0x75: "Del",
0x76: "F4", 0x77: "End", 0x78: "F2", 0x79: "PgDn", 0x7A: "F1",
]
return map[keyCode] ?? "Key\(keyCode)"
}