/* * Keyman is copyright (C) SIL Global. MIT License. * * Created by Shawn Schantz on 2026-05-11 * * Stateless utility for enabling, selecting, killing, requesting Accessibility permission * and getting information about the Keyman input method and for selecting another input source */ import Foundation import Carbon.HIToolbox import AppKit import KeymanSettings public enum KeymanVersionCheckError: Error { case inputMethodNotFound case versionNotFound } public enum KeymanInvocationError: Error { case inputMethodNotFound case inputMethodCouldNotBeInvoked } public let kAccessibilityPermissionGrantedMessage = "granted" public class InputMethodUtil { public let keymanInputMethodApplicationName = "Keyman.app" // only initialized after message is received from input method public var accessibilityPermissionGranted: Bool? = nil fileprivate let pathUtil: KeymanPaths fileprivate var observer: NSObjectProtocol? let kMigrateCommand = "migrate" let kAccessCommand = "access" let kCheckCommand = "check" public init() throws { try self.pathUtil = KeymanPaths() } /** * true if the Keyman input method exists at `~/Library/Input Methods` */ public func keymanInputMethodExists() -> Bool { guard let inputMethodUrl = pathUtil.buildInputMethodPathUrl(fileName: self.keymanInputMethodApplicationName) else { print("Keyman input method not found, failed to create input method url") return false } return FileManager.default.fileExists(atPath: inputMethodUrl.path) } /** * true if the Keyman input method of the correct version exists in the correct location */ public func isKeymanInputMethodCurrent() -> Bool { // MAC-CONFIG-TODO: implement with version check return true } /** * Returns version number string of Keyman input method */ public func getKeymanInputMethodVersion() throws -> String { return try self.appVersionForInputMethod(applicationName: keymanInputMethodApplicationName) } /** * Returns true if the Keyman input method is running */ public func isKeymanInputMethodRunning() -> Bool { return self.isApplicationRunning(bundleId: KeymanPaths.keymanBundleId) } /** * returns true if the specified bundleId is enabled */ public func isKeymanInputMethodEnabled() -> Bool { return self.isInputMethodEnabled(bundleId: KeymanPaths.keymanBundleId) } /** * attempts to register the Keyman input method and returns true if successful * a newly installed input method must be registered before enabling */ public func registerKeymanInputMethod() -> Bool { return self.registerInputMethod(bundleId: KeymanPaths.keymanBundleId) } /** * attempts to enable the Keyman input method and returns true if successful */ public func enableKeymanInputMethod() -> Bool { return self.enableInputMethod(bundleId: KeymanPaths.keymanBundleId) } /** * attempts to select the Keyman input method and returns true if successful */ public func selectKeymanInputMethod() -> Bool { return self.selectInputSource(inputSourceId: KeymanPaths.keymanBundleId) } /** * attempts to disable the Keyman input method and returns true if successful */ public func disableKeymanInputMethod() -> Bool { return self.disableInputMethod(bundleId: KeymanPaths.keymanBundleId) } /** * Kill Keyman -- only permitted when running oustide sandbox */ public func killKeymanInputMethod() -> Bool { return killApplication(bundleId: KeymanPaths.keymanBundleId) } // MAC-CONFIG_TODO: deleting the app files with default security settings, need some other approach to uninstall /** * uninstalls the Keyman input method */ public func uninstallKeyman() { _ = self.killKeymanInputMethod() _ = self.disableKeymanInputMethod() self.deleteKeyman() } /** * Returns version number string for the specifed app located at `~/Library/Input Methods` */ func appVersionForInputMethod(applicationName: String) throws -> String { guard let location = pathUtil.buildInputMethodPathUrl(fileName: applicationName) else { throw KeymanVersionCheckError.inputMethodNotFound } guard let keymanBundle = Bundle(url: location) else { throw KeymanVersionCheckError.inputMethodNotFound } guard let infoDictionary = keymanBundle.infoDictionary else { throw KeymanVersionCheckError.versionNotFound } guard let appVersionString = infoDictionary["CFBundleShortVersionString"] as? String else { throw KeymanVersionCheckError.versionNotFound } return appVersionString } /** * returns true if the application with the specified bundleId is running */ func isApplicationRunning(bundleId: String) -> Bool { return !NSRunningApplication.runningApplications(withBundleIdentifier: bundleId).isEmpty } /** * launch the specified input method */ func runInputMethod(inputMethodName: String) -> Bool { if let inputMethodUrl = pathUtil.buildInputMethodPathUrl(fileName: inputMethodName) { return NSWorkspace.shared.open(inputMethodUrl) } else { return false } } func invokeKeymanInputMethodMigration() -> Bool { print("invokeKeymanInputMethodMigration()") return self.invokeKeymanInputMethodAsSubProcess(argument: kMigrateCommand) == 0 } func invokeKeymanInputMethodRequestAccess() -> Bool { var success = false do { print("invokeKeymanInputMethodRequestAccess()") try self.launchKeymanInputMethodAsSeparateProcess(argument: kAccessCommand) success = true } catch { print("error requesting access: \(error)") } return success } /** * Calls Keyman input to check whether it has accessibility permission granted. * The actual result is not returned from Keyman when called as a separate process. * After this function is called, listen to the `NotificationCenter` for the notification named * `com.keyman.accessibility.state` * It contains a message with a value of `granted` or `not-granted` */ func invokeKeymanInputMethodCheckAccess() throws { print("invokeKeymanInputMethodCheckAccess()") try self.launchKeymanInputMethodAsSeparateProcess(argument: kCheckCommand) } /** * run Keyman as a subprocess with the specifed argument and return the result */ func invokeKeymanInputMethodAsSubProcess(argument: String) -> Int { var result = -1 let process = Process() if let executableUrl = self.pathUtil.buildInputMethodExecutableUrl(fileName: self.keymanInputMethodApplicationName) { process.executableURL = executableUrl print("invoking Keyman at: \(String(describing: process.executableURL))") process.arguments = [argument] } var currentEnv = ProcessInfo.processInfo.environment print("current env: \(String(describing: currentEnv))") currentEnv["__CFBundleIdentifier"] = KeymanPaths.keymanBundleId // set bundle ID to that of the Keyman input method process.environment = currentEnv do { try process.run() // start Keyman process.waitUntilExit() // wait for it to finish result = Int(process.terminationStatus) } catch { print("Failed to run process: \(error)") } print("invokeKeymanInputMethod() result: \(result)") return result } /** * launches the Keyman input method as an independent process with the specifed argument and return the result */ func launchKeymanInputMethodAsSeparateProcess(argument: String = "") throws { let openConfig = NSWorkspace.OpenConfiguration() if !argument.isEmpty { openConfig.arguments = [argument] } guard let inputMethodUrl = pathUtil.buildInputMethodPathUrl(fileName: self.keymanInputMethodApplicationName) else { print("launchKeymanInputMethodAsSeparateProcess, failed to create input method url") throw KeymanInvocationError.inputMethodNotFound } NSWorkspace.shared.openApplication(at: inputMethodUrl, configuration: openConfig) { (app, error) in if let error = error { print("Could not launch Keyman input method: \(error.localizedDescription)") } } } /** * Special care is needed with this code because `processAccessCheckResult(with:)` is bound to the Main Actor, * but it is called from a closure which may not run on the Main Actor. * DistributedNotificationCenter is not fully updated for concurrency, so specifying `.main` for the `OperationQueue` * does not ensure that the closure is running on the main actor. * To ensure this, a Task is defined around the call to `processAccessCheckResult`. */ func doAsyncAccessibilityCheck() { do { try self.invokeKeymanInputMethodCheckAccess() } catch { print("invoking Keyman failed: \(error.localizedDescription)") } let timeStyle = Date.FormatStyle() .hour(.twoDigits(amPM: Date.FormatStyle.Symbol.Hour.AMPMStyle.abbreviated)) .minute(.twoDigits) .second(.twoDigits) .secondFraction(.fractional(3)) print("doAsyncAccessibilityCheck, listening across process boundaries, time: \(Date().formatted(timeStyle))") } /** * Kill the application with the specified bundle Id * This is only permitted when running oustide sandbox */ func killApplication(bundleId: String) -> Bool { let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) var didTerminate = false print("Running app count for \(bundleId) = \(runningApps.count)") if let runningApp = runningApps.first { let processId = runningApp.processIdentifier didTerminate = runningApp.terminate() print("process \(processId) for \(bundleId) was terminated: \(didTerminate)") } return didTerminate } /** * returns the TISInputSource for the specified bundleId */ func getKeymanInputSource() -> TISInputSource? { return self.getInputSource(bundleId: KeymanPaths.keymanBundleId) } /** * returns the TISInputSource for the specified bundleId */ func getInputSource(bundleId: String) -> TISInputSource? { let properties: [String: Any] = [ kTISPropertyInputSourceID as String: bundleId ] let inputSourceList = TISCreateInputSourceList(properties as CFDictionary, true) guard let sources = inputSourceList?.takeRetainedValue() as? [TISInputSource], let targetSource = sources.first else { print("Error: Could not find the specified input source.") return(nil) } return targetSource } /** * returns true if the input method with the specified bundleId is enabled */ func isInputMethodEnabled(bundleId: String) -> Bool { var enabled = false if let inputSource = self.getInputSource(bundleId: bundleId) { let enabledValue = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceIsEnabled) if let cfType = enabledValue { // Bridge the CFTypeRef to an Unmanaged and then to a Swift String if let inputMethodEnabled = Unmanaged.fromOpaque(cfType).takeUnretainedValue() as? Bool { enabled = inputMethodEnabled print("is enabled: \(enabled)") } else { print("could not read retrieved enabled property for bundleId: \(bundleId)") } } else { print("Failed to get enabled property for bundleId: \(bundleId)") } } else { print("Failed to get input source for bundleId: \(bundleId)") } return enabled } /** * returns true if the input method with the specified bundleId is capable of being enabled */ func isInputMethodEnableCapable(bundleId: String) -> Bool { var enableCapable = false if let inputSource = self.getInputSource(bundleId: bundleId) { let enableCapableValue = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceIsEnableCapable) if let cfType = enableCapableValue { // Bridge the CFTypeRef to an Unmanaged and then to a Swift String if let capable = Unmanaged.fromOpaque(cfType).takeUnretainedValue() as? Bool { enableCapable = capable print("is enable capable: \(enableCapable)") } else { print("could not read retrieved enable capable property for bundleId: \(bundleId)") } } else { print("Failed to get enable capable property for bundleId: \(bundleId)") } } else { print("Failed to get input source for bundleId: \(bundleId)") } return enableCapable } /** * register the newly installed input method with the specified bundleId * this will allow a `TISInputSourceRef` to be obtained to access the input source */ func registerInputMethod(bundleId: String) -> Bool { var success = false guard let inputMethodUrl = pathUtil.buildInputMethodPathUrl(fileName: self.keymanInputMethodApplicationName) else { print("registerInputMethod, failed to create input method url") return false } let cfUrl = inputMethodUrl as CFURL let result = TISRegisterInputSource(cfUrl) success = result == noErr if (success) { print("registerInputMethod for bundle ID '\(bundleId)': success") } else { print("registerInputMethod for bundle ID '\(bundleId)' failed, result = \(result)") } return success } /** * attempts to enable the input method with the specified bundleId, returns true if successful */ func enableInputMethod(bundleId: String) -> Bool { var success = false if let inputSource = self.getInputSource(bundleId: bundleId) { let result = TISEnableInputSource(inputSource) success = result == noErr if (success) { print("enableInputMethod for bundle ID '\(bundleId)': success") } else { print("enableInputMethod for bundle ID '\(bundleId)' failed, result = \(result)") } } return success } /** * attempts to disable the input method with the specified bundleId, returns true if successful */ func disableInputMethod(bundleId: String) -> Bool { var success = false if let inputSource = self.getInputSource(bundleId: bundleId) { let result = TISDisableInputSource(inputSource) success = result == noErr if (success) { print("disableInputMethod for bundle ID '\(bundleId)': success") } else { print("disableInputMethod for bundle ID '\(bundleId)' failed, result = \(result)") } } return success } func deleteKeyman() { let fileManager = FileManager.default if let keymanFile = self.pathUtil.buildInputMethodPathUrl(fileName: keymanInputMethodApplicationName) { do { try fileManager.removeItem(at: keymanFile) print("Successfully deleted Keyman.app") } catch { print("Error deleting Keyman.app: \(error)") } } else { print("Keyman.app not found") } } /** * select the input source with the specified input source id and return true if successful */ public func selectInputSource(inputSourceId: String) -> Bool { let properties: [String: Any] = [ kTISPropertyInputSourceID as String: inputSourceId ] let inputSourceList = TISCreateInputSourceList(properties as CFDictionary, false) guard let sources = inputSourceList?.takeRetainedValue() as? [TISInputSource], let targetSource = sources.first else { print("Error: Could not find the input source '\(inputSourceId)'.") return false } let result = TISSelectInputSource(targetSource) if result != noErr { print("Error selecting input source '\(inputSourceId)': \(result)") return false } else { print("Successfully selected input source '\(inputSourceId)'.") return true } } }