diff --git a/mac/Config/Config/InstallDebugView.swift b/mac/Config/Config/InstallDebugView.swift index 50b03ed3c36..31b987a4c74 100644 --- a/mac/Config/Config/InstallDebugView.swift +++ b/mac/Config/Config/InstallDebugView.swift @@ -77,7 +77,7 @@ struct InstallDebugView: View { Text(verbatim: "Request Restart") } Button { - _ = installation.validateUserHasRestarted() + _ = installation.checkUserHasRestarted() } label: { Text(verbatim: "Check Restart") } diff --git a/mac/Config/Installation/InstallationCheck.swift b/mac/Config/Installation/InstallationCheck.swift index 0d153bb7321..a414d121092 100644 --- a/mac/Config/Installation/InstallationCheck.swift +++ b/mac/Config/Installation/InstallationCheck.swift @@ -171,11 +171,23 @@ public class InstallationCheck { public func startInstallationEvaluation() { // call the input method to check whether Accessibility permission has been granted if (self.isInputMethodInstalled && self.isInputMethodCurrent) && - (self.isEvaluatingNewInstallation || self.installationState?.isComplete == true) { - self.inputMethodUtil.doAsyncAccessibilityCheck() + (self.isEvaluatingNewInstallation || self.isReadyForRepairCheckAtStartup()) { + self.inputMethodUtil.doAsyncAccessibilityCheck(forceInputMethodRestart: false) } } + /** + * Returns true if we should check at app startup whether the installation needs repair. + * Simply returns true if the installation is complete. + */ + func isReadyForRepairCheckAtStartup() -> Bool { + let readyForRepairCheck = self.installationState?.isComplete == true + + Logger.app.debug("isReadyForRepairCheckAtStartup: \(readyForRepairCheck, privacy: .public)") + + return readyForRepairCheck + } + /** * Check whether the input method and configuration app are the same version. * Because the version of the config app will not be sent when build locally, this can be overridden, diff --git a/mac/Config/Installation/InstallationContainer.swift b/mac/Config/Installation/InstallationContainer.swift index 50c5f2d9dcc..27bc80413f0 100644 --- a/mac/Config/Installation/InstallationContainer.swift +++ b/mac/Config/Installation/InstallationContainer.swift @@ -63,7 +63,9 @@ public class InstallationContainer : ObservableObject { // If we can now confirm that the user restarted (the final task), then the installation // will be complete and there is no need to evaluate the state. // Otherwise, evaluate the installation to prepare for a new installation or check for repairs. - if !self.validateConfirmRestart() { + if self.confirmRestartRequired() { + self.confirmUserRestarted() + } else { self.registerObservers() self.installationCheck.startInstallationEvaluation() } @@ -126,7 +128,8 @@ public class InstallationContainer : ObservableObject { */ @objc func handleAccessibilityGranted(_ notification: Notification) { guard self.installationState != nil else { return } - + Logger.app.debug("handleAccessibilityGranted received") + // the confirmAccess task can now be marked as completed if let task = self.currentTask() { if task.taskType == .confirmAccess { @@ -141,25 +144,33 @@ public class InstallationContainer : ObservableObject { * called when `NSNotification.Name.accessibilityNotGranted` is received */ @objc func handleAccessibilityNotGranted(_ notification: Notification) { + Logger.app.debug("handleAccessibilityNotGranted received") NotificationCenter.default.post(name: .checkAccessibilityFailure, object: nil, userInfo: nil) } /** - * If the current task is confirmRestart, mark it as complete if the user has restarted. + * If the current task is confirmRestart, mark it as complete if the user has restarted their mac. */ - func validateConfirmRestart() -> Bool { - guard let task = self.currentTask() else { return false } - guard self.installationState != nil else { return false } + func confirmUserRestarted() { + guard let task = self.currentTask() else { return } + guard self.installationState != nil else { return } - if task.taskType == .confirmRestart && self.validateUserHasRestarted() { + if task.taskType == .confirmRestart && self.checkUserHasRestarted() { // the confirmAccess task can now be marked as completed self.updateTaskAsCompleted(taskType: .confirmRestart) - return true - } else { - return false } } + /** + * Check whether waiting to confirm that the user restarted. + */ + func confirmRestartRequired() -> Bool { + guard let task = self.currentTask() else { return false } + guard self.installationState != nil else { return false } + + return task.taskType == .confirmRestart + } + /** * Returns true if the Accessibility permission has been granted by the user for the Keyman input method. * This is an optional return value because it is only set in response to a call to `checkAccessibilityPermissionGranted` @@ -240,7 +251,7 @@ public class InstallationContainer : ObservableObject { case .requestRestart: completedTask = self.notifyUserPromptedToRestart() case .confirmRestart: - completedTask = self.validateUserHasRestarted() + completedTask = self.checkUserHasRestarted() } if completedTask { @@ -347,7 +358,7 @@ public class InstallationContainer : ObservableObject { /** * Check whether the user has restarted by comparing the latest startup time to the time we requested the user to restart */ - public func validateUserHasRestarted() -> Bool { + public func checkUserHasRestarted() -> Bool { var hasRestarted = false guard let state = self.installationState else { return false } @@ -441,7 +452,7 @@ public class InstallationContainer : ObservableObject { * call Keyman as a separate process with an argument that checks whether accessibility has been granted by the user */ public func checkAccessibilityPermissionGranted() { - self.inputMethodUtil.doAsyncAccessibilityCheck() + self.inputMethodUtil.doAsyncAccessibilityCheck(forceInputMethodRestart: false) } /** diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m index a4eb680bd35..ef0c140f5b1 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m @@ -30,6 +30,8 @@ // distributed notifications NSString *const kKeyboardsChanged = @"com.keyman.keyboards.changed"; +NSString *const kAccessibilityCheckedRequest = @"com.keyman.accessibility.check.request"; +NSString *const kAccessibilityRequest = @"com.keyman.accessibility.request"; // in-app notifications NSString *const kKeymanKeyboardDownloadCompletedNotification = @"kKeymanKeyboardDownloadCompletedNotification"; @@ -80,7 +82,9 @@ @implementation KMInputMethodAppDelegate - (id)init { self = [super init]; - if (self) { + if (self) { + [self registerObservers]; + // first notify user and request access to Accessibility/PostEvent permissions // pass block as completion handler to complete init with initCompletion [PrivacyConsent.shared requestPrivacyAccess:^void (void){ @@ -123,7 +127,15 @@ - (void)initCompletion { if (self.runLoopEventSrc && runLoop) { CFRunLoopAddSource(runLoop, self.runLoopEventSrc, kCFRunLoopDefaultMode); } - + + // start Input Method lifecycle + [KMInputMethodLifecycle.shared startLifecycle]; +} + +/** + * Register observers for local and distributed notifications + */ +- (void)registerObservers { // register to receive notifications generated from KMInputMethodLifecycle [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inputMethodActivated:) name:kInputMethodActivatedNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inputMethodDeactivated:) name:kInputMethodDeactivatedNotification object:nil]; @@ -131,12 +143,10 @@ - (void)initCompletion { // register to receive notifications generated from Keyman Configuration App [[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(handleKeyboardsChanged:) name:kKeyboardsChanged object:nil]; - - // start Input Method lifecycle - [KMInputMethodLifecycle.shared startLifecycle]; + [[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAccessibilityCheckRequested:) name:kAccessibilityCheckedRequest object:nil]; + [[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAccessibilityRequested:) name:kAccessibilityRequest object:nil]; } - /** * When packages have been installed, removed, enabled or disabled -- notification from the Keyman Configuration app */ @@ -145,6 +155,22 @@ - (void)handleKeyboardsChanged:(NSNotification *)notification { [self reloadEnabledKeyboards]; } +/** + * When the state of Accessibility permission has been requested -- notification from the Keyman Configuration app + */ +- (void)handleAccessibilityCheckRequested:(NSNotification *)notification { + BOOL hasAccess = checkAccessibility(); + os_log_info([KMLogs startupLog], "KMInputMethodAppDelegate handleAccessibilityCheckRequested, hasAccess: %{public}@", hasAccess?@"YES":@"NO"); +} + +/** + * When Accessibility permission has been requested -- notification from the Keyman Configuration app + */ +- (void)handleAccessibilityRequested:(NSNotification *)notification { + requestAccessibility(); + os_log_info([KMLogs startupLog], "KMInputMethodAppDelegate handleAccessibilityRequested"); +} + /** * When the input method is activated -- notification from KMInputMethodLifecycle */ diff --git a/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.h b/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.h index 42536b1d7cf..846bda83905 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.h @@ -13,6 +13,18 @@ NS_ASSUME_NONNULL_BEGIN +// command strings passed from Keyman Configuration +extern NSString *kMigrateCommand; +extern NSString *kAccessCommand; +extern NSString *kCheckCommand; + +// notification messages sent to Keyman Configuration +extern NSString *kAcessibilityPermissionGrantedMessage; +extern NSString *kAcessibilityPermissionNotGrantedMessage; + +int requestAccessibility(void); +int checkAccessibility(void); + @interface PrivacyConsent : NSObject @property (nonatomic, strong) PrivacyWindowController *privacyDialog; @property (nonatomic, copy, nullable) void (^completionHandler)(void); diff --git a/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.m b/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.m index 3a2ea2a9757..b4bd4000b2c 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/Privacy/PrivacyConsent.m @@ -34,6 +34,47 @@ #import "PrivacyConsent.h" #import "KMLogs.h" +// command strings passed from Keyman Configuration +NSString *kMigrateCommand = @"migrate"; +NSString *kAccessCommand = @"access"; +NSString *kCheckCommand = @"check"; + +// notification messages sent to Keyman Configuration +NSString *kAcessibilityPermissionGrantedMessage = @"granted"; +NSString *kAcessibilityPermissionNotGrantedMessage = @"not-granted"; + +/** + * Make a request to the system to add Accessibility permissions for the Keyman input method. + * Executed as requested by the Keyman Configuration app. + */ +int requestAccessibility(void) { + os_log_info([KMLogs startupLog], "requestAccessibility executed"); + [PrivacyConsent.shared requestPrivacyAccessForKeyman19:^void (void){ + os_log_info([KMLogs startupLog], "requestAccessibility completion handler: requestPrivacyAccessForKeyman19 completed"); + }]; + return 0; +} + +/** + * Check whether Accessibility permissions have been granted by the user for the Keyman input method. + * Executed as requested by the Keyman Configuration app. + */ +int checkAccessibility(void) { + BOOL hasAccess = NO; + NSString *message = kAcessibilityPermissionNotGrantedMessage; + + hasAccess = [PrivacyConsent.shared checkPostEventAccess]; + os_log_info([KMLogs startupLog], "checkAccessibility hasAccess: %{public}@", hasAccess?@"YES":@"NO"); + + if (hasAccess) { + message = kAcessibilityPermissionGrantedMessage; + } + + [[NSDistributedNotificationCenter defaultCenter] postNotificationName:@"com.keyman.accessibility.state" object:message userInfo:nil deliverImmediately:YES]; + + return hasAccess; +} + @implementation PrivacyConsent + (PrivacyConsent *)shared @@ -105,7 +146,7 @@ - (void)requestPrivacyAccessForKeyman19:(void (^)(void))withCompletionHandler // call completionHandler immediately withCompletionHandler(); } else { - os_log([KMLogs privacyLog], "does not have Accessibility, calling requestListenEventAccess"); + os_log([KMLogs privacyLog], "does not have Accessibility, calling requestPostEventAccess"); [self requestPostEventAccess]; withCompletionHandler(); } @@ -262,4 +303,5 @@ - (BOOL)requestPostEventAccess return granted; } + @end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/en.lproj/Localizable.strings b/mac/Keyman4MacIM/Keyman4MacIM/en.lproj/Localizable.strings index 367c301cea9..f300927a85c 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/en.lproj/Localizable.strings +++ b/mac/Keyman4MacIM/Keyman4MacIM/en.lproj/Localizable.strings @@ -72,10 +72,10 @@ "copyright-label" = "Copyright:"; /* message displayed to alert user to grant accessibility permission in Preferences, pre-Ventura */ -"privacy-alert-text" = "To function properly, Keyman requires accessibility features:\n\nGrant access in System Preferences, Security & Privacy.\nRestart your system."; +"privacy-alert-text" = "To function properly, Keyman requires accessibility features.\n\nGrant access in System Preferences, Security & Privacy."; /* message displayed to alert user to grant accessibility permission in Settings, for macOS Ventura and later */ -"privacy-alert-text-ventura" = "To function properly, Keyman requires accessibility features:\n\nGrant access in System Settings, Privacy & Security.\nRestart your system."; +"privacy-alert-text-ventura" = "To function properly, Keyman requires accessibility features.\n\nGrant access in System Settings, Privacy & Security."; /* Text of menu item in Input Menu when no Keyboards are configured -- include parentheses */ "no-keyboard-configured-menu-placeholder" = "(No Keyboard Configured)"; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/main.m b/mac/Keyman4MacIM/Keyman4MacIM/main.m index ad0ac10afb2..c17d51df5e5 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/main.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/main.m @@ -15,16 +15,6 @@ const NSString *kConnectionName = @"Keyman_Input_Connection"; IMKServer *server; -// command strings passed from Keyman Configuration -NSString *kMigrateCommand = @"migrate"; -NSString *kAccessCommand = @"access"; -NSString *kCheckCommand = @"check"; - -// notification messages sent to Keyman Configuration -NSString *kAcessibilityPermissionGrantedMessage = @"granted"; -NSString *kAcessibilityPermissionNotGrantedMessage = @"not-granted"; - - void runAsInputMethod(void) { os_log_info([KMLogs startupLog], "main runAsInputMethod"); NSString *identifier = [[NSBundle mainBundle] bundleIdentifier]; @@ -70,41 +60,6 @@ int doMigration(void) { return 0; } -/** - * Make a request to the system to add Accessibility permissions for the Keyman input method. - * Executed as requested by the Keyman Configuration app. - */ -int requestAccessibility(void) { - os_log_info([KMLogs startupLog], "doAccessibility executed"); - [PrivacyConsent.shared requestPrivacyAccessForKeyman19:^void (void){ - os_log_info([KMLogs startupLog], "doAccessibility completion handler: requestPrivacyAccessForKeyman19 completed"); - }]; - return 0; -} - -/** - * Check whether Accessibility permissions have been granted by the user for the Keyman input method. - * Executed as requested by the Keyman Configuration app. - */ -int checkAccessibility(void) { - BOOL hasAccess = NO; - NSString *message = kAcessibilityPermissionNotGrantedMessage; - - hasAccess = [PrivacyConsent.shared checkPostEventAccess]; - os_log_info([KMLogs startupLog], "checkAccessibility hasAccess: %{public}@", hasAccess?@"YES":@"NO"); - - if (hasAccess) { - hasAccess = 0; - message = kAcessibilityPermissionGrantedMessage; - } else { - hasAccess = 1; - } - - [[NSDistributedNotificationCenter defaultCenter] postNotificationName:@"com.keyman.accessibility.state" object:message userInfo:nil deliverImmediately:YES]; - - return hasAccess; -} - int main(int argc, const char * argv[]) { @autoreleasepool { diff --git a/mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift b/mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift index 3d4860094b0..5a01bba22a1 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/InputMethodUtil.swift @@ -172,14 +172,33 @@ public class InputMethodUtil { return self.invokeKeymanInputMethodAsSubProcess(argument: kMigrateCommand) == 0 } + /** + * Calls Keyman input method to request accessibility permission. + * The call varies based on whether Keyman is running or not. + * + * If the input method is running, then send a notification to the input method + * to request access. + * + * If the input method is not running, invoke the input method as a separate process + * with a special argument that causes it to run, request access and exit. + */ + public func invokeKeymanInputMethodRequestAccess() -> Bool { var success = false + + Logger.setup.info("invokeKeymanInputMethodRequestAccess()") + LogUtil.infoBreadcrumb("invokeKeymanInputMethodRequestAccess()", category: .setup) do { - // 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) + if self.isKeymanInputMethodRunning() { + // Keyman is not running, send notification to input method + Logger.setup.debug("invokeKeymanInputMethodRequestAccess(), calling sendAccessibilityRequest()") + self.sendAccessibilityRequest() + } else { + // Keyman is not running, launch and request accessibility with specific command line argument + Logger.setup.debug("invokeKeymanInputMethodRequestAccess(), calling launchKeymanInputMethodAsSeparateProcess()") + try self.launchKeymanInputMethodAsSeparateProcess(argument: kAccessCommand) + } + success = true } catch { Logger.setup.error("error requesting Accessibility from input method: \(error as NSError, privacy: .public)") @@ -191,21 +210,70 @@ public class InputMethodUtil { /** * 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. + * The call varies based on whether Keyman is running or not. + * + * If the input method is not running, then invoke it as a separate process with a special argument + * so that it runs, checks the access, sends a notification and exits. + * + * If the input method is running, then do not kill it unless `forceRestart` flag is set to true. + * Instead send a notification to instruct it to make the check and respond. + * + * If the input method is running and `forceRestart` is true, then kill Keyman and invoke it + * as a separate process. This is needed when a user is in the middle of an install or repair. + * + * The actual result is not returned from Keyman. * 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 { + func invokeKeymanInputMethodCheckAccess(forceInputMethodRestart forceRestart: Bool) throws { Logger.setup.info("invokeKeymanInputMethodCheckAccess()") LogUtil.infoBreadcrumb("invokeKeymanInputMethodCheckAccess()", category: .setup) - // 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) + var keymanIsRunning = self.isKeymanInputMethodRunning() + + if keymanIsRunning, forceRestart { + // kill Keyman so that it refreshes its accessibility state + _ = self.killKeymanInputMethod() + keymanIsRunning = false + Logger.setup.debug("invokeKeymanInputMethodCheckAccess(), killed Keyman") + } + + if keymanIsRunning { + // Keyman is running: check accessibility by sending distributed notification + Logger.setup.debug("invokeKeymanInputMethodCheckAccess(), calling sendAccessibilityCheckRequest()") + self.sendAccessibilityCheckRequest() + } else { + // Keyman is not running, launch and check accessibility with specific command line argument + Logger.setup.debug("invokeKeymanInputMethodCheckAccess(), calling launchKeymanInputMethodAsSeparateProcess()") + try self.launchKeymanInputMethodAsSeparateProcess(argument: kCheckCommand) + } } + /** + * Send a distributed notification to check the state of Accessibility permission. + */ + func sendAccessibilityCheckRequest() { + DistributedNotificationCenter.default().postNotificationName ( + .accessibilityCheckRequest, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + + /** + * Send a distributed notification to request Accessibility permission. + */ + func sendAccessibilityRequest() { + DistributedNotificationCenter.default().postNotificationName ( + .accessibilityRequest, + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } + /** * run Keyman as a subprocess with the specifed argument and return the result */ @@ -263,9 +331,9 @@ public class InputMethodUtil { * Calls Keyman input method to check whether it has accessibility permission granted. * Receives response as distributed notification named `accessibilityStateResponse` */ - public func doAsyncAccessibilityCheck() { + public func doAsyncAccessibilityCheck(forceInputMethodRestart forceRestart: Bool) { do { - try self.invokeKeymanInputMethodCheckAccess() + try self.invokeKeymanInputMethodCheckAccess(forceInputMethodRestart: forceRestart) } catch { Logger.setup.error("invoking Keyman failed: \(error as NSError, privacy: .public)") LogUtil.errorBreadcrumb("invoking Keyman failed: \(error as NSError)", category: .setup) diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index dee8c60105a..9c7eb97b34f 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -72,6 +72,10 @@ public extension Notification.Name { static let accessibilityStateResponse = Notification.Name("com.keyman.accessibility.state") // sent from config app (DefaultsRepository), received by input method static let keyboardsChanged = Notification.Name("com.keyman.keyboards.changed") + // sent from config app (InputMethodUtil), received by input method + static let accessibilityCheckRequest = Notification.Name("com.keyman.accessibility.check.request") + // sent from config app (InputMethodUtil), received by input method + static let accessibilityRequest = Notification.Name("com.keyman.accessibility.request") } // in-app notifications