feat(macos): VoiceCatMac AppKit client + fix xcodebuild
The previous "shipped" claim was false — xcodebuild had never been run and the macOS app source was never committed. This commit adds the 17 Swift source files + xcodeproj and fixes three real defect classes so Debug and Release both build clean: 1. MainWindowController.swift compile errors: - NSAccessibility.post arg order (element:notification:userInfo:) - NSAccessibilityPriorityMedium -> NSAccessibilityPriorityLevel.medium - StreamSummary.streamId -> .id (Identifiable conformance) - drop redundant VoiceCatResult.description extension 2. Linker: add -lc++ to OTHER_LDFLAGS (libvoicecat-fat.a is C++20; pure-Swift app target has no .cpp sources so libc++ wasn't pulled in — swift test passed because Package.swift testTarget has linkerSettings: c++). 3. Release config: add ONLY_ACTIVE_ARCH=YES (XCFramework only has arm64). Verified: clean Debug + Release builds, otool -L shows libc++.1.dylib, nm shows _vc_client_create/_vc_version_string, app launches and runs.
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
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
|
||||
|
||||
// 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)
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user