spiegel-keyman/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m

521 lines
22 KiB
Objective-C

//
// KMInputMethodEventHandler.m
// Keyman4MacIM
//
// Created by Tom Bogle on 11/22/17.
// Copyright © 2017-2018 SIL International. All rights reserved.
//
#import "KMInputMethodEventHandler.h"
#import <KeymanEngine4Mac/KeymanEngine4Mac.h>
#import <Carbon/Carbon.h> /* For kVK_ constants. */
#import "KeySender.h"
#import "TextApiCompliance.h"
#import "KMSettingsRepository.h"
#import "KMLogs.h"
@import Sentry;
@interface KMInputMethodEventHandler ()
@property (nonatomic, retain) KeySender* keySender;
@property (nonatomic, retain) TextApiCompliance* apiCompliance;
@property NSUInteger generatedBackspaceCount;
@property NSUInteger lowLevelBackspaceCount;
@property (nonatomic, retain) NSString* clientApplicationId;
@property NSString *queuedText;
@property BOOL contextChanged;
@end
@implementation KMInputMethodEventHandler
const CGKeyCode kProcessPendingBuffer = 0xFF;
const NSUInteger kMaxContext = 80;
const NSTimeInterval kQueuedTextEventDelayInSeconds = 0.1;
CGKeyCode _keyCodeOfOriginalEvent;
CGEventSourceRef _sourceFromOriginalEvent = nil;
CGEventSourceRef _sourceForGeneratedEvent = nil;
NSMutableString* _easterEggForSentry = nil;
NSString* const kEasterEggText = @"Sentry force now";
NSString* const kEasterEggKmxName = @"EnglishSpanish.kmx";
// This is the public initializer.
- (instancetype)initWithClient:(NSString *)clientAppId client:(id) sender {
_keySender = [[KeySender alloc] init];
_clientApplicationId = clientAppId;
_generatedBackspaceCount = 0;
_lowLevelBackspaceCount = 0;
_queuedText = nil;
BOOL forceSentryCrash = [KMSettingsRepository.shared readForceSentryError];
// In Xcode, if Keyman is the active IM and the settings include the
// forceSentryCrash flag and "English plus Spanish" is the current keyboard
// and you type "Sentry force now", it will force a simulated crash to
// test reporting to sentry.keyman.com
if (forceSentryCrash && [clientAppId isEqual: @"com.apple.dt.Xcode"]) {
os_log_debug([KMLogs testLog], "initWithClient, preparing to force Sentry crash.");
_easterEggForSentry = [[NSMutableString alloc] init];
}
else
_easterEggForSentry = nil;
[SentrySDK configureScope:^(SentryScope * _Nonnull scope) {
[scope setTagValue:clientAppId forKey:@"clientAppId"];
}];
return self;
}
- (void)deactivate {
if (_generatedBackspaceCount > 0 || (_queuedText != nil && _queuedText.length > 0) ||
_keyCodeOfOriginalEvent != 0 || _sourceFromOriginalEvent != nil)
{
os_log_error([KMLogs lifecycleLog], "ERROR: new app activated before previous app finished processing pending events! _generatedBackspaceCount: %lu, _queuedText: '%{public}@' _keyCodeOfOriginalEvent: %hu", _generatedBackspaceCount, _queuedText == nil ? @"(NIL)" : (NSString*)[self queuedText], _keyCodeOfOriginalEvent);
_keyCodeOfOriginalEvent = 0;
_generatedBackspaceCount = 0;
_queuedText = nil;
}
}
- (void)dealloc {
if (_sourceFromOriginalEvent != nil) {
CFRelease(_sourceFromOriginalEvent);
_sourceFromOriginalEvent = nil;
}
if (_sourceForGeneratedEvent != nil) {
CFRelease(_sourceForGeneratedEvent);
_sourceForGeneratedEvent = nil;
}
}
- (void)handleCommand:(NSEvent *)event {
// There are a bunch of common navigation/selection command-key combinations, but individual
// apps may implement specific commands that also change the selection. There is probably no
// situation where a user could reasonably hope that any dead-keys typed before using a
// command shortcut would be remembered, so other than an insignificant performance penalty,
// the only downside to treating all commands as having the potential to change the selection
// is that some non-compliant apps can't get their context at all. This can be overridden so that
// non-compliant apps can mitigate this problem as appropriate.
os_log_debug([KMLogs keyLog], "KMInputMethodEventHandler handleCommand, event type=%lu, must refresh context", (unsigned long)event.type);
self.contextChanged = YES;
}
- (KMInputMethodAppDelegate *)appDelegate {
return (KMInputMethodAppDelegate *)[NSApp delegate];
}
- (KMEngine *)kme {
return self.appDelegate.kme;
}
- (BOOL) handleEventWithKeymanEngine:(NSEvent *)event in:(id) sender {
CoreKeyOutput *output = nil;
output = [self processEventWithKeymanEngine:event in:sender];
if (output == nil) {
[self checkEventForSentryEasterEgg:event];
return NO;
}
return [self applyKeymanCoreActions:output event:event client:sender];
}
- (CoreKeyOutput*) processEventWithKeymanEngine:(NSEvent *)event in:(id) sender {
CoreKeyOutput* coreKeyOutput = nil;
if (self.appDelegate.lowLevelEventTap != nil) {
NSEventModifierFlags modifierFlags = [self.appDelegate determineModifiers];
os_log_debug([KMLogs eventsLog], "processEventWithKeymanEngine, using modifierFlags 0x%lX, instead of event.modifiers 0x%lX", modifierFlags, event.modifierFlags);
NSEvent *eventWithAppropriateModifierFlags = [NSEvent keyEventWithType:event.type location:event.locationInWindow modifierFlags:modifierFlags timestamp:event.timestamp windowNumber:event.windowNumber context:[NSGraphicsContext currentContext] characters:event.characters charactersIgnoringModifiers:event.charactersIgnoringModifiers isARepeat:event.isARepeat keyCode:event.keyCode];
coreKeyOutput = [self.kme processEvent:eventWithAppropriateModifierFlags];
}
else {
/**
* Without the low level event tap, there will be no way to distinguish left and right option keys.
* Also command-key actions that should invalidate the context may go undetected.
* Have yet to determine which scenarios prevent an event tap from be created
*/
coreKeyOutput = [self.kme processEvent:event];
}
return coreKeyOutput;
}
- (void)checkEventForSentryEasterEgg:(NSEvent *)event {
if (_easterEggForSentry != nil) {
NSString * kmxName = [[self.kme.kmx filePath] lastPathComponent];
os_log_debug([KMLogs testLog], "Sentry - KMX name: %{public}@", kmxName);
if ([kmxName isEqualToString:kEasterEggKmxName]) {
NSUInteger len = [_easterEggForSentry length];
os_log_debug([KMLogs testLog], "Sentry - Processing character(s): %{public}@", [event characters]);
if ([[event characters] characterAtIndex:0] == [kEasterEggText characterAtIndex:len]) {
NSString *characterToAdd = [kEasterEggText substringWithRange:NSMakeRange(len, 1)];
os_log_debug([KMLogs testLog], "Sentry - Adding character to Easter Egg code string: %{public}@", characterToAdd);
[_easterEggForSentry appendString:characterToAdd];
if ([_easterEggForSentry isEqualToString:kEasterEggText]) {
os_log_debug([KMLogs testLog], "Sentry - Forcing crash now");
[SentrySDK crash];
}
}
else if (len > 0) {
os_log_debug([KMLogs testLog], "Sentry - Clearing Easter Egg code string.");
[_easterEggForSentry setString:@""];
}
}
}
}
/**
handleBackspace: handles generated backspaces for a subset of non-compliant apps (such as Adobe apps) that do
not support replacing text with the insertText API but also intercept events so that we do not see them in handleEvent.
Other apps, such as Word, show the event both in the low
level EventTap and in the normal IM handleEvent. Thus, we set a flag after
processing a backspace here so that we do not process it again in handleEvent.
Note that a backspace that we handle here should never be passed on to Keyman
Core for processing, as it is already the result of processing.
*/
- (void)handleBackspace:(NSEvent *)event {
os_log_debug([KMLogs eventsLog], "KMInputMethodEventHandler handleBackspace, event = %{public}@", event);
if (self.generatedBackspaceCount > 0) {
self.generatedBackspaceCount--;
self.lowLevelBackspaceCount++;
// if we just encountered the last backspace, then send event to insert queued text
if ((self.generatedBackspaceCount == 0) && (self.queuedText.length > 0)) {
[self performSelector:@selector(triggerInsertQueuedText:) withObject:event afterDelay:kQueuedTextEventDelayInSeconds];
}
}
}
- (void)triggerInsertQueuedText:(NSEvent *)event {
os_log_debug([KMLogs eventsLog], "KMInputMethodEventHandler triggerInsertQueuedText");
[self.keySender sendKeymanKeyCodeForEvent:event];
}
//MARK: Core-related key processing
- (void)checkTextApiCompliance:(id)client {
// If the TextApiCompliance object is nil or the app or the context has changed,
// then create a new object for the current application and context.
// The text api compliance may vary from one text field to another of the same app.
if ((self.apiCompliance == nil) ||
(![self.apiCompliance.clientApplicationId isEqualTo:self.clientApplicationId]) ||
self.contextChanged) {
self.apiCompliance = [[TextApiCompliance alloc]initWithClient:client applicationId:self.clientApplicationId];
os_log_debug([KMLogs complianceLog], "KMInputMethodHandler initWithClient checkTextApiCompliance: %{public}@", _apiCompliance);
} else if (self.apiCompliance.isComplianceUncertain) {
// We have a valid TextApiCompliance object, but compliance is uncertain, so test it
[self.apiCompliance checkCompliance:client];
}
}
- (void) handleContextChangedByLowLevelEvent {
if (self.appDelegate.contextChangedByLowLevelEvent) {
if (!self.contextChanged) {
os_log_debug([KMLogs complianceLog], "Low-level event has changed the context.");
self.contextChanged = YES;
}
self.appDelegate.contextChangedByLowLevelEvent = NO;
}
}
// handleEvent implementation for core event processing
- (BOOL)handleEvent:(NSEvent *)event client:(id)sender {
BOOL handled = NO;
os_log_debug([KMLogs eventsLog], "handleEvent፡ event = %{public}@", event);
[self checkTextApiCompliance:sender];
// mouse movement requires that the context be invalidated
[self handleContextChangedByLowLevelEvent];
if (event.type == NSEventTypeKeyDown) {
// indicates that our generated backspace event(s) are consumed
// and we can insert text that followed the backspace(s)
if (event.keyCode == kKeymanEventKeyCode) {
os_log_debug([KMLogs eventsLog], "handleEvent, handling kKeymanEventKeyCode");
[self insertQueuedText: event client:sender];
return YES;
}
// for some apps, handleEvent will not be called for the generated backspace
// but if it is, we need to let it pass through rather than process again in core
if((event.keyCode == kVK_Delete) && (self.lowLevelBackspaceCount > 0)) {
os_log_debug([KMLogs eventsLog], "handleEvent, allowing generated backspace to pass through");
self.lowLevelBackspaceCount--;
// return NO to pass through to client app
return NO;
}
}
if (event.type == NSEventTypeFlagsChanged) {
[self handleFlagsChangedEvent:[event keyCode]];
return NO;
}
if ((event.modifierFlags & NSEventModifierFlagCommand) == NSEventModifierFlagCommand) {
[self handleCommand:event];
return NO; // let the client app handle all Command-key events.
}
if (event.type == NSEventTypeKeyDown) {
[self reportContext:event forClient:sender];
handled = [self handleEventWithKeymanEngine:event in: sender];
}
os_log_debug([KMLogs eventsLog], "event, keycode: %u, characters='%{public}@' handled = %{public}@", event.keyCode, event.characters, handled?@"yes":@"no");
return handled;
}
-(void)handleFlagsChangedEvent:(short)keyCode {
// Mark the context as out of date for the command keys
switch(keyCode) {
case kVK_RightCommand:
case kVK_Command:
self.contextChanged = YES;
break;
case kVK_Shift:
case kVK_RightShift:
case kVK_CapsLock:
case kVK_Option:
case kVK_RightOption:
case kVK_Control:
case kVK_RightControl:
break;
}
}
/**
* Called for every keystroke.
* If we can read the context, send it to Keyman Core.
* For non-compliant apps, we cannot read the context.
* In the non-compliant app case, if the context has changed then clear it in Core.
*/
-(void)reportContext:(NSEvent *)event forClient:(id) client {
NSString *contextString = nil;
// if we can read the text, then get the context and send it to Core
// we do this whether the context has changed or not
if (self.apiCompliance.canReadText) {
contextString = [self readContext:event forClient:client];
os_log_debug([KMLogs eventsLog], "reportContext, setting new context='%{public}@' for compliant app (if needed)", contextString);
[self.kme setCoreContextIfNeeded:contextString];
} else if (self.contextChanged) {
// we cannot read the text but know the context has changed, so we must clear it
os_log_debug([KMLogs eventsLog], "reportContext, clearing context for non-compliant app");
[self.kme clearCoreContext];
}
// the context is now current, reset the flag
self.contextChanged = NO;
}
-(NSString*)readContext:(NSEvent *)event forClient:(id) client {
NSString *contextString = @"";
NSAttributedString *attributedString = nil;
// if we can read the text, then get the context for up to kMaxContext characters
if (self.apiCompliance.canReadText) {
NSRange selectionRange = [client selectedRange];
NSUInteger contextLength = MIN(kMaxContext, selectionRange.location);
NSUInteger contextStart = selectionRange.location - contextLength;
if (contextLength > 0) {
os_log_debug([KMLogs eventsLog], " *** InputMethodEventHandler readContext, %lu characters", (unsigned long)contextLength);
NSRange contextRange = NSMakeRange(contextStart, contextLength);
attributedString = [client attributedSubstringFromRange:contextRange];
// adjust string in case that we receive half of a surrogate pair at context start
// the API appears to always return a full code point, but this could vary by app
if (attributedString.length > 0) {
if (CFStringIsSurrogateLowCharacter([attributedString.string characterAtIndex:0])) {
os_log_debug([KMLogs eventsLog], " *** InputMethodEventHandler readContext, first char is low surrogate, reducing context by one character");
contextString = [attributedString.string substringFromIndex:1];
} else {
contextString = attributedString.string;
}
}
}
}
return contextString;
}
/**
* Returns NO if we have not applied an event to the client or if Keyman Core determines that we should emit the keystroke
*/
-(BOOL)applyKeymanCoreActions:(CoreKeyOutput*)output event: (NSEvent*)event client:(id) client {
//os_log_debug([KMLogs eventsLog], "InputMethodEventHandler applyKeymanCoreActions: output = %{public}@ ", output);
BOOL handledEvent = [self applyKeyOutputToTextInputClient:output keyDownEvent:event client:client];
[self applyNonTextualOutput:output];
// if output from Keyman Core indicates to emit the keystroke,
// then return NO, so that the OS still handles the event
if (handledEvent && output.emitKeystroke) {
os_log_debug([KMLogs eventsLog], " *** emitKeystroke, not handling event");
handledEvent = NO;
}
return handledEvent;
}
-(BOOL)applyKeyOutputToTextInputClient:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client {
BOOL handledEvent = YES;
if (output.isInsertOnlyScenario) {
os_log_debug([KMLogs keyLog], "applyOutputToTextInputClient, insert only scenario");
[self insertAndReplaceTextForOutput:output client:client];
} else if (output.isDeleteOnlyScenario) {
if ((event.keyCode == kVK_Delete) && output.codePointsToDeleteBeforeInsert == 1) {
// let the delete pass through in the original event rather than sending a new delete
os_log_debug([KMLogs keyLog], "applyOutputToTextInputClient, delete only scenario with passthrough");
handledEvent = NO;
} else {
os_log_debug([KMLogs keyLog], "applyOutputToTextInputClient, delete only scenario");
[self sendEvents:event forOutput:output];
}
} else if (output.isDeleteAndInsertScenario) {
// TODO: fix issue #10246
/*
// needs further testing to fix backspace bug in google docs (without breaking other apps)
// assume that all non-compliant apps which require backspaces apply an extra backspace if the original event is a backspace
if ((self.apiCompliance.mustBackspaceUsingEvents) && (event.keyCode == kVK_Delete)) {
output.codePointsToDeleteBeforeInsert--;
os_log_info(keymanLog, "isDeleteAndInsertScenario, after delete pressed subtracting one backspace to reach %d and insert text '%{public}@'", output.codePointsToDeleteBeforeInsert, output.textToInsert);
if (output.codePointsToDeleteBeforeInsert == 0) {
// no backspace events needed
[self insertAndReplaceTextForOutput:output client:client];
} else {
// still need at least one backspace event
[self sendEvents:event forOutput:output];
}
}
*/
if (self.apiCompliance.mustBackspaceUsingEvents) {
os_log_debug([KMLogs keyLog], "applyOutputToTextInputClient, delete and insert scenario with events");
[self sendEvents:event forOutput:output];
} else {
os_log_debug([KMLogs keyLog], "applyOutputToTextInputClient, delete and insert scenario with insert API");
// directly insert text and handle backspaces by using replace
[self insertAndReplaceTextForOutput:output client:client];
}
}
return handledEvent;
}
-(void)applyNonTextualOutput:(CoreKeyOutput*)output {
[self persistOptions:output.optionsToPersist];
[self applyCapsLock:output.capsLockState];
if (output.alert) {
NSBeep();
}
}
-(void) persistOptions:(NSDictionary*)options{
for(NSString *key in options) {
NSString *value = [options objectForKey:key];
if(key && value) {
os_log_debug([KMLogs keyLog], "persistOptions, key: %{public}@, value: %{public}@", key, value);
[[KMSettingsRepository shared] writeOptionForSelectedKeyboard:key withValue:value];
}
else {
os_log_debug([KMLogs keyLog], "invalid values in persistOptions, not writing to UserDefaults, key: %{public}@, value: %{public}@", key, value);
}
}
}
-(void) applyCapsLock:(CapsLockState)capsLockState {
switch(capsLockState) {
case On:
//TODO: handle this, Issue #10244
break;
case Off:
//TODO: handle this, Issue #10244
break;
case Unchanged:
// do nothing
break;
}
}
-(void)insertAndReplaceTextForOutput:(CoreKeyOutput*)output client:(id) client {
[self insertAndReplaceText:output.textToInsert deleteCount:(int)output.codePointsToDeleteBeforeInsert toReplace:output.textToDelete client:client];
}
/**
* This directly inserts text and applies backspaces for the operation by replacing existing text with the new text.
* Because this method depends on the selectedRange API which is not implemented correctly for some client applications,
* this method can only be used if approved by TextApiCompliance
*/
-(void)insertAndReplaceText:(NSString *)text deleteCount:(int) replacementCount toReplace:(NSString*)textToDelete client:(id) client {
os_log_debug([KMLogs keyLog], "insertAndReplaceText, insert: %{public}@, delete: %{public}@, replacementCount: %d", text, textToDelete, replacementCount);
NSRange selectionRange = [client selectedRange];
NSRange replacementRange = [self calculateInsertRangeForDeletedText:textToDelete selectionRange:selectionRange];
[client insertText:text replacementRange:replacementRange];
if (self.apiCompliance.isComplianceUncertain) {
[self.apiCompliance checkComplianceAfterInsert:client delete:textToDelete insert:text];
}
}
/*
* Calculates the range where text will be inserted and replace existing text.
* Returning {NSNotFound, NSNotFound} for range signifies to insert at current location without replacement.
*/
-(NSRange) calculateInsertRangeForDeletedText:(NSString*)textToDelete selectionRange:(NSRange) selection {
NSRange notFoundRange = NSMakeRange(NSNotFound, NSNotFound);
NSRange resultingReplacementRange = NSMakeRange(0, 0);
// range = current location if either
// 1. no text has been designated to be deleted, or
// 2. the length of the text to delete is impossible (greater than the location)
if ((textToDelete.length == 0) || (textToDelete.length > selection.location)) {
// magic value of {NSNotFound, NSNotFound} passed to insertText means "do not replace, insert in current location"
return notFoundRange;
} else {
resultingReplacementRange.length = textToDelete.length + selection.length;
resultingReplacementRange.location = selection.location - textToDelete.length;
}
return resultingReplacementRange;
}
-(void)sendEvents:(NSEvent *)event forOutput:(CoreKeyOutput*)output {
os_log_debug([KMLogs keyLog], "sendEvents called, output = %{public}@", output);
_sourceForGeneratedEvent = CGEventCreateSourceFromEvent([event CGEvent]);
self.generatedBackspaceCount = output.codePointsToDeleteBeforeInsert;
if (output.hasCodePointsToDelete) {
for (int i = 0; i < output.codePointsToDeleteBeforeInsert; i++) {
os_log_debug([KMLogs keyLog], "sendEvents sending backspace key event");
[self.keySender sendBackspaceforEventSource:_sourceForGeneratedEvent];
}
}
if (output.hasTextToInsert) {
os_log_debug([KMLogs keyLog], "sendEvents, queueing text to insert: %{public}@", output.textToInsert);
self.queuedText = output.textToInsert;
}
}
-(void)insertQueuedText: (NSEvent *)event client:(id) client {
if (self.queuedText.length> 0) {
os_log_debug([KMLogs keyLog], "insertQueuedText, inserting %{public}@", self.queuedText);
[self insertAndReplaceText:self.queuedText deleteCount:0 toReplace:nil client:client];
self.queuedText = nil;
} else {
os_log_debug([KMLogs keyLog], "insertQueuedText called but no text to insert");
}
}
@end