Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionController.swift
T

258 lines
9.9 KiB
Swift

#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<Void, Never>?
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