messenger/imessage-macos/Sources/UI/MenuBarController.swift
2026-03-13 04:31:58 -07:00

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)"
}
}