230 lines
9.8 KiB
Swift
230 lines
9.8 KiB
Swift
import AppKit
|
|
import Carbon
|
|
|
|
// AppKit is unavailable to Catalyst targets. This native bundle uses only
|
|
// public AppKit/Carbon APIs and communicates with the client through Foundation.
|
|
@MainActor
|
|
@objc(SybilMacQuickQuestionPlugin)
|
|
final class MacQuickQuestionPlugin: NSObject, SybilQuickQuestionPanelBridging, NSWindowDelegate {
|
|
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
|
private var panel: QuickQuestionPanel?
|
|
private var panelView: QuickQuestionPanelView?
|
|
private var hotKey: EventHotKeyRef?
|
|
private var eventHandler: EventHandlerRef?
|
|
private var outsideClickMonitor: Any?
|
|
private var hotKeyState = SybilQuickQuestionHotKeyState()
|
|
private var isDismissing = false
|
|
private var shortcutAvailable = false
|
|
private var windowlessActivity: NSObjectProtocol?
|
|
private var latestState = SybilQuickQuestionPanelState()
|
|
private var previousApplication: NSRunningApplication?
|
|
|
|
required override init() {
|
|
super.init()
|
|
}
|
|
|
|
var isVisible: Bool { panel?.isVisible == true }
|
|
|
|
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
|
|
self.delegate = delegate
|
|
if windowlessActivity == nil {
|
|
// Keep the hotkey service alive with no windows. This option does
|
|
// not prevent sleep/App Nap or intercept an explicit Quit command.
|
|
windowlessActivity = ProcessInfo.processInfo.beginActivity(
|
|
options: .automaticTerminationDisabled,
|
|
reason: "Quick Question global shortcut"
|
|
)
|
|
}
|
|
guard eventHandler == nil else { return noErr }
|
|
var eventTypes = [
|
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
|
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
|
|
]
|
|
let context = Unmanaged.passUnretained(self).toOpaque()
|
|
let handlerStatus = InstallEventHandler(
|
|
GetApplicationEventTarget(),
|
|
{ _, event, context in
|
|
guard let event, let context else { return OSStatus(eventNotHandledErr) }
|
|
var identifier = EventHotKeyID()
|
|
let status = GetEventParameter(
|
|
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
|
|
nil, MemoryLayout<EventHotKeyID>.size, nil, &identifier
|
|
)
|
|
guard status == noErr, identifier.signature == 0x53595151, identifier.id == 1 else {
|
|
return OSStatus(eventNotHandledErr)
|
|
}
|
|
let isKeyDown = GetEventKind(event) == UInt32(kEventHotKeyPressed)
|
|
let plugin = Unmanaged<MacQuickQuestionPlugin>.fromOpaque(context).takeUnretainedValue()
|
|
// Application event handlers run on the main event loop.
|
|
MainActor.assumeIsolated {
|
|
if plugin.hotKeyState.shouldToggle(isKeyDown: isKeyDown) {
|
|
plugin.delegate?.quickQuestionToggleRequested()
|
|
}
|
|
}
|
|
return noErr
|
|
},
|
|
eventTypes.count, &eventTypes, context, &eventHandler
|
|
)
|
|
guard handlerStatus == noErr else { return handlerStatus }
|
|
let status = RegisterEventHotKey(
|
|
UInt32(kVK_Space), UInt32(optionKey),
|
|
EventHotKeyID(signature: 0x53595151, id: 1),
|
|
GetApplicationEventTarget(), 0, &hotKey
|
|
)
|
|
shortcutAvailable = status == noErr
|
|
return status
|
|
}
|
|
|
|
func updateState(_ data: Data) {
|
|
guard let state = try? JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data) else { return }
|
|
// Updating a background request must not create a non-hosting window.
|
|
// The controller creates/orders the panel only after UIKit is foreground.
|
|
latestState = state
|
|
panelView?.update(state, shortcutAvailable: shortcutAvailable)
|
|
if isVisible { resizePanel() }
|
|
}
|
|
|
|
func show() {
|
|
createPanelIfNeeded()
|
|
guard let panel, let panelView else { return }
|
|
panelView.update(latestState, shortcutAvailable: shortcutAvailable)
|
|
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSScreen.main
|
|
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
|
|
let width = min(680, visibleFrame.width - 48)
|
|
panel.setContentSize(NSSize(width: width, height: 240))
|
|
panelView.layoutSubtreeIfNeeded()
|
|
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
|
let top = visibleFrame.maxY - min(visibleFrame.height * 0.18, 160)
|
|
let y = max(visibleFrame.minY + 24, top - height)
|
|
panel.setFrame(NSRect(x: visibleFrame.midX - width / 2, y: y, width: width, height: height), display: true)
|
|
// The controller has verified UIKit foreground readiness. A
|
|
// nonactivating panel can then take focus without raising a workspace.
|
|
panel.makeKeyAndOrderFront(nil)
|
|
panel.makeFirstResponder(panelView.promptView)
|
|
panelView.preparePromptForPresentation()
|
|
installOutsideClickMonitor()
|
|
}
|
|
|
|
func hide() {
|
|
dismissPanel(restoringPreviousApplication: true)
|
|
}
|
|
|
|
func activateForQuickQuestion() {
|
|
if let frontmost = NSWorkspace.shared.frontmostApplication,
|
|
frontmost != NSRunningApplication.current {
|
|
previousApplication = frontmost
|
|
}
|
|
// Do not order a window here. Catalyst must finish its own foreground
|
|
// transition before the controller calls show().
|
|
NSApp.activate()
|
|
}
|
|
|
|
func activateMainWindow() {
|
|
// An explicit Open in Chat/Open Sybil action keeps focus in Sybil.
|
|
previousApplication = nil
|
|
NSApp.activate()
|
|
NSApp.windows.first { $0 !== panel && $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
private func dismissPanel(restoringPreviousApplication: Bool) {
|
|
guard !isDismissing else { return }
|
|
let wasVisible = isVisible
|
|
isDismissing = true
|
|
if wasVisible { panel?.orderOut(nil) }
|
|
removeOutsideClickMonitor()
|
|
isDismissing = false
|
|
let applicationToRestore = previousApplication
|
|
previousApplication = nil
|
|
if restoringPreviousApplication, NSApp.isActive,
|
|
let applicationToRestore, !applicationToRestore.isTerminated {
|
|
NSApp.yieldActivation(to: applicationToRestore)
|
|
applicationToRestore.activate(options: [])
|
|
}
|
|
if wasVisible { delegate?.quickQuestionPanelDismissed() }
|
|
}
|
|
|
|
func stop() {
|
|
previousApplication = nil
|
|
hide()
|
|
removeOutsideClickMonitor()
|
|
if let hotKey { UnregisterEventHotKey(hotKey) }
|
|
if let eventHandler { RemoveEventHandler(eventHandler) }
|
|
hotKey = nil
|
|
eventHandler = nil
|
|
hotKeyState = SybilQuickQuestionHotKeyState()
|
|
if let windowlessActivity {
|
|
ProcessInfo.processInfo.endActivity(windowlessActivity)
|
|
}
|
|
windowlessActivity = nil
|
|
delegate = nil
|
|
}
|
|
|
|
func windowDidResignKey(_ notification: Notification) {
|
|
// Focus is already moving to a window/app the user chose.
|
|
dismissPanel(restoringPreviousApplication: false)
|
|
}
|
|
|
|
private func createPanelIfNeeded() {
|
|
guard panel == nil else { return }
|
|
let panel = QuickQuestionPanel(
|
|
contentRect: NSRect(x: 0, y: 0, width: 680, height: 240),
|
|
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
|
|
)
|
|
panel.title = "Quick Question"
|
|
panel.level = .floating
|
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
|
|
panel.isFloatingPanel = true
|
|
panel.hidesOnDeactivate = false
|
|
panel.becomesKeyOnlyIfNeeded = false
|
|
panel.worksWhenModal = true
|
|
panel.isReleasedWhenClosed = false
|
|
panel.isOpaque = false
|
|
panel.backgroundColor = .clear
|
|
panel.hasShadow = true
|
|
panel.isMovableByWindowBackground = true
|
|
panel.appearance = NSAppearance(named: .darkAqua)
|
|
panel.delegate = self
|
|
panel.onDismiss = { [weak self] in self?.hide() }
|
|
let view = QuickQuestionPanelView()
|
|
view.delegate = delegate
|
|
view.onDismiss = { [weak self] in self?.hide() }
|
|
panel.contentView = view
|
|
panelView = view
|
|
self.panel = panel
|
|
}
|
|
|
|
private func resizePanel() {
|
|
guard let panel, let panelView else { return }
|
|
panelView.layoutSubtreeIfNeeded()
|
|
let visibleFrame = panel.screen?.visibleFrame ?? panel.frame
|
|
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
|
guard abs(height - panel.frame.height) > 1 else { return }
|
|
var frame = panel.frame
|
|
frame.origin.y = max(visibleFrame.minY + 24, frame.maxY - height)
|
|
frame.size.height = height
|
|
panel.setFrame(frame, display: true)
|
|
}
|
|
|
|
private func installOutsideClickMonitor() {
|
|
guard outsideClickMonitor == nil else { return }
|
|
// Mouse-only monitoring does not require Accessibility or Input
|
|
// Monitoring permission. No global keyboard event tap is installed.
|
|
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
|
|
Task { @MainActor [weak self] in self?.hide() }
|
|
}
|
|
}
|
|
|
|
private func removeOutsideClickMonitor() {
|
|
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
|
|
outsideClickMonitor = nil
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class QuickQuestionPanel: NSPanel {
|
|
var onDismiss: (() -> Void)?
|
|
override var canBecomeKey: Bool { true }
|
|
override var canBecomeMain: Bool { false }
|
|
override func cancelOperation(_ sender: Any?) { onDismiss?() }
|
|
}
|