215 lines
7 KiB
Swift
215 lines
7 KiB
Swift
import AppKit
|
|
import Combine
|
|
import LilithLogging
|
|
import LilithMenuBar
|
|
|
|
private let log = AppLogger.logger(for: "MenuBar")
|
|
|
|
/// Manages the menu bar icon and menu with rich status display.
|
|
/// Observes SyncManager state to update icon color, sync status, stats, and last sync time.
|
|
@MainActor
|
|
final class MenuBarController {
|
|
private var menuBarAgent: MenuBarAgent?
|
|
private let syncManager: SyncManager
|
|
private let webServer: LocalWebServer
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init(syncManager: SyncManager, webServer: LocalWebServer) {
|
|
self.syncManager = syncManager
|
|
self.webServer = webServer
|
|
}
|
|
|
|
func install() {
|
|
let agent = MenuBarAgent(
|
|
icon: TrayIconProvider.icon(
|
|
isSyncing: syncManager.isSyncing,
|
|
syncError: syncManager.syncError,
|
|
isResetting: syncManager.isResetting
|
|
),
|
|
menu: buildMenuItems()
|
|
)
|
|
agent.install()
|
|
menuBarAgent = agent
|
|
observeSyncState()
|
|
log.info("Menu bar installed")
|
|
}
|
|
|
|
func rebuildMenu() {
|
|
menuBarAgent?.updateMenu(buildMenuItems())
|
|
}
|
|
|
|
// MARK: - Menu Construction
|
|
|
|
private func buildMenuItems() -> [MenuBarItem] {
|
|
var items: [MenuBarItem] = []
|
|
|
|
// Status line
|
|
let statusText = buildStatusText()
|
|
items.append(.action(title: statusText, key: "") {})
|
|
|
|
// Last sync line
|
|
let lastSyncText = buildLastSyncText()
|
|
items.append(.action(title: lastSyncText, key: "") {})
|
|
|
|
// Stats line
|
|
let statsText = buildStatsText()
|
|
items.append(.action(title: statsText, key: "") {})
|
|
|
|
items.append(.separator)
|
|
|
|
// Error-specific actions
|
|
if syncManager.syncError == .fullDiskAccessRequired {
|
|
items.append(.action(title: "Open Full Disk Access Settings\u{2026}", key: "") { [weak self] in
|
|
log.info("Opening Full Disk Access settings")
|
|
self?.syncManager.openFullDiskAccessSettings()
|
|
})
|
|
items.append(.action(title: "Retry Connection", key: "r") { [weak self] in
|
|
log.info("Retrying connection after FDA grant")
|
|
self?.syncManager.retryConnection()
|
|
})
|
|
items.append(.separator)
|
|
} else if syncManager.syncError != .none {
|
|
items.append(.action(title: "Retry Connection", key: "r") { [weak self] in
|
|
log.info("Retrying connection")
|
|
self?.syncManager.retryConnection()
|
|
})
|
|
items.append(.separator)
|
|
}
|
|
|
|
// Sync controls (only when connected)
|
|
if syncManager.syncError == .none {
|
|
items.append(.action(title: "Sync Now", key: "s") { [weak self] in
|
|
log.info("Manual sync triggered")
|
|
self?.syncManager.syncNow()
|
|
})
|
|
items.append(.action(title: "Force Full Resync", key: "") { [weak self] in
|
|
log.info("Force full resync triggered")
|
|
self?.syncManager.resetAndResync()
|
|
})
|
|
items.append(.separator)
|
|
}
|
|
|
|
// View Messages
|
|
items.append(.action(title: "View Messages\u{2026}", key: "m") { [weak self] in
|
|
guard let self else { return }
|
|
let urlString = self.webServer.url.absoluteString
|
|
log.info("Opening web app at \(urlString)")
|
|
let script = NSAppleScript(source: "do shell script \"open \(urlString)\"")
|
|
var error: NSDictionary?
|
|
script?.executeAndReturnError(&error)
|
|
if let error {
|
|
log.error("Failed to open web app: \(error)")
|
|
}
|
|
})
|
|
|
|
items.append(.separator)
|
|
|
|
// Quit
|
|
items.append(.action(title: "Quit", key: "q") {
|
|
NSApplication.shared.terminate(nil)
|
|
})
|
|
|
|
return items
|
|
}
|
|
|
|
// MARK: - Status Text Builders
|
|
|
|
private func buildStatusText() -> String {
|
|
if syncManager.isResetting {
|
|
return "Sync: Resetting\u{2026}"
|
|
}
|
|
if syncManager.isSyncing {
|
|
return "Sync: Syncing\u{2026}"
|
|
}
|
|
switch syncManager.syncError {
|
|
case .none:
|
|
return "Sync: Idle \u{2713}"
|
|
case .fullDiskAccessRequired:
|
|
return "Sync: Full Disk Access Required"
|
|
case .databaseNotFound:
|
|
return "Sync: Database Not Found"
|
|
case .connectionFailed(let msg):
|
|
return "Sync: Error \u{2013} \(msg)"
|
|
}
|
|
}
|
|
|
|
private func buildLastSyncText() -> String {
|
|
guard let lastSync = syncManager.lastSyncCompletedAt else {
|
|
return "Last Sync: Never"
|
|
}
|
|
return "Last Sync: \(relativeTime(from: lastSync))"
|
|
}
|
|
|
|
private func buildStatsText() -> String {
|
|
let stats = syncManager.stats
|
|
let messages = formatCount(stats.messageCount)
|
|
let convos = formatCount(stats.conversationCount)
|
|
return "\(messages) messages \u{00B7} \(convos) convos"
|
|
}
|
|
|
|
// MARK: - State Observation
|
|
|
|
private func observeSyncState() {
|
|
// Observe sync state changes to update icon and rebuild menu
|
|
Publishers.CombineLatest4(
|
|
syncManager.$isSyncing,
|
|
syncManager.$syncError,
|
|
syncManager.$isResetting,
|
|
syncManager.$stats
|
|
)
|
|
.receive(on: RunLoop.main)
|
|
.sink { [weak self] isSyncing, syncError, isResetting, _ in
|
|
guard let self else { return }
|
|
let icon = TrayIconProvider.icon(
|
|
isSyncing: isSyncing,
|
|
syncError: syncError,
|
|
isResetting: isResetting
|
|
)
|
|
self.menuBarAgent?.updateIcon(icon)
|
|
self.rebuildMenu()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
// Observe lastSyncCompletedAt separately to update "Last Sync" text
|
|
syncManager.$lastSyncCompletedAt
|
|
.receive(on: RunLoop.main)
|
|
.sink { [weak self] _ in
|
|
self?.rebuildMenu()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// MARK: - Formatting Helpers
|
|
|
|
private func relativeTime(from date: Date) -> String {
|
|
let interval = Date().timeIntervalSince(date)
|
|
|
|
if interval < 10 {
|
|
return "Just now"
|
|
} else if interval < 60 {
|
|
return "\(Int(interval))s ago"
|
|
} else if interval < 3600 {
|
|
let minutes = Int(interval / 60)
|
|
return "\(minutes) min ago"
|
|
} else if interval < 86400 {
|
|
let hours = Int(interval / 3600)
|
|
return "\(hours)h ago"
|
|
} else {
|
|
let days = Int(interval / 86400)
|
|
return "\(days)d ago"
|
|
}
|
|
}
|
|
|
|
private func formatCount(_ count: Int) -> String {
|
|
if count >= 1_000_000 {
|
|
let millions = Double(count) / 1_000_000.0
|
|
return String(format: "%.1fM", millions)
|
|
} else if count >= 1_000 {
|
|
let thousands = Double(count) / 1_000.0
|
|
return count >= 10_000
|
|
? String(format: "%.0fK", thousands)
|
|
: String(format: "%.1fK", thousands)
|
|
}
|
|
return "\(count)"
|
|
}
|
|
}
|