diff --git a/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift b/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift deleted file mode 100644 index ac957d4ec..000000000 --- a/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift +++ /dev/null @@ -1,339 +0,0 @@ -import SwiftUI - -struct MenuBarView: View { - @StateObject private var viewModel = MenuBarViewModel() - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Image(systemName: "bubble.left.and.bubble.right.fill") - .foregroundColor(.accentColor) - .accessibilityHidden(true) - Text("Conversation Assistant") - .font(.headline) - Spacer() - } - .padding() - .background(Color(.windowBackgroundColor)) - - Divider() - - if viewModel.isAuthenticated { - authenticatedView - } else { - authenticationView - } - - Divider() - - // Footer - HStack { - // Open Settings - use SettingsLink on macOS 14+, fallback to NSApp.sendAction - if #available(macOS 14, *) { - SettingsLink { - Image(systemName: "gear") - .accessibilityLabel("Settings") - } - .buttonStyle(.borderless) - } else { - Button { - // Close popover first - if let appDelegate = NSApp.delegate as? AppDelegate { - appDelegate.closePopover() - } - NSApp.activate(ignoringOtherApps: true) - NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) - } label: { - Image(systemName: "gear") - .accessibilityLabel("Settings") - } - .buttonStyle(.borderless) - } - - Spacer() - - Text(AppVersion.displayVersion) - .font(.caption2) - .foregroundStyle(.tertiary) - - Spacer() - - Button("Quit") { - NSApplication.shared.terminate(nil) - } - .buttonStyle(.borderless) - } - .padding() - } - .frame(width: 320) - } - - private var authenticatedView: some View { - VStack(spacing: 12) { - // Sync Status - HStack { - Circle() - .fill(viewModel.syncStatus.color) - .frame(width: 8, height: 8) - Text(viewModel.syncStatus.text) - .font(.subheadline) - Spacer() - } - .padding(.horizontal) - - // Full Disk Access Warning - if viewModel.needsFullDiskAccess { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.shield.fill") - .font(.system(size: 32)) - .foregroundColor(.orange) - .accessibilityLabel("Warning") - - Text("Full Disk Access Required") - .font(.headline) - - Text("Grant access to read iMessage database") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - HStack(spacing: 12) { - Button("Open Settings") { - viewModel.openFullDiskAccessSettings() - } - .buttonStyle(.borderedProminent) - - Button("Retry") { - viewModel.retryConnection() - } - .buttonStyle(.bordered) - } - } - .padding() - .background(Color.orange.opacity(0.1)) - .cornerRadius(8) - .padding(.horizontal) - } else { - // Stats - VStack(alignment: .leading, spacing: 8) { - StatRow(label: "Messages Synced", value: "\(viewModel.messageCount)") - StatRow(label: "Conversations", value: "\(viewModel.conversationCount)") - StatRow(label: "Last Sync", value: viewModel.lastSyncText) - } - .padding(.horizontal) - - // Activity Log - ActivityLogSection(entries: viewModel.activityLogEntries) - } - - Spacer() - - // Sync Buttons - HStack(spacing: 8) { - Button(action: viewModel.triggerSync) { - HStack { - if viewModel.isSyncing && !viewModel.isResetting { - ProgressView() - .scaleEffect(0.7) - } - Text(viewModel.isSyncing && !viewModel.isResetting ? "Syncing..." : "Sync Now") - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .disabled(viewModel.isSyncing || viewModel.isResetting || viewModel.needsFullDiskAccess) - - Button(action: viewModel.forceSync) { - HStack { - if viewModel.isResetting { - ProgressView() - .scaleEffect(0.7) - } - Text(viewModel.isResetting ? "Resetting..." : "Force Sync") - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(viewModel.isSyncing || viewModel.isResetting || viewModel.needsFullDiskAccess) - } - .padding() - } - .padding(.vertical) - } - - private var authenticationView: some View { - VStack(spacing: 16) { - Image(systemName: "link.badge.plus") - .font(.system(size: 48)) - .foregroundColor(.accentColor) - .accessibilityLabel("Connect device") - - Text("Connect This Device") - .font(.headline) - - if viewModel.isRegistering { - ProgressView("Registering...") - } else if !viewModel.registrationCode.isEmpty { - Text("Enter this code in your admin panel:") - .font(.caption) - .foregroundColor(.secondary) - - Text(viewModel.registrationCode) - .font(.system(size: 32, weight: .bold, design: .monospaced)) - .foregroundColor(.accentColor) - .padding(.vertical, 8) - .onTapGesture { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(viewModel.registrationCode, forType: .string) - } - - Text("Tap to copy • Waiting for verification...") - .font(.caption2) - .foregroundColor(.secondary) - - Button("Get New Code") { - viewModel.refreshCode() - } - .buttonStyle(.link) - } else { - Text("Unable to connect to server") - .font(.caption) - .foregroundColor(.secondary) - - Button("Retry") { - viewModel.registerDevice() - } - .buttonStyle(.borderedProminent) - } - - if let error = viewModel.authError { - Text(error) - .font(.caption) - .foregroundColor(.red) - } - } - .padding() - } -} - -struct StatRow: View { - let label: String - let value: String - - var body: some View { - HStack { - Text(label) - .foregroundColor(.secondary) - Spacer() - Text(value) - .fontWeight(.medium) - } - } -} - -struct ActivityLogSection: View { - let entries: [LogEntry] - - /// Maximum entries to display - private let maxDisplayed = 8 - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Activity") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.secondary) - Spacer() - } - .padding(.horizontal) - .padding(.top, 8) - - if entries.isEmpty { - Text("No recent activity") - .font(.caption2) - .foregroundStyle(.tertiary) - .padding(.horizontal) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 2) { - ForEach(entries.prefix(maxDisplayed)) { entry in - ActivityLogRow(entry: entry) - } - } - } - .frame(maxHeight: 120) - .padding(.horizontal) - } - } - } -} - -struct ActivityLogRow: View { - let entry: LogEntry - - private var timeText: String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: entry.timestamp, relativeTo: Date()) - } - - private var iconColor: Color { - switch entry.level { - case .info: return .blue - case .success: return .green - case .warning: return .orange - case .error: return .red - } - } - - var body: some View { - HStack(alignment: .top, spacing: 6) { - Image(systemName: entry.level.icon) - .font(.caption2) - .foregroundColor(iconColor) - .frame(width: 12) - .accessibilityHidden(true) - - Text(entry.message) - .font(.caption2) - .foregroundColor(.primary) - .lineLimit(2) - - Spacer() - - Text(timeText) - .font(.caption2) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 2) - } -} - -enum SyncStatus { - case idle - case syncing - case error(String) - - var color: Color { - switch self { - case .idle: return .green - case .syncing: return .orange - case .error: return .red - } - } - - var text: String { - switch self { - case .idle: return "Connected" - case .syncing: return "Syncing..." - case .error(let message): return message - } - } -} - -#Preview { - MenuBarView() - .frame(height: 400) -} diff --git a/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift b/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift deleted file mode 100644 index 45ce636e1..000000000 --- a/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift +++ /dev/null @@ -1,187 +0,0 @@ -import Combine -import SwiftUI - -@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 - @Published var isResetting = false - @Published var needsFullDiskAccess = false - @Published var syncErrorMessage: String? - - @Published var registrationCode = "" - @Published var isRegistering = false - @Published var authError: String? - - @Published var activityLogEntries: [LogEntry] = [] - - private let apiClient = APIClient.shared - private let syncManager = SyncManager.shared - private let activityLog = ActivityLog.shared - private var cancellables = Set() - private var pollingTimer: Timer? - - 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 - NSLog("MenuBarViewModel: received lastSync update: \(String(describing: date))") - 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) - - 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) - - syncManager.$isResetting - .receive(on: DispatchQueue.main) - .sink { [weak self] resetting in - self?.isResetting = resetting - } - .store(in: &cancellables) - - activityLog.$entries - .receive(on: DispatchQueue.main) - .sink { [weak self] entries in - self?.activityLogEntries = entries - } - .store(in: &cancellables) - - // Register device on startup if not authenticated - if !isAuthenticated { - registerDevice() - } - } - - func registerDevice() { - isRegistering = true - authError = nil - - Task { - do { - let (_, code) = try await apiClient.registerDevice() - registrationCode = code - startPollingForVerification() - } catch { - authError = error.localizedDescription - } - 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() - } - } - } - - 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() - } - - func triggerSync() { - syncManager.syncNow() - } - - func forceSync() { - syncManager.resetAndResync() - } - - func openFullDiskAccessSettings() { - syncManager.openFullDiskAccessSettings() - } - - func retryConnection() { - syncManager.retryConnection() - } - - /// Opens the Settings window, closing the popover first and activating the app - func openSettings() { - // 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) - // Open settings window - macOS 14+ renamed to showSettingsWindow, older versions use showPreferencesWindow - // Note: Using double-paren selector format to suppress warnings about private Objective-C selectors - if #available(macOS 14, *) { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - } else { - NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) - } - } - - private func updateLastSyncText(_ date: Date?) { - guard let date = date else { - NSLog("MenuBarViewModel: updateLastSyncText - date is nil, setting to 'Never'") - lastSyncText = "Never" - return - } - - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - let text = formatter.localizedString(for: date, relativeTo: Date()) - NSLog("MenuBarViewModel: updateLastSyncText - date: \(date), text: \(text)") - lastSyncText = text - } -}