amical/packages/native-helpers/swift-helper/Sources/SwiftHelper/main.swift
2025-11-30 20:54:49 +05:30

228 lines
8.3 KiB
Swift

import ApplicationServices
import CoreGraphics
import Foundation
// Import the generated models
// Note: We'll manually create the proper HelperEvent structure since quicktype doesn't handle discriminated unions well
// Define the proper event structures that match the TypeScript schemas
struct KeyEventPayload: Codable {
let key: String?
let code: String?
let altKey: Bool?
let ctrlKey: Bool?
let shiftKey: Bool?
let metaKey: Bool?
let keyCode: Int?
let fnKeyPressed: Bool?
}
struct HelperEvent: Codable {
let type: String
let payload: KeyEventPayload
let timestamp: String?
init(type: String, payload: KeyEventPayload, timestamp: String? = nil) {
self.type = type
self.payload = payload
self.timestamp = timestamp
}
}
// Function to handle the event tap
func eventTapCallback(
proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer?
) -> Unmanaged<CGEvent>? {
guard let refcon = refcon else {
return Unmanaged.passRetained(event)
}
let anInstance = Unmanaged<SwiftHelper>.fromOpaque(refcon).takeUnretainedValue()
if type == .keyDown || type == .keyUp {
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
let eventTypeString = (type == .keyDown) ? "keyDown" : "keyUp"
// Create the proper payload structure
let payload = KeyEventPayload(
key: nil, // We could map keyCode to key string if needed
code: nil, // We could map keyCode to code string if needed
altKey: event.flags.contains(.maskAlternate),
ctrlKey: event.flags.contains(.maskControl),
shiftKey: event.flags.contains(.maskShift),
metaKey: event.flags.contains(.maskCommand),
keyCode: Int(keyCode),
fnKeyPressed: event.flags.contains(.maskSecondaryFn)
)
let helperEvent = HelperEvent(
type: eventTypeString,
payload: payload,
timestamp: ISO8601DateFormatter().string(from: Date())
)
anInstance.sendKeyEvent(helperEvent)
} else if type == .flagsChanged {
// Handle flags changed events (like Fn key press/release)
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
let payload = KeyEventPayload(
key: nil,
code: nil,
altKey: event.flags.contains(.maskAlternate),
ctrlKey: event.flags.contains(.maskControl),
shiftKey: event.flags.contains(.maskShift),
metaKey: event.flags.contains(.maskCommand),
keyCode: Int(keyCode),
fnKeyPressed: event.flags.contains(.maskSecondaryFn)
)
let helperEvent = HelperEvent(
type: "flagsChanged",
payload: payload,
timestamp: ISO8601DateFormatter().string(from: Date())
)
anInstance.sendKeyEvent(helperEvent)
} else if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
// Re-enable the tap if it times out or is disabled by user input
if let tap = anInstance.eventTap {
CGEvent.tapEnable(tap: tap, enable: true)
}
}
return Unmanaged.passRetained(event)
}
class SwiftHelper {
var eventTap: CFMachPort?
let outputPipe = Pipe()
let errorPipe = Pipe() // For logging errors from the helper
init() {
// Redirect stdout to the pipe for IPC
// dup2(outputPipe.fileHandleForWriting.fileDescriptor, STDOUT_FILENO)
// For debugging, you might want to print to stderr of the helper itself
// dup2(errorPipe.fileHandleForWriting.fileDescriptor, STDERR_FILENO)
}
func sendKeyEvent(_ eventData: HelperEvent) {
let encoder = JSONEncoder()
do {
let jsonData = try encoder.encode(eventData)
if let jsonString = String(data: jsonData, encoding: .utf8) {
// Print to stdout, which will be captured by the Electron process
print(jsonString)
fflush(stdout) // Ensure the output is sent immediately
}
} catch {
// Log errors to the helper's stderr
let errorMsg = "Error encoding HelperEvent: \(error)\n"
if let data = errorMsg.data(using: .utf8) {
FileHandle.standardError.write(data)
}
}
}
func checkAccessibilityPermission() -> Bool {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] as CFDictionary
return AXIsProcessTrustedWithOptions(options)
}
func attemptEventTapCreation() {
// Don't recreate if already exists
guard eventTap == nil else {
return
}
// Check accessibility permission before attempting
guard checkAccessibilityPermission() else {
FileHandle.standardError.write("Accessibility permission not granted. Event tap disabled. RPC methods still available.\n".data(using: .utf8)!)
return
}
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
let eventMask =
(1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
| (1 << CGEventType.flagsChanged.rawValue)
if let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: CGEventMask(eventMask),
callback: eventTapCallback,
userInfo: selfPtr
) {
self.eventTap = tap
// Create a run loop source and add it to the current run loop
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
// Enable the event tap
CGEvent.tapEnable(tap: tap, enable: true)
FileHandle.standardError.write("Event tap created successfully. Keyboard monitoring active.\n".data(using: .utf8)!)
} else {
FileHandle.standardError.write("Failed to create event tap despite having permissions.\n".data(using: .utf8)!)
}
}
func setupPermissionObserver() {
// Observe accessibility permission changes
DistributedNotificationCenter.default().addObserver(
forName: NSNotification.Name("com.apple.accessibility.api"),
object: nil,
queue: .main
) { [weak self] _ in
// Delay to allow permission change to propagate
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self?.attemptEventTapCreation()
}
}
}
func start() {
// Try to create event tap if permissions available
attemptEventTapCreation()
// Set up observer for permission changes
setupPermissionObserver()
// Keep the program running (works even without event tap)
let startMsg = eventTap != nil
? "SwiftHelper started and listening for events...\n"
: "SwiftHelper started in degraded mode (no accessibility permission). RPC methods available.\n"
FileHandle.standardError.write(startMsg.data(using: .utf8)!)
CFRunLoopRun()
}
deinit {
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: false)
// CFMachPortInvalidate(tap) // This might be needed for complete cleanup
// CFRelease(tap) // And this
}
let endMsg = "SwiftHelper stopping.\n"
if let data = endMsg.data(using: .utf8) {
FileHandle.standardError.write(data)
}
}
}
// Create instances of both helpers
let swiftHelper = SwiftHelper()
let ioBridge = IOBridge(jsonEncoder: JSONEncoder(), jsonDecoder: JSONDecoder())
// Start RPC processing in a background thread
// Using .userInteractive QoS for high priority (reduces latency for audio muting)
DispatchQueue.global(qos: .userInteractive).async {
FileHandle.standardError.write(
"Starting IOBridge RPC processing in background thread...\n".data(using: .utf8)!)
ioBridge.processRpcRequests()
}
// Start Swift helper in the main thread (this will run the main run loop)
FileHandle.standardError.write("Starting SwiftHelper in main thread...\n".data(using: .utf8)!)
swiftHelper.start()