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.nickname?.isEmpty == false ? server.nickname! : 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.nickname?.isEmpty == false ? server.nickname! : 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