From ccd5f18fc63a390579a229c56de04fbd9dc1027d Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Tue, 9 Jun 2026 19:18:59 -0400 Subject: [PATCH] feat(mac): add async notification of Accessibility check --- mac/Config/Installation/InputMethodUtil.swift | 114 +++++++++++++++--- .../Installation/InstallationCheck.swift | 8 +- .../Installation/InstallationContainer.swift | 101 ++++++++++++---- mac/Keyman4MacIM/Keyman4MacIM/main.m | 29 +++-- .../KeymanSettings/SettingsContainer.swift | 1 + 5 files changed, 196 insertions(+), 57 deletions(-) diff --git a/mac/Config/Installation/InputMethodUtil.swift b/mac/Config/Installation/InputMethodUtil.swift index 70391634c7..ae5f804bac 100644 --- a/mac/Config/Installation/InputMethodUtil.swift +++ b/mac/Config/Installation/InputMethodUtil.swift @@ -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 and then to a Swift String diff --git a/mac/Config/Installation/InstallationCheck.swift b/mac/Config/Installation/InstallationCheck.swift index 9359a79d17..0922b7dbe2 100644 --- a/mac/Config/Installation/InstallationCheck.swift +++ b/mac/Config/Installation/InstallationCheck.swift @@ -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)) } diff --git a/mac/Config/Installation/InstallationContainer.swift b/mac/Config/Installation/InstallationContainer.swift index 885c0d8a6e..8ebf062029 100644 --- a/mac/Config/Installation/InstallationContainer.swift +++ b/mac/Config/Installation/InstallationContainer.swift @@ -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 + } } /** diff --git a/mac/Keyman4MacIM/Keyman4MacIM/main.m b/mac/Keyman4MacIM/Keyman4MacIM/main.m index bf12bdfb73..d03fd5acf4 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/main.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/main.m @@ -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 #import @@ -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; } diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 8de6089cef..cd0f2fe156 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -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]