Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces, dead nick_buf_ptr, a no-op --print-config flag now implemented for real), fixes stale/misleading comments (channel passwords are no longer a "future M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention, an incomplete BanRecord::subject_type doc, and a smoke test pointing at a build/m1-dev preset that no longer exists), strips internal M1-M5 milestone jargon from comments now that the roadmap is done, trims comments that just restated the following line, and consolidates a few "why" explanations that were duplicated 2-3 times in the same file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
375 lines
14 KiB
Swift
375 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 }
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
statusLabel.textColor = .secondaryLabelColor
|
|
statusLabel.setAccessibilityLabel("Connection status")
|
|
contentView.addSubview(statusLabel)
|
|
|
|
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
|
|
}
|
|
}
|