#if targetEnvironment(macCatalyst) import Combine import Foundation import Observation import UIKit @MainActor public final class SybilMacQuickQuestionController: NSObject, SybilQuickQuestionPanelDelegate { public static let shared = SybilMacQuickQuestionController() private var pluginBundle: Bundle? private var bridge: (any SybilQuickQuestionPanelBridging)? private var viewModel: SybilViewModel? private var observationGeneration = 0 private var isPresentationRequested = false private var activationRequested = false private var presentationTask: Task? private var foregroundObservation: AnyCancellable? private var notificationCenter = NotificationCenter.default private var isApplicationInForeground: @MainActor () -> Bool = { UIApplication.shared.applicationState != .background } private override init() { super.init() } // Inject foreground readiness as well as the panel so tests can exercise // windowless activation without activating apps or showing real windows. init( bridge: any SybilQuickQuestionPanelBridging, isApplicationInForeground: @escaping @MainActor () -> Bool = { true }, notificationCenter: NotificationCenter = NotificationCenter() ) { self.bridge = bridge self.isApplicationInForeground = isApplicationInForeground self.notificationCenter = notificationCenter super.init() _ = bridge.start(delegate: self) observeForegroundTransitionsIfNeeded() } public func start() { observeForegroundTransitionsIfNeeded() guard bridge == nil else { return } guard let url = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SybilMacQuickQuestion.bundle"), let bundle = Bundle(url: url) else { SybilLog.error(SybilLog.app, "The macOS Quick Question bundle is missing.") return } do { try bundle.loadAndReturnError() guard let pluginClass = bundle.principalClass as? any SybilQuickQuestionPanelBridging.Type else { SybilLog.error(SybilLog.app, "The macOS Quick Question bundle has an invalid principal class.") return } let plugin = pluginClass.init() pluginBundle = bundle bridge = plugin let status = plugin.start(delegate: self) if status != 0 { SybilLog.warning(SybilLog.app, "Option+Space could not be registered (status \(status)). Quick Question remains available in the app menu.") } } catch { SybilLog.error(SybilLog.app, "Could not load the macOS Quick Question panel", error: error) } } func attach(_ viewModel: SybilViewModel) { guard self.viewModel !== viewModel else { return } // A window becoming active must not replace an in-flight quick question. if self.viewModel != nil, isPresentationRequested || bridge?.isVisible == true || self.viewModel?.isQuickQuestionSending == true || self.viewModel?.isConvertingQuickQuestion == true { return } self.viewModel = viewModel if bridge?.isVisible == true { updateAndObserve() } } public func toggle() { start() if isPresentationRequested || bridge?.isVisible == true { cancelPresentation() bridge?.hide() } else { show() } } public func show() { start() isPresentationRequested = true schedulePresentationIfNeeded() } public func stop() { cancelPresentation() foregroundObservation = nil viewModel?.cancelQuickQuestion() bridge?.stop() bridge = nil // Keep the loaded bundle alive for the lifetime of its code. } private func observeForegroundTransitionsIfNeeded() { guard foregroundObservation == nil else { return } foregroundObservation = notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification) .merge(with: notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification)) .sink { [weak self] _ in Task { @MainActor [weak self] in self?.schedulePresentationIfNeeded() } } } private func schedulePresentationIfNeeded() { guard isPresentationRequested, bridge?.isVisible != true, presentationTask == nil else { return } // Leave the Carbon/NSApplication event callback before touching windows. // AppKit activation is asynchronous; an arbitrary delay or NSApp.isActive // is not a substitute for checking UIKit's foreground lifecycle state. presentationTask = Task { @MainActor [weak self] in guard let self, !Task.isCancelled else { return } self.presentationTask = nil guard self.isPresentationRequested else { return } guard self.isApplicationInForeground() else { guard !self.activationRequested else { return } self.activationRequested = true SybilLog.debug(SybilLog.ui, "Waiting for Catalyst foreground before showing Quick Question") self.bridge?.activateForQuickQuestion() // Also handle an activation that completes synchronously. If it // has not completed, subsequent UIKit notifications resume us. self.schedulePresentationIfNeeded() return } self.activationRequested = false self.updateAndObserve() self.bridge?.show() } } private func cancelPresentation() { isPresentationRequested = false activationRequested = false presentationTask?.cancel() presentationTask = nil observationGeneration += 1 } private func updateAndObserve() { guard let bridge else { return } observationGeneration += 1 let generation = observationGeneration let state = withObservationTracking { viewModel?.macQuickQuestionPanelState ?? SybilQuickQuestionPanelState() } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self, self.observationGeneration == generation, self.bridge?.isVisible == true else { return } self.updateAndObserve() } } do { bridge.updateState(try JSONEncoder().encode(state)) } catch { SybilLog.error(SybilLog.ui, "Could not update the macOS Quick Question panel", error: error) } } func quickQuestionToggleRequested() { toggle() } func quickQuestionPromptChanged(_ prompt: String) { guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return } viewModel.updateQuickQuestionPrompt(prompt) updateAndObserve() } func quickQuestionSubmitRequested() { guard let viewModel, viewModel.isAuthenticated, !viewModel.isCheckingSession else { return } viewModel.sendQuickQuestion() updateAndObserve() } func quickQuestionProviderChanged(_ provider: String) { guard let viewModel, let provider = Provider(rawValue: provider), !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return } viewModel.setQuickQuestionProvider(provider) updateAndObserve() } func quickQuestionModelChanged(_ model: String) { guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return } viewModel.setQuickQuestionModel(model) updateAndObserve() } func quickQuestionConvertRequested() { guard let viewModel, viewModel.canConvertQuickQuestion else { return } Task { @MainActor [weak self] in guard let self else { return } if await viewModel.convertQuickQuestionToChat() { self.cancelPresentation() self.openMainWindow() self.bridge?.hide() } else { self.updateAndObserve() } } } func quickQuestionOpenAppRequested() { cancelPresentation() openMainWindow() bridge?.hide() } private func openMainWindow() { let session = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .first?.session UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil) bridge?.activateMainWindow() } func quickQuestionPanelDismissed() { // Toggling out preserves the draft and lets an answer finish. Reopening // reads the latest state, without starting a new request. cancelPresentation() } } extension SybilViewModel { var macQuickQuestionPanelState: SybilQuickQuestionPanelState { SybilQuickQuestionPanelState( prompt: quickQuestionPrompt, answer: quickQuestionAnswerText, toolSummaries: quickQuestionMessages.compactMap { message in guard let metadata = message.toolCallMetadata else { return nil } return metadata.summary ?? message.content }, provider: quickQuestionProvider.rawValue, providers: providerOptions.map { .init(id: $0.rawValue, title: $0.displayName) }, model: quickQuestionModel, models: quickQuestionProviderModelOptions, isSending: isQuickQuestionSending, isConverting: isConvertingQuickQuestion, canSend: isAuthenticated && !isCheckingSession && canSendQuickQuestion, canConvert: canConvertQuickQuestion, isAuthenticated: isAuthenticated, isCheckingSession: isCheckingSession, error: quickQuestionError ) } } #endif