mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-10 10:55:33 +00:00
772 lines
33 KiB
Objective-C
772 lines
33 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 <CoreFoundation/CoreFoundation.h>
|
|
#import "KeySender.h"
|
|
#import "TextApiCompliance.h"
|
|
#import "KMSettingsRepository.h"
|
|
#import "KMLogs.h"
|
|
#import "KMSentryHelper.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;
|
|
@property BOOL sentryTestingEnabled;
|
|
@end
|
|
|
|
@implementation KMInputMethodEventHandler
|
|
|
|
const CGKeyCode kProcessPendingBuffer = 0xFF;
|
|
const NSUInteger kMaxContext = 80;
|
|
const NSTimeInterval kQueuedTextEventDelayInSeconds = 0.1;
|
|
|
|
CGKeyCode _keyCodeOfOriginalEvent;
|
|
CGEventSourceRef _sourceFromOriginalEvent = nil;
|
|
CGEventSourceRef _sourceForGeneratedEvent = nil;
|
|
|
|
// This is the public initializer.
|
|
- (instancetype)initWithClient:(NSString *)clientAppId client:(id) sender {
|
|
self = [super init];
|
|
if(self) {
|
|
_keySender = [[KeySender alloc] init];
|
|
_clientApplicationId = clientAppId;
|
|
_generatedBackspaceCount = 0;
|
|
_lowLevelBackspaceCount = 0;
|
|
_queuedText = nil;
|
|
|
|
os_log_debug([KMLogs lifecycleLog], "KMInputMethodEventHandler initWithClient [client: %p] [clientAppId: %{public}@]", sender, clientAppId);
|
|
[KMSentryHelper addInfoBreadCrumb:@"lifecycle" message:[NSString stringWithFormat:@"KMInputMethodEventHandler initWithClient, clientAppId '%@'", clientAppId]];
|
|
|
|
_apiCompliance = [[TextApiCompliance alloc]initWithClient:sender applicationId:clientAppId];
|
|
|
|
[KMSentryHelper addClientAppIdTag: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;
|
|
|
|
if ([self.appDelegate canForceSentryEvents]) {
|
|
if ([self forceSentryEvent:event]) {
|
|
return NO;
|
|
}
|
|
}
|
|
|
|
output = [self processEventWithKeymanEngine:event in:sender];
|
|
os_log_debug([KMLogs keyTraceLog], "keyman engine output: %{public}@", output);
|
|
|
|
if (output == nil) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* When in the mode to test sentry, this method will force certain Sentry APIs to be called,
|
|
* such as forcing a crash or capturing a message, depending on the key being typed.
|
|
*/
|
|
- (BOOL)forceSentryEvent:(NSEvent *)event {
|
|
BOOL forcedEvent = YES;
|
|
|
|
NSString *sentryCommand = event.characters;
|
|
if ([sentryCommand isEqualToString:@"{"]) {
|
|
os_log_debug([KMLogs testLog], "forceSentryEvent: forcing crash now");
|
|
NSBeep();
|
|
[SentrySDK crash];
|
|
} else if ([sentryCommand isEqualToString:@"["]) {
|
|
os_log_debug([KMLogs testLog], "forceSentryEvent: forcing message now");
|
|
NSBeep();
|
|
[SentrySDK captureMessage:@"Forced test message"];
|
|
} else {
|
|
os_log_debug([KMLogs testLog], "forceSentryEvent: unrecognized command");
|
|
forcedEvent = NO;
|
|
}
|
|
|
|
return forcedEvent;
|
|
}
|
|
|
|
/**
|
|
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 keyTraceLog], "KMInputMethodEventHandler handleBackspace, event = %{public}@", event);
|
|
[KMSentryHelper addInfoBreadCrumb:@"user" message:@"handle backspace for non-compliant app"];
|
|
|
|
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 stale, create a new one for the current application and context.
|
|
// The text api compliance may vary from one text field to another of the same app.
|
|
|
|
if ([self textApiComplianceIsStale:client]) {
|
|
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];
|
|
}
|
|
}
|
|
|
|
- (BOOL)textApiComplianceIsStale:(id)client {
|
|
BOOL stale = false;
|
|
NSString *complianceAppId = nil;
|
|
TextApiCompliance *currentApiCompliance = self.apiCompliance;
|
|
|
|
// test for three scenarios in which the api compliance is stale
|
|
if (currentApiCompliance == nil) { // if we have no previous api compliance object
|
|
os_log_debug([KMLogs complianceLog], "*** stale: currentApiCompliance == nil");
|
|
stale = true;
|
|
} else { // if we have one but for a different client
|
|
stale = ![currentApiCompliance isMatchingClient:client applicationId:self.clientApplicationId];
|
|
if (stale){
|
|
os_log_debug([KMLogs complianceLog], "*** stale: due to failed isMatchingClient");
|
|
}
|
|
}
|
|
if (self.contextChanged) { // if the context has changed
|
|
if(!stale) {
|
|
os_log_debug([KMLogs complianceLog], "*** stale: due to contextChanged");
|
|
}
|
|
stale = true;
|
|
}
|
|
|
|
NSString *message = [NSString stringWithFormat:@"textApiComplianceIsStale = %@ for appId: %@, apiCompliance: %p, complianceAppId: %@", stale?@"true":@"false", self.clientApplicationId, currentApiCompliance, complianceAppId];
|
|
[KMSentryHelper addDebugBreadCrumb:@"compliance" message:message];
|
|
os_log_debug([KMLogs complianceLog], "%{public}@", message);
|
|
|
|
return stale;
|
|
}
|
|
|
|
/**
|
|
* Handle the flag set in the eventTap when we detect events that cause a change in context.
|
|
* This includes mouse movement, arrow keys, page up and page down, function keys, and others.
|
|
*/
|
|
- (void) handleContextChangedByLowLevelEvent {
|
|
if (self.appDelegate.contextChangedByLowLevelEvent) {
|
|
if (!self.contextChanged) {
|
|
os_log_debug([KMLogs complianceLog], "Handling context change due to low-level event.");
|
|
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 keyTraceLog], "eventHandler handleEvent፡ event = %{public}@", event);
|
|
|
|
[self handleContextChangedByLowLevelEvent];
|
|
|
|
[self checkTextApiCompliance:sender];
|
|
|
|
if (event.type == NSEventTypeKeyDown) {
|
|
os_log_debug([KMLogs keyTraceLog], "eventHandler, event.type is NSEventTypeKeyDown");
|
|
[KMSentryHelper addDebugBreadCrumb:@"event" message:@"handling keydown event"];
|
|
// 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 keyTraceLog], "eventHandler, event is 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 keyTraceLog], "eventHandler, generated backspace passing through");
|
|
self.lowLevelBackspaceCount--;
|
|
|
|
// return NO to pass through to client app
|
|
return NO;
|
|
}
|
|
}
|
|
|
|
if (event.type == NSEventTypeFlagsChanged) {
|
|
os_log_debug([KMLogs keyTraceLog], "eventHandler, flags changed passing through");
|
|
[self handleFlagsChangedEvent:[event keyCode]];
|
|
return NO;
|
|
}
|
|
|
|
if ((event.modifierFlags & NSEventModifierFlagCommand) == NSEventModifierFlagCommand) {
|
|
os_log_debug([KMLogs keyTraceLog], "eventHandler, modifier flag passing through");
|
|
[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 keyTraceLog], "eventHandler complete, 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 keyTraceLog], "reportContext, setting new context for compliant app (if needed)");
|
|
[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 keyTraceLog], "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];
|
|
os_log_debug([KMLogs eventsLog], "InputMethodEventHandler readContext, canReadText: true selectionRange %{public}@: ", NSStringFromRange(selectionRange));
|
|
|
|
NSUInteger contextLength = MIN(kMaxContext, selectionRange.location);
|
|
NSUInteger contextStart = selectionRange.location - contextLength;
|
|
|
|
if (contextLength > 0) {
|
|
NSRange contextRange = NSMakeRange(contextStart, contextLength);
|
|
os_log_debug([KMLogs eventsLog], " contextRange %{public}@: client: %p", NSStringFromRange(contextRange), client);
|
|
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], " first char is low surrogate, reducing context by one character");
|
|
contextString = [attributedString.string substringFromIndex:1];
|
|
} else {
|
|
contextString = attributedString.string;
|
|
|
|
//only uncomment for testing as we do not want to write context in logs
|
|
//os_log_debug([KMLogs keyTraceLog], " length: %lu result: %{public}@", contextString.length, contextString);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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) {
|
|
NSString *message = @"applyOutputToTextInputClient, insert only scenario";
|
|
os_log_debug([KMLogs keyTraceLog], "%@", message);
|
|
[KMSentryHelper addDebugBreadCrumb:@"event" message:message];
|
|
[self insertAndReplaceTextForOutput:output client:client];
|
|
} else if (output.isDeleteOnlyScenario) {
|
|
handledEvent = [self handleDeleteOnlyScenario:output keyDownEvent:event client:client];
|
|
} 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) {
|
|
NSString *message = @"applyOutputToTextInputClient, delete and insert scenario with events";
|
|
os_log_debug([KMLogs keyTraceLog], "%@", message);
|
|
[KMSentryHelper addDebugBreadCrumb:@"event" message:message];
|
|
[self sendEvents:event forOutput:output];
|
|
} else {
|
|
NSString *message = @"applyOutputToTextInputClient, delete and insert scenario with insert API";
|
|
os_log_debug([KMLogs keyTraceLog], "%@", message);
|
|
[KMSentryHelper addDebugBreadCrumb:@"event" message:message];
|
|
// 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);
|
|
[KMSentryHelper addInfoBreadCrumb:@"event" message:@"persist options"];
|
|
[[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 {
|
|
NSRange selectionRange = [client selectedRange];
|
|
NSRange replacementRange = [self calculateInsertRangeForDeletedText:textToDelete selectionRange:selectionRange];
|
|
|
|
os_log_debug([KMLogs keyTraceLog], "insertAndReplaceText: '%{public}@', selectionRange: %{public}@, replacementRange: %{public}@", text, selectionRange.location==NSNotFound?@"NSNotFound":NSStringFromRange(selectionRange), replacementRange.location==NSNotFound?@"NSNotFound":NSStringFromRange(replacementRange));
|
|
[client insertText:text replacementRange:replacementRange];
|
|
|
|
if ((self.apiCompliance.isComplianceUncertain) && (text != nil)) {
|
|
[self.apiCompliance checkComplianceAfterInsert:text deleted:textToDelete];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles deleting without an associated insert in one of three methods:
|
|
* 1. delete via replace: do the delete by replacing two (or more) characters with one.
|
|
* 2. backspace passthrough : if the original keydown event was a backspace, pass it through unhandled
|
|
* 3. generate event: generate keydown backspace events as necessary
|
|
*/
|
|
-(BOOL)handleDeleteOnlyScenario:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client {
|
|
|
|
// attempt to delete by replacing -- for compliant apps only
|
|
if ([self handleDeleteWithReplacement:output keyDownEvent:event client:client]) {
|
|
return YES;
|
|
}
|
|
|
|
// pass through if this was a backspace keydown event
|
|
if (event.keyCode == kVK_Delete && output.codePointsToDeleteBeforeInsert == 1) {
|
|
// let the delete pass through in the original event rather than sending a new delete
|
|
NSString *message = @"handleDeleteOnlyForOutput, delete only scenario with passthrough";
|
|
os_log_debug([KMLogs keyTraceLog], "%@", message);
|
|
[KMSentryHelper addDebugBreadCrumb:@"event" message:message];
|
|
|
|
// instruct system to handle the event
|
|
return NO;
|
|
}
|
|
else {
|
|
// otherwise generate a backspace
|
|
NSString *message = @"handleDeleteOnlyForOutput, send backspace event";
|
|
os_log_debug([KMLogs keyTraceLog], "%@", message);
|
|
[KMSentryHelper addDebugBreadCrumb:@"event" message:message];
|
|
[self sendEvents:event forOutput:output];
|
|
|
|
return YES;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For compliant apps only.
|
|
*
|
|
* This an attempt to make sure that deletion removes only the expected codepoints.
|
|
* When handling a transform which only deletes a character, or when allowing a
|
|
* backspace to pass through, the OS or application may not use the same rules
|
|
* around deletion as Keyman -- especially when deleting clusters such as letter +
|
|
* combining diacritic (e.g. `U+0062 U+0301`), where some applications may delete
|
|
* both together as they represent a single 'grapheme cluster'.
|
|
*
|
|
* (Note, the question of whether it is appropriate for backspace to delete a
|
|
* cluster rather than a codepoint from an end-user perspective is not relevant
|
|
* here, because what is important is that we match the rules that the keyboard has
|
|
* provided, which means we need a method of deleting a precise number of
|
|
* codepoints. The keyboard author can and should include rules for cluster
|
|
* deletion that meet end-user expectations.)
|
|
*
|
|
* The `insertText` API takes two parameters: a string to insert, and a range to
|
|
* replace with that string. However, we cannot simply pass through a zero-length
|
|
* insertion string along with the range to delete, because the `insertText` API
|
|
* treats this as an invalid call and ignores it.
|
|
*
|
|
* Instead, we can delete the desired number of codepoints only by using the
|
|
* `insertText` API to replace e.g. two codepoints with one.
|
|
*
|
|
* This method only works for compliant apps because non-compliant apps do not
|
|
* support the `insertText` API.
|
|
*
|
|
* Ref: https://developer.apple.com/documentation/appkit/nstextinputclient/inserttext(_:replacementrange:)
|
|
*/
|
|
-(BOOL)handleDeleteWithReplacement:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client {
|
|
BOOL handledEvent = NO;
|
|
NSString *context = [self readContext:event forClient:client];
|
|
|
|
// guard: only for compliant apps with sufficient context
|
|
if (!(self.apiCompliance.canReplaceText) || ([context length] <= output.textToDelete.length)) {
|
|
os_log_debug([KMLogs keyTraceLog], "cannot replace text, non-compliant or insufficient context");
|
|
return NO; // return without deleting/replacing
|
|
}
|
|
|
|
// guard: the logic of this method depends on locating textToDelete in the context
|
|
if (![self stringToDeleteMatchesContextSuffix:output.textToDelete context:context]) {
|
|
os_log_debug([KMLogs keyTraceLog], "cannot replace text, textToDelete not found at end of context");
|
|
return NO; // return without deleting/replacing
|
|
}
|
|
|
|
NSUInteger deletionTargetLength = output.textToDelete.length;
|
|
NSUInteger deletionTargetLocation = context.length-deletionTargetLength;
|
|
NSUInteger precedingCharacterLocation = deletionTargetLocation - 1;
|
|
|
|
// if the preceding character is the trailing half of a surrogate pair
|
|
// then delete by replacing with the entire surrogate pair
|
|
if ([self precededBySurrogatePair:precedingCharacterLocation context:context]) {
|
|
handledEvent = [self deleteByReplacingWithPrecedingSurrogate:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client];
|
|
} else {
|
|
// otherwise replace with only the preceding character
|
|
handledEvent = [self deleteByReplacingWithPrecedingCharacter:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client];
|
|
}
|
|
|
|
return handledEvent;
|
|
}
|
|
|
|
/**
|
|
* Check whether the string to be deleted is found at the tail end of the current context.
|
|
*/
|
|
-(BOOL) stringToDeleteMatchesContextSuffix:(NSString*)textToDelete context:(NSString*) context {
|
|
BOOL doesMatch = NO;
|
|
|
|
// get length of string to delete and compare to end of context
|
|
NSUInteger deleteLength = textToDelete.length;
|
|
NSUInteger locationOfDeletionTarget = context.length-deleteLength;
|
|
NSUInteger locationOfPrecedingCharacter = locationOfDeletionTarget - 1;
|
|
NSString *contextSuffix = [context substringFromIndex:context.length-deleteLength];
|
|
|
|
os_log_debug([KMLogs keyTraceLog], "stringToDeleteMatchesSuffix, textToDelete: '%{public}@', contextSuffix: '%{public}@', locationOfDeletionTarget: %u, locationOfprecedingCharacter: %u", textToDelete, contextSuffix, (int)locationOfDeletionTarget, (int)locationOfPrecedingCharacter);
|
|
|
|
doesMatch = [textToDelete isEqualToString:contextSuffix];
|
|
os_log_debug([KMLogs keyTraceLog], "stringToDeleteMatchesSuffix: %{public}@", doesMatch?@"YES":@"NO");
|
|
return doesMatch;
|
|
}
|
|
|
|
/**
|
|
* Check whether the preceding character, which is to be used for the replacement,
|
|
* is part of a surrogate pair that is distinct from the character being deleted.
|
|
*/
|
|
-(BOOL) precededBySurrogatePair: (NSUInteger)precedingLocation context:(NSString*) context {
|
|
BOOL precedingCharacterIsLowSurrogate = false;
|
|
unichar precedingCharacter = [context characterAtIndex:precedingLocation];
|
|
|
|
if (CFStringIsSurrogateHighCharacter(precedingCharacter)) {
|
|
// preceding character is high character -- unexpected from Keyman Core
|
|
// write to log and return false
|
|
precedingCharacterIsLowSurrogate = false;
|
|
NSString *message = [NSString stringWithFormat:@"Unexpected high surrogate found for preceding character at %ld", (long)precedingCharacter];
|
|
os_log_debug([KMLogs keyTraceLog], "%{public}@", message);
|
|
[KMSentryHelper addDebugBreadCrumb:@"event" message:message];
|
|
} else if (CFStringIsSurrogateLowCharacter(precedingCharacter)) {
|
|
// preceding character is low surrogate
|
|
precedingCharacterIsLowSurrogate = true;
|
|
}
|
|
|
|
return precedingCharacterIsLowSurrogate;
|
|
}
|
|
|
|
/**
|
|
* Replace both the text to delete and the character preceding it solely with the character that precedes it.
|
|
* Returns YES if executing the replace/delete and NO otherwise.
|
|
*/
|
|
-(BOOL) deleteByReplacingWithPrecedingCharacter:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client {
|
|
|
|
os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingCharacter");
|
|
// get the preceding character
|
|
NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation, 1);
|
|
NSString *replacementString = [context substringWithRange:precedingCharacterRange];
|
|
|
|
// guard: if preceding character is a control character, return NO
|
|
if ([self containsControlCharacter:replacementString]) {
|
|
NSString *message = @"replacementString contains control characters, cannot delete with replace";
|
|
os_log_debug([KMLogs keyTraceLog], "%@", message);
|
|
[KMSentryHelper addDebugBreadCrumb:@"event" message:message];
|
|
return NO;
|
|
}
|
|
|
|
// perform the replacement
|
|
NSUInteger replacementLength = [replacementString length] + deleteLength;
|
|
NSRange replacementRange = NSMakeRange(precedingCharacterLocation, replacementLength);
|
|
os_log_debug([KMLogs keyTraceLog], "replacementRange: %{public}@", NSStringFromRange(replacementRange));
|
|
[client insertText:replacementString replacementRange:replacementRange];
|
|
|
|
return YES;
|
|
}
|
|
|
|
/**
|
|
* Replace both the text to delete and the surrogate pair preceding it with the surrogate pair preceding it.
|
|
* Returns YES if executing the replace/delete and NO otherwise.
|
|
*/
|
|
-(BOOL) deleteByReplacingWithPrecedingSurrogate:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client {
|
|
|
|
os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingSurrogate");
|
|
|
|
// guard: return NO if there is no character before the precedingCharacterLocation
|
|
if (precedingCharacterLocation <= 0) {
|
|
NSString *message = @"no characters exist before precedingCharacterLocation, so it cannot be a surrogate pair";
|
|
os_log_debug([KMLogs keyTraceLog], "%@", message);
|
|
return NO;
|
|
}
|
|
|
|
// get the preceding character
|
|
NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation - 1, 2);
|
|
NSString *replacementString = [context substringWithRange:precedingCharacterRange];
|
|
|
|
unichar highCharacter = [replacementString characterAtIndex:0];
|
|
unichar lowCharacter = [replacementString characterAtIndex:1];
|
|
|
|
// verify that preceding characters comprise a surrogate pair
|
|
if ((CFStringIsSurrogateHighCharacter(highCharacter)) && (CFStringIsSurrogateLowCharacter(lowCharacter))) {
|
|
// found preceding surrogate as expected
|
|
} else {
|
|
NSString *message = [NSString stringWithFormat:@"Preceding characters of string do not comprise a surrogate pair: 0x%02x, 0x%02x", (unsigned int)highCharacter, (unsigned int)lowCharacter];
|
|
os_log_debug([KMLogs keyTraceLog], "%@", message);
|
|
[KMSentryHelper addDebugBreadCrumb:@"event" message:message];
|
|
return NO;
|
|
}
|
|
|
|
// perform the replacement
|
|
NSUInteger replacementLength = [replacementString length] + deleteLength;
|
|
NSRange replacementRange = NSMakeRange(precedingCharacterRange.location, replacementLength);
|
|
os_log_debug([KMLogs keyTraceLog], "replacementRange: %{public}@", NSStringFromRange(replacementRange));
|
|
[client insertText:replacementString replacementRange:replacementRange];
|
|
|
|
return YES;
|
|
}
|
|
|
|
-(BOOL) containsControlCharacter:(NSString*)text {
|
|
NSCharacterSet *controlSet = [NSCharacterSet controlCharacterSet];
|
|
NSRange range = [text rangeOfCharacterFromSet:controlSet];
|
|
|
|
return (range.location != NSNotFound);
|
|
}
|
|
|
|
/**
|
|
* 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 keyTraceLog], "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 keyTraceLog], "sendEvents sending backspace key event");
|
|
[self.keySender sendBackspaceforEventSource:_sourceForGeneratedEvent];
|
|
}
|
|
}
|
|
|
|
if (output.hasTextToInsert) {
|
|
os_log_debug([KMLogs keyTraceLog], "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 keyTraceLog], "insertQueuedText, inserting %{public}@", self.queuedText);
|
|
[self insertAndReplaceText:self.queuedText deleteCount:0 toReplace:@"" client:client];
|
|
self.queuedText = nil;
|
|
} else {
|
|
os_log_debug([KMLogs keyLog], "insertQueuedText called but no text to insert");
|
|
}
|
|
}
|
|
|
|
@end
|