Files
voice-cat/clients/apple/macOS/VoiceCatMac/Windows/ConnectWindowController.swift
Talon 69cd7d80ad fix(macos): retain MainWindowController so the client stays alive
The main window controller was created as a local variable in
ConnectWindowController.authSucceeded and never retained — ARC
deallocated it immediately, which destroyed the VoiceCatClient
(connection silently dropped), nil'd every button's weak target
(clicks did nothing), and killed event delivery (channel list,
messages, voice never worked). Symptom: TOFU (in the retained
connect controller) worked, but everything in the main window
was a zombie shell.

Fix: store the MainWindowController in a new field on
ConnectWindowController (which AppDelegate retains for the app's
lifetime). Also add NSLog diagnostics in deinit/bootstrap/handleEvent
so lifecycle and event delivery are observable from the terminal
or Console.app.
2026-06-18 15:52:13 +02:00

383 lines
14 KiB
Swift

import AppKit
import VoiceCatCore
final class ConnectWindowController: NSWindowController, NSWindowDelegate {
// MARK: - UI
private let serverTableView = NSTableView()
private let serverScrollView = NSScrollView()
private let addButton = NSButton()
private let editButton = NSButton()
private let removeButton = NSButton()
private let connectButton = NSButton()
private let statusLabel = NSTextField(labelWithString: "Select a server and click Connect.")
// MARK: - State
private var servers: [SavedServer] = ServerListStore.load()
private var client: VoiceCatClient?
private var identityDialogShown = false
private var mainWindowController: MainWindowController?
// MARK: - Init
init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 320),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
window.title = "VoiceCat — Connect"
window.center()
super.init(window: window)
window.delegate = self
buildUI()
refreshServerList()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - UI construction
private func buildUI() {
guard let contentView = window?.contentView else { return }
// Server list
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("server"))
col.title = "Saved Servers"
serverTableView.addTableColumn(col)
serverTableView.headerView = nil
serverTableView.dataSource = self
serverTableView.delegate = self
serverTableView.doubleAction = #selector(connectClicked)
serverTableView.target = self
serverTableView.setAccessibilityLabel("Saved servers")
serverScrollView.documentView = serverTableView
serverScrollView.hasVerticalScroller = true
serverScrollView.borderType = .bezelBorder
serverScrollView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(serverScrollView)
// Buttons row
configureButton(addButton, title: "Add…", action: #selector(addClicked))
configureButton(editButton, title: "Edit…", action: #selector(editClicked))
configureButton(removeButton, title: "Remove", action: #selector(removeClicked))
let buttonStack = NSStackView(views: [addButton, editButton, removeButton, NSView()])
buttonStack.orientation = .horizontal
buttonStack.spacing = 8
buttonStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(buttonStack)
// Status
statusLabel.translatesAutoresizingMaskIntoConstraints = false
statusLabel.textColor = .secondaryLabelColor
statusLabel.setAccessibilityLabel("Connection status")
contentView.addSubview(statusLabel)
// Connect button
connectButton.title = "Connect"
connectButton.bezelStyle = .rounded
connectButton.keyEquivalent = "\r"
connectButton.target = self
connectButton.action = #selector(connectClicked)
connectButton.translatesAutoresizingMaskIntoConstraints = false
connectButton.setAccessibilityLabel("Connect to selected server")
contentView.addSubview(connectButton)
NSLayoutConstraint.activate([
serverScrollView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
serverScrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
serverScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
serverScrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -8),
buttonStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
buttonStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
buttonStack.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -12),
statusLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
statusLabel.trailingAnchor.constraint(equalTo: connectButton.leadingAnchor, constant: -8),
statusLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16),
connectButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
connectButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12),
connectButton.widthAnchor.constraint(equalToConstant: 90),
])
}
private func configureButton(_ button: NSButton, title: String, action: Selector) {
button.title = title
button.bezelStyle = .rounded
button.target = self
button.action = action
button.translatesAutoresizingMaskIntoConstraints = false
}
// MARK: - Server list management
private func refreshServerList() {
serverTableView.reloadData()
updateButtonStates()
}
private func updateButtonStates() {
let hasSelection = serverTableView.selectedRow >= 0
connectButton.isEnabled = hasSelection
editButton.isEnabled = hasSelection
removeButton.isEnabled = hasSelection
}
@objc private func addClicked() {
let sheet = AddServerSheet(editing: nil)
sheet.onComplete = { [weak self] server in
guard let self, let server else { return }
self.servers.append(server)
ServerListStore.save(self.servers)
self.refreshServerList()
let newRow = self.servers.count - 1
self.serverTableView.selectRowIndexes(IndexSet(integer: newRow), byExtendingSelection: false)
}
presentSheet(sheet)
}
@objc private func editClicked() {
let row = serverTableView.selectedRow
guard row >= 0 else { return }
let existing = servers[row]
let sheet = AddServerSheet(editing: existing)
sheet.onComplete = { [weak self] server in
guard let self, let server else { return }
self.servers[row] = server
ServerListStore.save(self.servers)
self.refreshServerList()
}
presentSheet(sheet)
}
@objc private func removeClicked() {
let row = serverTableView.selectedRow
guard row >= 0 else { return }
let server = servers[row]
let alert = NSAlert()
alert.messageText = "Remove server?"
alert.informativeText = "Remove '\(server.displayString)' from the saved-server list?"
alert.addButton(withTitle: "Remove")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
guard let window else { return }
alert.beginSheetModal(for: window) { [weak self] response in
guard response == .alertFirstButtonReturn, let self else { return }
if let tag = self.servers[row].keychainTag {
ServerListStore.deletePassword(tag: tag)
}
self.servers.remove(at: row)
ServerListStore.save(self.servers)
self.refreshServerList()
}
}
// MARK: - Connect flow
@objc private func connectClicked() {
let row = serverTableView.selectedRow
guard row >= 0 else { return }
startConnect(server: servers[row])
}
private func startConnect(server: SavedServer) {
setBusy(true)
setStatus("Connecting…")
try? FileManager.default.createDirectory(atPath: ServerListStore.appSupportURL.path,
withIntermediateDirectories: true)
let config = VoiceCatConfig(
clientName: "VoiceCat-macOS",
clientVersion: "0.0.1",
logLevel: .info,
tofuStorePath: ServerListStore.tofuStorePath
)
let newClient = VoiceCatClient(config: config)
client = newClient
identityDialogShown = false
newClient.onEvent = { [weak self] event in
self?.handleEvent(event, server: server)
}
let connectResult = newClient.connect(host: server.host, port: server.port)
guard connectResult == .ok else {
setStatus("Connect failed: \(connectResult)")
cleanupFailedAttempt()
return
}
switch server.authMode {
case .guest:
let nick = server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName()
newClient.authenticateGuest(nick)
case .password:
let username = server.savedUsername ?? ""
if let tag = server.keychainTag, let password = ServerListStore.loadPassword(tag: tag) {
newClient.authenticateUser(username, password: password)
} else {
let pwSheet = PasswordPromptSheet(prompt: "Password for \(username)@\(server.host):")
pwSheet.onComplete = { [weak self, weak newClient] password in
guard let self, let newClient else { return }
guard let password else {
self.setStatus("Cancelled.")
self.cleanupFailedAttempt()
return
}
newClient.authenticateUser(username, password: password)
}
presentSheet(pwSheet)
}
}
}
private func handleEvent(_ event: VoiceCatEvent, server: SavedServer) {
switch event.type {
case .connectionState:
let label: String
switch event.connectionState {
case .connecting: label = "Connecting…"
case .tlsHandshake: label = "TLS handshake…"
case .verifyingIdentity: label = "Verifying server identity…"
case .authenticating: label = "Authenticating…"
case .connected: label = "Connected."
default: label = statusLabel.stringValue
}
setStatus(label)
case .serverIdentity:
handleServerIdentity(tofuStatus: event.tofuStatus ?? .firstConnect,
displayText: client?.getServerIdentityDisplay() ?? "")
case .authResult:
if event.result == .ok {
let nickname = server.authMode == .guest
? (server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName())
: (server.savedUsername ?? "")
authSucceeded(client: client!, selfUserId: event.userId, nickname: nickname)
} else {
setStatus("Authentication failed: \(event.text ?? event.result.description)")
cleanupFailedAttempt()
}
case .disconnected:
if client != nil {
setStatus(event.text.map { "Disconnected: \($0)" } ?? "Disconnected.")
cleanupFailedAttempt()
}
default:
break
}
}
private func handleServerIdentity(tofuStatus: VoiceCatTofuStatus, displayText: String) {
if identityDialogShown { return }
if tofuStatus == .matched {
client?.confirmServerIdentity(accept: true)
return
}
identityDialogShown = true
let sheet = ServerIdentitySheet(tofuStatus: tofuStatus, displayFingerprint: displayText)
sheet.onComplete = { [weak self] accepted in
self?.client?.confirmServerIdentity(accept: accepted)
if !accepted {
self?.setStatus("Server identity rejected.")
self?.cleanupFailedAttempt()
}
}
presentSheet(sheet)
}
private func authSucceeded(client: VoiceCatClient, selfUserId: UInt32, nickname: String) {
client.onEvent = nil
let mainWC = MainWindowController(client: client, selfUserId: selfUserId, nickname: nickname)
self.mainWindowController = mainWC
mainWC.showWindow(nil)
self.client = nil
close()
}
private func cleanupFailedAttempt() {
client?.onEvent = nil
client = nil
setBusy(false)
}
// MARK: - Helpers
private func setStatus(_ text: String) {
statusLabel.stringValue = text
}
private func setBusy(_ busy: Bool) {
serverTableView.isEnabled = !busy
addButton.isEnabled = !busy
connectButton.isEnabled = !busy && serverTableView.selectedRow >= 0
editButton.isEnabled = !busy && serverTableView.selectedRow >= 0
removeButton.isEnabled = !busy && serverTableView.selectedRow >= 0
}
private func presentSheet(_ vc: NSViewController) {
if let parent = window?.contentViewController {
parent.presentAsSheet(vc)
} else {
let contentVC = NSViewController()
contentVC.view = window!.contentView!
window?.contentViewController = contentVC
contentVC.presentAsSheet(vc)
}
}
// MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
client?.onEvent = nil
client = nil
}
}
// MARK: - NSTableViewDataSource / Delegate
extension ConnectWindowController: NSTableViewDataSource, NSTableViewDelegate {
func numberOfRows(in tableView: NSTableView) -> Int { servers.count }
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let id = NSUserInterfaceItemIdentifier("serverCell")
let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
?? makeTableCellView(identifier: id)
cell.textField?.stringValue = servers[row].displayString
return cell
}
func tableViewSelectionDidChange(_ notification: Notification) {
updateButtonStates()
}
private func makeTableCellView(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: - Helper