Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/Menubar/AstationApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 30 additions & 4 deletions Sources/Menubar/AstationHubManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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

Expand All @@ -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 {
Expand Down
13 changes: 9 additions & 4 deletions Sources/Menubar/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -103,7 +108,7 @@ class HotkeyManager {
UnregisterEventHotKey(ref)
videoHotkeyRef = nil
}
print("[HotkeyManager] Hotkeys unregistered")
Log.info("[HotkeyManager] Hotkeys unregistered")
}

// MARK: - Carbon Event Handler
Expand Down
13 changes: 11 additions & 2 deletions Sources/Menubar/JoinChannelWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -338,6 +343,10 @@ class JoinChannelWindowController: NSObject, NSWindowDelegate {
)
}

private static func randomHex(_ length: Int) -> String {
(0..<length).map { _ in String(format: "%x", Int.random(in: 0...15)) }.joined()
}

// MARK: - NSWindowDelegate

func windowWillClose(_ notification: Notification) {
Expand Down
123 changes: 106 additions & 17 deletions Sources/Menubar/ProjectsWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ class ProjectsWindowController: NSObject, NSWindowDelegate {
private let hubManager: AstationHubManager
private var tableView: NSTableView!
private var statusLabel: NSTextField!
private var selectionToast: NSTextField!
private var certificateVisibility: [String: Bool] = [:]

private let nameColId = NSUserInterfaceItemIdentifier("name")
private let appIdColId = NSUserInterfaceItemIdentifier("appId")
private let certColId = NSUserInterfaceItemIdentifier("cert")
private let statusColId = NSUserInterfaceItemIdentifier("status")
private let createdColId = NSUserInterfaceItemIdentifier("created")

init(hubManager: AstationHubManager) {
self.hubManager = hubManager
Expand Down Expand Up @@ -41,12 +43,25 @@ class ProjectsWindowController: NSObject, NSWindowDelegate {
let contentView = NSView(frame: window.contentView!.bounds)
contentView.autoresizingMask = [.width, .height]

// Header
// Header + selection toast
let headerLabel = NSTextField(labelWithString: "Projects")
headerLabel.font = NSFont.boldSystemFont(ofSize: 14)
headerLabel.frame = NSRect(x: 16, y: 410, width: 200, height: 24)
headerLabel.sizeToFit()
headerLabel.frame = NSRect(x: 16, y: 410, width: headerLabel.frame.width, height: headerLabel.frame.height)
contentView.addSubview(headerLabel)

selectionToast = NSTextField(labelWithString: "")
selectionToast.font = NSFont.boldSystemFont(ofSize: 14)
selectionToast.textColor = .systemBlue
selectionToast.alphaValue = 0
selectionToast.frame = NSRect(
x: headerLabel.frame.maxX + 8,
y: headerLabel.frame.origin.y,
width: 400,
height: headerLabel.frame.height
)
contentView.addSubview(selectionToast)

// Refresh button
let refreshButton = NSButton(
title: "Refresh", target: self, action: #selector(refreshProjects))
Expand All @@ -65,6 +80,10 @@ class ProjectsWindowController: NSObject, NSWindowDelegate {
let table = NSTableView()
table.rowHeight = 28
table.usesAlternatingRowBackgroundColors = true
table.allowsEmptySelection = true
table.allowsMultipleSelection = false
table.doubleAction = #selector(tableDoubleClicked)
table.target = self
table.delegate = self
table.dataSource = self

Expand All @@ -76,22 +95,28 @@ class ProjectsWindowController: NSObject, NSWindowDelegate {

let appIdCol = NSTableColumn(identifier: appIdColId)
appIdCol.title = "App ID"
appIdCol.width = 220
appIdCol.minWidth = 120
appIdCol.width = 160
appIdCol.minWidth = 100
table.addTableColumn(appIdCol)

let certCol = NSTableColumn(identifier: certColId)
certCol.title = "Certificate"
certCol.width = 240
certCol.width = 180
certCol.minWidth = 140
table.addTableColumn(certCol)

let statusCol = NSTableColumn(identifier: statusColId)
statusCol.title = "Status"
statusCol.width = 70
statusCol.width = 60
statusCol.minWidth = 50
table.addTableColumn(statusCol)

let createdCol = NSTableColumn(identifier: createdColId)
createdCol.title = "Created"
createdCol.width = 160
createdCol.minWidth = 150
table.addTableColumn(createdCol)

scrollView.documentView = table
contentView.addSubview(scrollView)
self.tableView = table
Expand Down Expand Up @@ -184,6 +209,34 @@ extension ProjectsWindowController: NSTableViewDataSource, NSTableViewDelegate {
return hubManager.getProjects().count
}

@objc private func tableDoubleClicked() {
let row = tableView.clickedRow
guard row >= 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?
{
Expand All @@ -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

Expand All @@ -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(
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading