spiegel-keyman/mac/Config/Installation/InputMethodUtil.swift
Shawn Schantz 469c23aaf0
Some checks are pending
Keyman Build Summary / Summarize build status checks (push) Waiting to run
feat(mac): check Accessibility permission before attempt to repair
when creating the InstallationCheck, first ask
for the Accessibility permission from the input
method, and once the response is returned via
DistributedNotificiationCenter message, then do
the rest of the validation
2026-06-18 10:56:50 -04:00

497 lines
17 KiB
Swift

/*
* 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 let kAccessibilityPermissionGrantedMessage = "granted"
extension Notification.Name {
static let accessCheck = Notification.Name("com.keyman.accessibility.state")
}
public enum KeymanVersionCheckError: Error {
case inputMethodNotFound
case versionNotFound
}
public enum KeymanInvocationError: Error {
case inputMethodNotFound
case inputMethodCouldNotBeInvoked
}
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 inputMethodProccesId: pid_t = 0
fileprivate var observer: NSObjectProtocol?
let kMigrateCommand = "migrate"
let kAccessCommand = "access"
let kCheckCommand = "check"
public init() {
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)
}
/**
* launch the Keyman input method
*/
public func runKeymanInputMethod() throws {
try self.launchKeymanInputMethodAsSeparateProcess()
}
/**
* 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<AnyObject> and then to a Swift String
if let inputMethodEnabled = Unmanaged<AnyObject>.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<AnyObject> and then to a Swift String
if let capable = Unmanaged<AnyObject>.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")
} catch {
print("Error deleting file: \(error)")
}
} else {
print("Keyman not found")
}
}
/**
* get the ID of the current input source
*/
public func getCurrentInputSourceId() -> String? {
// Get the current keyboard input source
guard let inputSource = TISCopyCurrentKeyboardInputSource()?.takeUnretainedValue() else {
print("Could not get current keyboard input source.")
return nil
}
let inputSourceValue = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID)
if let cfType = inputSourceValue {
// Bridge the CFTypeRef to an Unmanaged<AnyObject> and then to a Swift String
let inputSourceId = Unmanaged<AnyObject>.fromOpaque(cfType).takeUnretainedValue() as? String
print("Input Source ID: \(String(describing: inputSourceId))")
return inputSourceId
} else {
print("Failed to get input source ID property")
return nil
}
}
/**
* 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
}
}
}