2025-12-28 16:10:47 -08:00
|
|
|
import Combine
|
2025-12-29 19:36:12 -08:00
|
|
|
import SwiftUI
|
2025-12-28 16:10:47 -08:00
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
class MenuBarViewModel: ObservableObject {
|
|
|
|
|
@Published var isAuthenticated = false
|
|
|
|
|
@Published var syncStatus: SyncStatus = .idle
|
|
|
|
|
@Published var messageCount = 0
|
|
|
|
|
@Published var conversationCount = 0
|
|
|
|
|
@Published var lastSyncText = "Never"
|
|
|
|
|
@Published var isSyncing = false
|
2025-12-29 22:58:35 -08:00
|
|
|
@Published var isResetting = false
|
2025-12-29 01:18:46 -08:00
|
|
|
@Published var needsFullDiskAccess = false
|
|
|
|
|
@Published var syncErrorMessage: String?
|
2025-12-28 16:10:47 -08:00
|
|
|
|
2025-12-28 23:10:58 -08:00
|
|
|
@Published var registrationCode = ""
|
|
|
|
|
@Published var isRegistering = false
|
2025-12-28 16:10:47 -08:00
|
|
|
@Published var authError: String?
|
|
|
|
|
|
2025-12-29 23:46:17 -08:00
|
|
|
@Published var activityLogEntries: [LogEntry] = []
|
|
|
|
|
|
2025-12-28 16:10:47 -08:00
|
|
|
private let apiClient = APIClient.shared
|
|
|
|
|
private let syncManager = SyncManager.shared
|
2025-12-29 23:46:17 -08:00
|
|
|
private let activityLog = ActivityLog.shared
|
2025-12-28 16:10:47 -08:00
|
|
|
private var cancellables = Set<AnyCancellable>()
|
2025-12-28 23:10:58 -08:00
|
|
|
private var pollingTimer: Timer?
|
2025-12-28 16:10:47 -08:00
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
isAuthenticated = apiClient.isAuthenticated
|
|
|
|
|
|
|
|
|
|
syncManager.$isSyncing
|
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
|
.sink { [weak self] syncing in
|
|
|
|
|
self?.isSyncing = syncing
|
|
|
|
|
self?.syncStatus = syncing ? .syncing : .idle
|
|
|
|
|
}
|
|
|
|
|
.store(in: &cancellables)
|
|
|
|
|
|
|
|
|
|
syncManager.$lastSync
|
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
|
.sink { [weak self] date in
|
2025-12-28 23:10:58 -08:00
|
|
|
NSLog("MenuBarViewModel: received lastSync update: \(String(describing: date))")
|
2025-12-28 16:10:47 -08:00
|
|
|
self?.updateLastSyncText(date)
|
|
|
|
|
}
|
|
|
|
|
.store(in: &cancellables)
|
|
|
|
|
|
|
|
|
|
syncManager.$stats
|
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
|
.sink { [weak self] stats in
|
|
|
|
|
self?.messageCount = stats.messageCount
|
|
|
|
|
self?.conversationCount = stats.conversationCount
|
|
|
|
|
}
|
|
|
|
|
.store(in: &cancellables)
|
|
|
|
|
|
2025-12-29 01:18:46 -08:00
|
|
|
syncManager.$syncError
|
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
|
.sink { [weak self] error in
|
|
|
|
|
switch error {
|
|
|
|
|
case .none:
|
|
|
|
|
self?.needsFullDiskAccess = false
|
|
|
|
|
self?.syncErrorMessage = nil
|
|
|
|
|
self?.syncStatus = .idle
|
|
|
|
|
case .fullDiskAccessRequired:
|
|
|
|
|
self?.needsFullDiskAccess = true
|
|
|
|
|
self?.syncErrorMessage = error.message
|
|
|
|
|
self?.syncStatus = .error(error.message)
|
|
|
|
|
default:
|
|
|
|
|
self?.needsFullDiskAccess = false
|
|
|
|
|
self?.syncErrorMessage = error.message
|
|
|
|
|
self?.syncStatus = .error(error.message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.store(in: &cancellables)
|
|
|
|
|
|
2025-12-29 22:58:35 -08:00
|
|
|
syncManager.$isResetting
|
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
|
.sink { [weak self] resetting in
|
|
|
|
|
self?.isResetting = resetting
|
|
|
|
|
}
|
|
|
|
|
.store(in: &cancellables)
|
|
|
|
|
|
2025-12-29 23:46:17 -08:00
|
|
|
activityLog.$entries
|
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
|
.sink { [weak self] entries in
|
|
|
|
|
self?.activityLogEntries = entries
|
|
|
|
|
}
|
|
|
|
|
.store(in: &cancellables)
|
|
|
|
|
|
2025-12-28 23:10:58 -08:00
|
|
|
// Register device on startup if not authenticated
|
|
|
|
|
if !isAuthenticated {
|
|
|
|
|
registerDevice()
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-28 16:10:47 -08:00
|
|
|
|
2025-12-28 23:10:58 -08:00
|
|
|
func registerDevice() {
|
|
|
|
|
isRegistering = true
|
2025-12-28 16:10:47 -08:00
|
|
|
authError = nil
|
|
|
|
|
|
|
|
|
|
Task {
|
|
|
|
|
do {
|
2025-12-28 23:10:58 -08:00
|
|
|
let (_, code) = try await apiClient.registerDevice()
|
|
|
|
|
registrationCode = code
|
|
|
|
|
startPollingForVerification()
|
2025-12-28 16:10:47 -08:00
|
|
|
} catch {
|
|
|
|
|
authError = error.localizedDescription
|
|
|
|
|
}
|
2025-12-28 23:10:58 -08:00
|
|
|
isRegistering = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func startPollingForVerification() {
|
|
|
|
|
// Poll every 3 seconds to check if device has been verified
|
|
|
|
|
pollingTimer?.invalidate()
|
|
|
|
|
pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
await self?.checkVerificationStatus()
|
|
|
|
|
}
|
2025-12-28 16:10:47 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 23:10:58 -08:00
|
|
|
private func checkVerificationStatus() async {
|
|
|
|
|
do {
|
|
|
|
|
let verified = try await apiClient.checkVerification()
|
|
|
|
|
if verified {
|
|
|
|
|
pollingTimer?.invalidate()
|
|
|
|
|
pollingTimer = nil
|
|
|
|
|
isAuthenticated = true
|
|
|
|
|
registrationCode = ""
|
|
|
|
|
syncManager.startSync()
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Silently continue polling
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func refreshCode() {
|
|
|
|
|
registrationCode = ""
|
|
|
|
|
registerDevice()
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 16:10:47 -08:00
|
|
|
func triggerSync() {
|
|
|
|
|
syncManager.syncNow()
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 22:58:35 -08:00
|
|
|
func forceSync() {
|
|
|
|
|
syncManager.resetAndResync()
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 01:18:46 -08:00
|
|
|
func openFullDiskAccessSettings() {
|
|
|
|
|
syncManager.openFullDiskAccessSettings()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func retryConnection() {
|
|
|
|
|
syncManager.retryConnection()
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 20:14:48 -08:00
|
|
|
/// Opens the Settings window, closing the popover first and activating the app
|
2025-12-28 16:10:47 -08:00
|
|
|
func openSettings() {
|
2025-12-29 19:36:12 -08:00
|
|
|
// Close the popover first
|
|
|
|
|
if let appDelegate = NSApp.delegate as? AppDelegate {
|
|
|
|
|
appDelegate.closePopover()
|
|
|
|
|
}
|
|
|
|
|
// Activate the app (required for menu bar apps to show settings)
|
|
|
|
|
NSApp.activate(ignoringOtherApps: true)
|
2025-12-29 21:34:42 -08:00
|
|
|
// Open settings window - macOS 14+ renamed to showSettingsWindow, older versions use showPreferencesWindow
|
2025-12-29 21:52:09 -08:00
|
|
|
// Note: Using double-paren selector format to suppress warnings about private Objective-C selectors
|
2025-12-29 19:36:12 -08:00
|
|
|
if #available(macOS 14, *) {
|
2025-12-29 21:52:09 -08:00
|
|
|
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
2025-12-29 21:34:42 -08:00
|
|
|
} else {
|
2025-12-29 21:52:09 -08:00
|
|
|
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
|
2025-12-29 19:36:12 -08:00
|
|
|
}
|
2025-12-28 16:10:47 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func updateLastSyncText(_ date: Date?) {
|
|
|
|
|
guard let date = date else {
|
2025-12-28 23:10:58 -08:00
|
|
|
NSLog("MenuBarViewModel: updateLastSyncText - date is nil, setting to 'Never'")
|
2025-12-28 16:10:47 -08:00
|
|
|
lastSyncText = "Never"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let formatter = RelativeDateTimeFormatter()
|
|
|
|
|
formatter.unitsStyle = .abbreviated
|
2025-12-28 23:10:58 -08:00
|
|
|
let text = formatter.localizedString(for: date, relativeTo: Date())
|
|
|
|
|
NSLog("MenuBarViewModel: updateLastSyncText - date: \(date), text: \(text)")
|
|
|
|
|
lastSyncText = text
|
2025-12-28 16:10:47 -08:00
|
|
|
}
|
|
|
|
|
}
|