mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-26 09:37:40 +00:00
Some checks are pending
Keyman Build Summary / Summarize build status checks (push) Waiting to run
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
497 lines
17 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|