spiegel-keyman/mac/Config/Installation/InputMethodUtil.swift
Shawn Schantz d4f36f4865 feat(mac): pre-install evaluation of install state
creates custom list of installation state in case
some of the installation was done previously
checks whether repairs are needed for completed
installations
fixes bug to kill keyman for CheckAccess call
2026-07-24 10:14:02 -04:00

469 lines
16 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 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()")
// because we are launching Keyman with a specific command line argument
// for this request, we must kill it first
_ = self.killKeymanInputMethod()
try self.launchKeymanInputMethodAsSeparateProcess(argument: kAccessCommand)
success = true
} catch {
print("error requesting access: \(error)")
}
return success
}
/**
* Calls Keyman input method 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 `DistributedNotificationCenter` for the notification named
* `accessibilityStateResponse`
* It contains a message with a value of `granted` or `not-granted`
*/
func invokeKeymanInputMethodCheckAccess() throws {
print("invokeKeymanInputMethodCheckAccess()")
// because we are launching Keyman with a specific command line argument
// for this request, we must kill it first
_ = self.killKeymanInputMethod()
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)")
}
}
}
/**
* Calls Keyman input method to check whether it has accessibility permission granted.
* Receives response as distributed notification named `accessibilityStateResponse`
*/
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.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
}
}
}