mirror of
https://github.com/keymanapp/keyman.git
synced 2026-10-28 00:37:53 +00:00
feat(mac): add async notification of Accessibility check
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled
This commit is contained in:
parent
ddabddd720
commit
ccd5f18fc6
5 changed files with 196 additions and 57 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue