diff --git a/Sources/Menubar/AstationApp.swift b/Sources/Menubar/AstationApp.swift index c69e409..9e8bf42 100644 --- a/Sources/Menubar/AstationApp.swift +++ b/Sources/Menubar/AstationApp.swift @@ -97,6 +97,7 @@ class AstationApp: NSObject, NSApplicationDelegate { self?.statusBarController.showStatus() } hotkeyManager?.registerHotkeys() + statusBarController.hotkeyManager = hotkeyManager // Connect to relay using this Astation's identity, so Atem TUI can auto-reconnect // after the first `atem pair` without needing to pair again. diff --git a/Sources/Menubar/AstationHubManager.swift b/Sources/Menubar/AstationHubManager.swift index 58a9176..de88de1 100644 --- a/Sources/Menubar/AstationHubManager.swift +++ b/Sources/Menubar/AstationHubManager.swift @@ -6,6 +6,7 @@ import Network class AstationHubManager: ObservableObject { @Published var connectedClients: [ConnectedClient] = [] @Published var projects: [AgoraProject] = [] + @Published var selectedProject: AgoraProject? @Published var isClaudeRunning = false @Published var startTime = Date() @Published var voiceActive = false @@ -264,11 +265,19 @@ class AstationHubManager: ObservableObject { Task { let token: String do { token = try await tokenProvider.validToken() } - catch { + catch SsoError.notSignedIn { await MainActor.run { self.projects = [] - self.projectLoadError = error.localizedDescription - Log.info("[AstationHub] Cannot load projects: \(error.localizedDescription)") + self.projectLoadError = "Not signed in. Open Settings → Sign in with Agora." + Log.info("[AstationHub] Cannot load projects: not signed in") + } + return + } catch { + await MainActor.run { + self.projects = [] + self.projectLoadError = "Session expired — please sign in again." + NotificationCenter.default.post(name: .credentialsChanged, object: nil) + Log.info("[AstationHub] Session expired, cleared: \(error.localizedDescription)") } return } @@ -278,6 +287,9 @@ class AstationHubManager: ObservableObject { await MainActor.run { self.projects = fetched self.projectLoadError = nil + if self.selectedProject == nil || !fetched.contains(where: { $0.id == self.selectedProject?.id }) { + self.selectedProject = fetched.first + } Log.info(" Loaded \(fetched.count) projects from BFF") } } catch AgoraAPIError.unauthorized { @@ -305,6 +317,20 @@ class AstationHubManager: ObservableObject { func getProjects() -> [AgoraProject] { return projects } + + /// The project to use for RTC, ConvoAI, and token generation. + var effectiveProject: AgoraProject? { + selectedProject ?? projects.first + } + + func selectProject(id: String) { + guard let project = projects.first(where: { $0.id == id }) else { + Log.warn("[AstationHub] selectProject: no project with id \(id)") + return + } + selectedProject = project + Log.info("[AstationHub] Selected project: \(project.name)") + } // MARK: - Token Management @@ -322,7 +348,7 @@ class AstationHubManager: ObservableObject { if let projectId = projectId { project = projects.first(where: { $0.id == projectId || $0.vendorKey == projectId }) } else { - project = projects.first + project = effectiveProject } guard let project = project, !project.signKey.isEmpty else { diff --git a/Sources/Menubar/HotkeyManager.swift b/Sources/Menubar/HotkeyManager.swift index 4cb2bf3..74a7e9a 100644 --- a/Sources/Menubar/HotkeyManager.swift +++ b/Sources/Menubar/HotkeyManager.swift @@ -16,6 +16,9 @@ class HotkeyManager { /// Called when Ctrl+Shift+V is pressed (video toggle). var onVideoToggle: (() -> Void)? + private(set) var voiceHotkeyFailed = false + private(set) var videoHotkeyFailed = false + private static let voiceHotkeyID = UInt32(1) private static let videoHotkeyID = UInt32(2) private static let hotkeySignature = OSType(0x4154454D) // "ATEM" in ASCII @@ -70,7 +73,8 @@ class HotkeyManager { &voiceHotkeyRef ) if voiceResult != noErr { - print("[HotkeyManager] Failed to register Ctrl+V: \(voiceResult)") + voiceHotkeyFailed = true + Log.warn("[HotkeyManager] Failed to register Ctrl+V hotkey: \(voiceResult)") } // Ctrl+Shift+V → video toggle @@ -87,10 +91,11 @@ class HotkeyManager { &videoHotkeyRef ) if videoResult != noErr { - print("[HotkeyManager] Failed to register Ctrl+Shift+V: \(videoResult)") + videoHotkeyFailed = true + Log.warn("[HotkeyManager] Failed to register Ctrl+Shift+V hotkey: \(videoResult)") } - print("[HotkeyManager] Global hotkeys registered: Ctrl+V (voice), Ctrl+Shift+V (video)") + Log.info("[HotkeyManager] Global hotkeys registered: Ctrl+V (voice), Ctrl+Shift+V (video)") } /// Unregister all hotkeys. @@ -103,7 +108,7 @@ class HotkeyManager { UnregisterEventHotKey(ref) videoHotkeyRef = nil } - print("[HotkeyManager] Hotkeys unregistered") + Log.info("[HotkeyManager] Hotkeys unregistered") } // MARK: - Carbon Event Handler diff --git a/Sources/Menubar/JoinChannelWindowController.swift b/Sources/Menubar/JoinChannelWindowController.swift index 5dd135a..1428d2a 100644 --- a/Sources/Menubar/JoinChannelWindowController.swift +++ b/Sources/Menubar/JoinChannelWindowController.swift @@ -64,8 +64,9 @@ class JoinChannelWindowController: NSObject, NSWindowDelegate { contentView.addSubview(channelLabel) channelField = NSTextField(frame: NSRect(x: 110, y: y, width: fieldWidth, height: 22)) - channelField.placeholderString = "e.g. astation-default" - channelField.stringValue = "astation-default" + let defaultChannel = "astation-\(Self.randomHex(8))" + channelField.placeholderString = "e.g. astation-a1b2c3d4" + channelField.stringValue = defaultChannel contentView.addSubview(channelField) y -= 40 @@ -219,6 +220,10 @@ class JoinChannelWindowController: NSObject, NSWindowDelegate { joinButton?.isEnabled = false } else { projectPicker?.addItems(withTitles: projects.map { $0.name }) + if let selected = hubManager.selectedProject, + let idx = projects.firstIndex(where: { $0.id == selected.id }) { + projectPicker?.selectItem(at: idx) + } projectPicker?.isEnabled = true joinButton?.isEnabled = true } @@ -338,6 +343,10 @@ class JoinChannelWindowController: NSObject, NSWindowDelegate { ) } + private static func randomHex(_ length: Int) -> String { + (0..= 0 else { return } + let projects = hubManager.getProjects() + guard row < projects.count else { return } + hubManager.selectProject(id: projects[row].id) + tableView.reloadData() + showSelectionToast("Selected: \(projects[row].name)") + } + + private func showSelectionToast(_ text: String) { + // Cancel any in-flight animation so the new toast appears immediately. + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0 + ctx.allowsImplicitAnimation = false + selectionToast.animator().alphaValue = 1 + } completionHandler: { [weak self] in + guard let self else { return } + self.selectionToast.stringValue = text + self.selectionToast.alphaValue = 1 + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 4.0 + ctx.timingFunction = CAMediaTimingFunction(name: .easeIn) + self.selectionToast.animator().alphaValue = 0 + } + } + } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { @@ -193,24 +246,34 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { switch colId { case nameColId: - return makeLabelCell(tableView, id: "nameCell", text: project.name) + let isSelected = hubManager.selectedProject?.id == project.id + let displayName = isSelected ? "\u{2713} \(project.name)" : " \(project.name)" + return makeLabelCell(tableView, id: "nameCell", text: displayName) case appIdColId: return makeTextWithCopyCell( - tableView, id: "appIdCell", text: project.vendorKey, row: row, + tableView, id: "appIdCell", text: Self.masked(project.vendorKey), row: row, copyAction: #selector(copyAppId(_:))) case certColId: let isVisible = certificateVisibility[project.vendorKey] ?? false - let displayText = isVisible ? project.signKey : String(repeating: "\u{2022}", count: 12) + let displayText = isVisible ? Self.masked(project.signKey) : String(repeating: "\u{2022}", count: 12) let toggleTitle = isVisible ? "Hide" : "Show" return makeCertCell( tableView, id: "certCell", text: displayText, toggleTitle: toggleTitle, row: row) case statusColId: let cell = makeLabelCell(tableView, id: "statusCell", text: project.status) - if let textField = cell as? NSTextField { - textField.textColor = project.status == "active" ? .systemGreen : .secondaryLabelColor + if let cellView = cell as? NSTableCellView { + cellView.textField?.textColor = project.status == "active" ? .systemGreen : .secondaryLabelColor + } + return cell + + case createdColId: + let dateStr = Self.formatDate(project.created) + let cell = makeLabelCell(tableView, id: "createdCell", text: dateStr) + if let cellView = cell as? NSTableCellView { + cellView.textField?.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular) } return cell @@ -219,19 +282,45 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { } } + /// Show first 6 + "..." + last 6 chars. Short strings pass through unchanged. + private static func masked(_ s: String) -> String { + guard s.count > 16 else { return s } + return "\(s.prefix(6))...\(s.suffix(6))" + } + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + return f + }() + + private static func formatDate(_ unix: UInt64) -> String { + guard unix > 0 else { return "—" } + return dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(unix))) + } + // MARK: - Cell Factories private func makeLabelCell(_ tableView: NSTableView, id: String, text: String) -> NSView { let cellId = NSUserInterfaceItemIdentifier(id) - if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTextField { - existing.stringValue = text + if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTableCellView { + existing.textField?.stringValue = text return existing } + let cell = NSTableCellView() + cell.identifier = cellId let label = NSTextField(labelWithString: text) - label.identifier = cellId label.font = NSFont.systemFont(ofSize: 12) label.lineBreakMode = .byTruncatingTail - return label + label.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(label) + cell.textField = label + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 2), + label.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -2), + label.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell } private func makeTextWithCopyCell( @@ -241,7 +330,7 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { let label = NSTextField(labelWithString: text) label.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) - label.lineBreakMode = .byTruncatingTail + label.lineBreakMode = .byClipping label.translatesAutoresizingMaskIntoConstraints = false container.addSubview(label) @@ -271,7 +360,7 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate { let label = NSTextField(labelWithString: text) label.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) - label.lineBreakMode = .byTruncatingTail + label.lineBreakMode = .byClipping label.translatesAutoresizingMaskIntoConstraints = false container.addSubview(label) diff --git a/Sources/Menubar/RTCJoinOptions.swift b/Sources/Menubar/RTCJoinOptions.swift index 988dad7..c98e344 100644 --- a/Sources/Menubar/RTCJoinOptions.swift +++ b/Sources/Menubar/RTCJoinOptions.swift @@ -11,12 +11,12 @@ enum RTCEncryptionMode: Int32, CaseIterable { case aes128Xts = 1 case sm4_128Ecb = 4 - static let manualJoinDefault: RTCEncryptionMode = .aes256Gcm2 + static let manualJoinDefault: RTCEncryptionMode = .none static let manualJoinPickerOptions: [RTCEncryptionMode] = [ + .none, .aes256Gcm2, .aes128Gcm2, - .none, .aes256Gcm, .aes128Gcm, .aes256Xts, diff --git a/Sources/Menubar/StatusBarController.swift b/Sources/Menubar/StatusBarController.swift index fd5c6fd..1881aec 100644 --- a/Sources/Menubar/StatusBarController.swift +++ b/Sources/Menubar/StatusBarController.swift @@ -11,6 +11,7 @@ class StatusBarController: NSObject, NSMenuDelegate { private lazy var projectsWindowController = ProjectsWindowController(hubManager: hubManager) private lazy var joinChannelWindowController = JoinChannelWindowController(hubManager: hubManager) private lazy var connectionsWindowController = ConnectionsWindowController(hubManager: hubManager) + var hotkeyManager: HotkeyManager? private var headerTapCount = 0 private var lastHeaderTapTime: Date? @@ -71,8 +72,14 @@ class StatusBarController: NSObject, NSMenuDelegate { statusItem.isEnabled = false statusMenu.addItem(statusItem) + let projectTitle: String + if let selected = hubManager.selectedProject { + projectTitle = "📋 Project: \(selected.name)" + } else { + projectTitle = "📋 Projects: \(systemStatus.projects) loaded" + } let projectsItem = NSMenuItem( - title: "📋 Projects: \(systemStatus.projects) loaded", + title: projectTitle, action: nil, keyEquivalent: "" ) @@ -108,17 +115,21 @@ class StatusBarController: NSObject, NSMenuDelegate { voiceOffItem.isEnabled = false statusMenu.addItem(voiceOffItem) - if hubManager.rtcManager.isInChannel { - let handsFreeItem = NSMenuItem( - title: "Start Hands-Free Mode", - action: #selector(startHandsFreeMode), - keyEquivalent: "" - ) - handsFreeItem.image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Hands-Free") - handsFreeItem.target = self - statusMenu.addItem(handsFreeItem) + if hotkeyManager?.voiceHotkeyFailed == true { + let warn = NSMenuItem(title: " ⚠ Ctrl+V hotkey unavailable (conflict)", action: nil, keyEquivalent: "") + warn.isEnabled = false + statusMenu.addItem(warn) } + let handsFreeItem = NSMenuItem( + title: "Start Hands-Free Mode", + action: #selector(startHandsFreeMode), + keyEquivalent: "" + ) + handsFreeItem.image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Hands-Free") + handsFreeItem.target = self + statusMenu.addItem(handsFreeItem) + case .ptt: let pttItem = NSMenuItem( title: vcm.isWaitingForResponse ? "Voice (PTT): Waiting for Claude..." : "Voice (PTT): Active", @@ -160,6 +171,12 @@ class StatusBarController: NSObject, NSMenuDelegate { videoItem.target = self statusMenu.addItem(videoItem) + if hotkeyManager?.videoHotkeyFailed == true { + let warn = NSMenuItem(title: " ⚠ Ctrl+Shift+V hotkey unavailable (conflict)", action: nil, keyEquivalent: "") + warn.isEnabled = false + statusMenu.addItem(warn) + } + statusMenu.addItem(NSMenuItem.separator()) // Connected Atems Section diff --git a/Sources/Menubar/VoiceCodingManager.swift b/Sources/Menubar/VoiceCodingManager.swift index 6695f54..f4f59c6 100644 --- a/Sources/Menubar/VoiceCodingManager.swift +++ b/Sources/Menubar/VoiceCodingManager.swift @@ -126,14 +126,13 @@ class VoiceCodingManager: NSObject { return } - let projects = hubManager.getProjects() - guard let project = projects.first else { + guard let project = hubManager.effectiveProject else { updateStage("Voice: No projects configured", autoHideAfter: 2.0) finishRtcJoin(success: false) return } - let channel = "astation-default" + let channel = "astation-\(Self.randomHex(8))" let uid = Int.random(in: 1000...9999) updateStage("Voice: Joining RTC…") hubManager.initializeRTC(appId: project.vendorKey) @@ -534,7 +533,7 @@ class VoiceCodingManager: NSObject { // MARK: - ConvoAI Agent private func createConvoAIAgent(sessionId: String) { - guard let project = hubManager.projects.first, + guard let project = hubManager.effectiveProject, let channel = hubManager.rtcManager.currentChannel, !project.vendorKey.isEmpty, !project.signKey.isEmpty @@ -582,7 +581,7 @@ class VoiceCodingManager: NSObject { private func stopConvoAIAgent() { guard let agentId = activeAgentId, - let project = hubManager.projects.first, + let project = hubManager.effectiveProject, let channel = hubManager.rtcManager.currentChannel, !project.vendorKey.isEmpty, !project.signKey.isEmpty @@ -651,6 +650,10 @@ class VoiceCodingManager: NSObject { } } + private static func randomHex(_ length: Int) -> String { + (0..