258 lines
9.9 KiB
Swift
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
|