feat(mac): add async notification of Accessibility check
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled

This commit is contained in:
Shawn Schantz 2026-06-09 19:18:59 -04:00
parent ddabddd720
commit ccd5f18fc6
5 changed files with 196 additions and 57 deletions

View file

@ -13,11 +13,24 @@ import Carbon.HIToolbox
import AppKit
import KeymanSettings
extension Notification.Name {
static let accessCheck = Notification.Name("com.keyman.accessibility.state")
}
public enum KeymanInvocationError: Error {
case inputMethodNotFound
case inputMethodCouldNotBeInvoked
}
public class InputMethodUtil {
public let keymanInputMethodApplicationName = "Keyman.app"
public var requestAccessGranted: Bool = false
fileprivate let pathUtil: KeymanPaths
fileprivate var inputMethodProccesId: pid_t = 0
fileprivate var observer: NSObjectProtocol?
let kAccessGrantedMessage = "granted"
let kMigrateCommand = "migrate"
let kAccessCommand = "access"
let kCheckCommand = "check"
@ -58,9 +71,8 @@ public class InputMethodUtil {
/**
* launch the Keyman input method
*/
public func runKeymanInputMethod() -> Bool {
let success = self.launchKeymanInputMethodAsSeparateProcess() == 0
return success
public func runKeymanInputMethod() throws {
try self.launchKeymanInputMethodAsSeparateProcess()
}
/**
@ -166,14 +178,29 @@ public class InputMethodUtil {
return self.invokeKeymanInputMethodAsSubProcess(argument: kMigrateCommand) == 0
}
func invokeKeymanInputMethodGrantAccess() -> Bool {
print("invokeKeymanInputMethodGrantAccess()")
return self.launchKeymanInputMethodAsSeparateProcess(argument: kAccessCommand) == 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
}
func invokeKeymanInputMethodCheckAccess() -> Bool {
/**
* 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()")
return self.launchKeymanInputMethodAsSeparateProcess(argument: kCheckCommand) == 0
try self.launchKeymanInputMethodAsSeparateProcess(argument: kCheckCommand)
}
/**
@ -209,7 +236,7 @@ public class InputMethodUtil {
/**
* launches the Keyman input method as an independent process with the specifed argument and return the result
*/
func launchKeymanInputMethodAsSeparateProcess(argument: String = "") -> Int {
func launchKeymanInputMethodAsSeparateProcess(argument: String = "") throws {
let openConfig = NSWorkspace.OpenConfiguration()
if !argument.isEmpty {
openConfig.arguments = [argument]
@ -219,18 +246,70 @@ public class InputMethodUtil {
guard let inputMethodUrl = pathUtil.buildInputMethodPathUrl(fileName: self.keymanInputMethodApplicationName) else {
print("launchKeymanInputMethodAsSeparateProcess, failed to create input method url")
return 1
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)")
} else if let runningApp = app {
print("application: \(runningApp.localizedName ?? "unknown"), isActive: \(runningApp.isActive), isTerminated: \(runningApp.isTerminated)")
print("bundleId: \(runningApp.bundleIdentifier ?? "unknown") processID: \(runningApp.processIdentifier)")
}
}
return 0
}
/**
* 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 doAsyncAccessCheck() {
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("doAsyncAccessCheck, listening across process boundaries, time: \(Date().formatted(timeStyle))")
// register to listen to system-wide notification sent from Keyman input method
observer = DistributedNotificationCenter.default().addObserver(
forName: .accessCheck,
object: nil, // nil to listen for this notification from any sender
queue: .main
) { [weak self] notification in
// in DistributedNotificationCenter, the string message is passed as the object
if let receivedString = notification.object as? String {
Task { @MainActor [weak self] in
self?.processAccessCheckResult(with: receivedString)
}
} else {
print("notification received, but object was not a String.")
}
}
}
private func processAccessCheckResult(with message: String) {
let timeStyle = Date.FormatStyle()
.hour(.twoDigits(amPM: Date.FormatStyle.Symbol.Hour.AMPMStyle.abbreviated))
.minute(.twoDigits)
.second(.twoDigits)
.secondFraction(.fractional(3))
print("processAccessCheckResult received message: \(message), time: \(Date().formatted(timeStyle))")
// if the message indicates that access was granted, then set as true
self.requestAccessGranted = message == kAccessGrantedMessage
// Clean up the observer immediately so it only fires once
if let observer = observer {
DistributedNotificationCenter.default().removeObserver(observer)
}
}
/**
@ -408,11 +487,6 @@ public class InputMethodUtil {
return nil
}
// // Get the input source ID
// if let inputSourceId = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) {
// ConfigLogger.shared.testLogger.debug("Input Source ID: \(String(describing: inputSourceId))")
// }
//
let inputSourceValue = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID)
if let cfType = inputSourceValue {
// Bridge the CFTypeRef to an Unmanaged<AnyObject> and then to a Swift String

View file

@ -11,6 +11,7 @@
import Foundation
import KeymanSettings
public class InstallationCheck {
fileprivate let keymanVersion: String
fileprivate let defaultsRepository: DefaultsRepo
@ -62,7 +63,7 @@ public class InstallationCheck {
}
/**
* Check the version number to see if this is a new installation
* Creates a InstallationState object describing a new installation
*/
func createInstallationStateForNewInstallation() -> InstallationState {
let installationState = InstallationState(version: self.keymanVersion, tasks: self.createNewInstallationTasks())
@ -104,8 +105,9 @@ public class InstallationCheck {
// also need to restart after enabling the input method
repairTasks.insert(InstallationTask.createNewInstallationTask(type: .restartMac))
}
if !self.inputMethodUtil.invokeKeymanInputMethodCheckAccess() {
self.inputMethodUtil.doAsyncAccessCheck()
if !self.inputMethodUtil.requestAccessGranted {
repairTasks.insert(InstallationTask.createNewInstallationTask(type: .requestAccess))
}

View file

@ -11,6 +11,7 @@ import SwiftUI
import Combine
import KeymanSettings
@MainActor // run on the main actor since data is published directly to the UI
public class InstallationContainer : ObservableObject {
@Published public var installationState: InstallationState
@ -35,8 +36,58 @@ public class InstallationContainer : ObservableObject {
notificationCenter = DistributedNotificationCenter.default()
self.installationState = InstallationCheck(version: "1.0.0", defaultsRepo: defaultsRepo, inputMethodUtil: inputMethodUtil).evaluate()
self.registerObservers()
}
public func accessIsGranted() -> Bool {
self.inputMethodUtil.requestAccessGranted
}
func registerObservers() {
DispatchQueue.main.async {
// Register for the distributed notification
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(self.handleCheckAccess(_:)),
name: NSNotification.Name("com.keyman.accessibility.state.ignore"),
// name: .accessCheck,
object: nil // Observe notifications from any sender
)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(self.handleRequestAccess(_:)),
name: NSNotification.Name("com.activeoyster.test"),
object: nil // Observe notifications from any sender
)
}
}
@objc func handleCheckAccess(_ notification: Notification) {
let details:String
// extract message from the notification if available
// if let userInfo = notification.userInfo as? [String: Any],
// let message = userInfo["message"] as? String {
if let message = notification.object as? String {
details = message
} else {
details = "no data included."
}
print("Notification received by handleCheckAccess: \(details)")
}
@objc func handleRequestAccess(_ notification: Notification) {
let details:String
if let message = notification.object as? String {
details = message
} else {
details = "no data included."
}
print("Notification received by handleRequestAccess: \(details)")
}
/**
* return trues if every installation task is incomplete
*/
@ -151,23 +202,17 @@ public class InstallationContainer : ObservableObject {
if let mostRecentStartupTime = self.getMostRecentRestartTime() {
hasRestarted = mostRecentStartupTime > timeRestartRequested
print("mostRecentStartupTime: \(mostRecentStartupTime), timeRestartRequested: \(timeRestartRequested)")
self.markInstallationComplete()
}
}
print("validateRestarted: \(hasRestarted)")
return hasRestarted
}
/**
* for testing purposes, replace the InstallationState with a new object set for a new installation
*/
func resetInstallation() {
// self.state = kNewInstall
// self.defaultsRepository.writeInstallationState(kNewInstall)
// self.defaultsRepository.clearRestartRequestTime()
}
func markInstallationComplete() {
// self.state = kInstallComplete
// self.defaultsRepository.writeInstallationState(kInstallComplete)
// self.defaultsRepository.clearRestartRequestTime()
self.installationState = InstallationCheck(version: "1.0.0", defaultsRepo: self.defaultsRepository, inputMethodUtil: self.inputMethodUtil).createInstallationStateForNewInstallation()
}
/**
@ -188,15 +233,12 @@ public class InstallationContainer : ObservableObject {
}
}
// if let bootDate = getLastRestartTime() {
// print("Last restart time: \(bootDate)")
// }
public func debug() {
let version = inputMethodUtil.getKeymanInputMethodVersion()
let enabled = inputMethodUtil.isKeymanInputMethodEnabled()
let running = inputMethodUtil.isKeymanInputMethodRunning()
print("Keyman status, version: \(version ?? ""), enabled: \(enabled), running: \(running)")
let accessGranted = self.accessIsGranted()
print("Keyman status, version: \(version ?? ""), enabled: \(enabled), running: \(running), accessGranted: \(accessGranted)")
}
@ -257,16 +299,17 @@ public class InstallationContainer : ObservableObject {
* listen for message from Keyman to indicate the result
*/
public func isAccessibilityGranted() -> Bool {
var hasAccess = false
if (inputMethodUtil.isKeymanInputMethodRunning()) {
let killed = self.inputMethodUtil.killKeymanInputMethod()
print("checkAccessibilityPermission, Keyman input method killed: \(killed)")
} else {
print("checkAccessibilityPermission, Keyman input method not running")
}
let hasAccess = self.inputMethodUtil.invokeKeymanInputMethodCheckAccess()
print("checkAccessibilityPermission, Keyman input method has permission: \(hasAccess)")
self.inputMethodUtil.doAsyncAccessCheck()
return hasAccess
}
@ -277,17 +320,19 @@ public class InstallationContainer : ObservableObject {
*/
// MAC-CONFIG-TODO: do we get an accurate message from Keyman Input Method
public func requestAccessibility() -> Bool {
var requested = false
if (inputMethodUtil.isKeymanInputMethodRunning()) {
let killed = self.inputMethodUtil.killKeymanInputMethod()
print("checkAccessibilityPermission, Keyman input method killed: \(killed)")
print("requestAccessibility, Keyman input method killed: \(killed)")
} else {
print("checkAccessibilityPermission, Keyman input method not running")
print("requestAccessibility, Keyman input method not running")
}
let grantedAccess = self.inputMethodUtil.invokeKeymanInputMethodGrantAccess()
print("requestAccess suceeded: \(grantedAccess)")
requested = self.inputMethodUtil.invokeKeymanInputMethodRequestAccess()
print("requestAccessibility called, requested: \(requested)")
return grantedAccess
return requested
}
@ -295,7 +340,13 @@ public class InstallationContainer : ObservableObject {
* run Keyman as a separate process with its full Input Method functionality
*/
public func runKeymanInputMethod() -> Bool {
return self.inputMethodUtil.runKeymanInputMethod()
do {
try self.inputMethodUtil.runKeymanInputMethod()
return true
} catch {
print("runKeymanInputMethod error: \(error)")
return false
}
}
/**

View file

@ -1,10 +1,9 @@
//
// main.m
// Keyman4MacIM
//
// Created by Serkan Kurt on 28/01/2015.
// Copyright (c) 2017 SIL International. All rights reserved.
//
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by Serkan Kurt on 2015-01-28
*
*/
#import <Cocoa/Cocoa.h>
#import <InputMethodKit/InputMethodKit.h>
@ -14,10 +13,17 @@
#import "KMLogs.h"
const NSString *kConnectionName = @"Keyman_Input_Connection";
IMKServer *server;
// command strings passed from Keyman Configuration
NSString *kMigrateCommand = @"migrate";
NSString *kAccessCommand = @"access";
NSString *kCheckCommand = @"check";
IMKServer *server;
// notification messages sent to Keyman Configuration
NSString *kAccessGrantedMessage = @"granted";
NSString *kAccessNotGrantedMessage = @"not-granted";
void runAsInputMethod(void) {
os_log_info([KMLogs startupLog], "main runAsInputMethod");
@ -51,15 +57,20 @@ int doAccessibility(void) {
int checkAccessibility(void) {
BOOL hasAccess = NO;
NSString *message = kAccessNotGrantedMessage;
hasAccess = [PrivacyConsent.shared checkPostEventAccess];
os_log_info([KMLogs startupLog], "checkAccessibility hasAccess: %{public}@", hasAccess?@"YES":@"NO");
if (hasAccess) {
hasAccess = 0;
message = kAccessGrantedMessage;
} else {
hasAccess = 1;
}
[[NSDistributedNotificationCenter defaultCenter] postNotificationName:@"com.keyman.accessibility.state" object:message userInfo:nil deliverImmediately:YES];
return hasAccess;
}

View file

@ -28,6 +28,7 @@ public enum SettingsError: Error {
case unknownPackage
}
@MainActor // run on the main actor since data is published directly to the UI
public class SettingsContainer : ObservableObject {
// packages are loaded from disk, each package may contain one or more keyboard
@Published public var installedPackages: [KeymanPackage]