From 01b192796a49a134c95e413fba1a3f044a8c6364 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 25 Nov 2020 16:56:52 +1100 Subject: [PATCH] feat(mac): user-controllable legacy app list Fixes #1953. Makes it possible to specify additional apps that need 'legacy' handling without requiring an updated build of Keyman for Mac. The list should be stored in user defaults under the `KMLegacyApps` key. It can be viewed with the command: ``` defaults read keyman.inputmethod.Keyman KMLegacyApps ``` And updated with ``` defaults write keyman.inputmethod.Keyman KMLegacyApps -array value1 value2 ... defaults write keyman.inputmethod.Keyman KMLegacyApps -array-add value1 value2 ... ``` Each entry should be a regex that matches the client app id. For example: ``` defaults write keyman.inputmethod.Keyman KMLegacyApps -array com.microsoft.Word '^com.github.atom$' ``` (Note for purity, the `.` should be escaped in the regex but it'll actually be fine without...) This commit also adds the `keymanDataPath` method on `KMInputMethodAppDelegate`, so we can place additional configuration data there in the future (as opposed to in the shared config which is less easy for users to edit.) --- .../Keyman4MacIM/KMInputMethodAppDelegate.h | 2 + .../Keyman4MacIM/KMInputMethodAppDelegate.m | 39 ++++++++-- .../Keyman4MacIM/KMInputMethodEventHandler.m | 77 ++++++++++++++----- 3 files changed, 91 insertions(+), 27 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.h b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.h index 526b69b0bb..f2c9c0e64d 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.h @@ -125,6 +125,8 @@ typedef struct { - (NSString *)oskWindowTitle; - (void)postKeyboardEventWithSource: (CGEventSourceRef)source code:(CGKeyCode) virtualKey postCallback:(PostEventCallback)postEvent; - (KeymanVersionInfo)versionInfo; +- (NSString *)keymanDataPath; +- (NSArray *)legacyAppsUserDefaults; @end #endif /* KMInputMethodAppDelegate_h */ diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m index 4d42f721a2..b85dbd2a93 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m @@ -21,11 +21,14 @@ #import "ZipArchive.h" @import Sentry; +/** NSUserDefaults keys */ NSString *const kKMSelectedKeyboardKey = @"KMSelectedKeyboardKey"; NSString *const kKMActiveKeyboardsKey = @"KMActiveKeyboardsKey"; NSString *const kKMSavedStoresKey = @"KMSavedStoresKey"; NSString *const kKMAlwaysShowOSKKey = @"KMAlwaysShowOSKKey"; NSString *const kKMUseVerboseLogging = @"KMUseVerboseLogging"; +NSString *const kKMLegacyApps = @"KMLegacyApps"; + NSString *const kKeymanKeyboardDownloadCompletedNotification = @"kKeymanKeyboardDownloadCompletedNotification"; NSString *const kPackage = @"[Package]"; @@ -64,6 +67,7 @@ typedef enum { @synthesize alwaysShowOSK = _alwaysShowOSK; id _lastServerWithOSKShowing = nil; +NSString* _keymanDataPath = nil; - (id)init { self = [super init]; @@ -460,14 +464,37 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef return [userData boolForKey:kKMUseVerboseLogging]; } +/** + * Locate and create the Keyman data path; currently in ~/Documents/Keyman-Keyboards + */ +- (NSString *)keymanDataPath { + if(_keymanDataPath == nil) { + NSString *documentDirPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]; + _keymanDataPath = [documentDirPath stringByAppendingPathComponent:@"Keyman-Keyboards"]; + + NSFileManager *fm = [NSFileManager defaultManager]; + if (![fm fileExistsAtPath:_keymanDataPath]) { + [fm createDirectoryAtPath:_keymanDataPath withIntermediateDirectories:YES attributes:nil error:nil]; + } + } + return _keymanDataPath; +} + +/** + * Returns the list of user-default legacy apps + */ +- (NSArray *)legacyAppsUserDefaults { + NSUserDefaults *userData = [NSUserDefaults standardUserDefaults]; + return [userData arrayForKey:kKMLegacyApps]; +} + +/** + * Returns the root folder where keyboards are stored; currently the same + * as the keymanDataPath, but may diverge in future versions (possibly a sub-folder) + */ - (NSString *)keyboardsPath { if (_keyboardsPath == nil) { - NSString *documentDirPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]; - _keyboardsPath = [documentDirPath stringByAppendingPathComponent:@"Keyman-Keyboards"]; - NSFileManager *fm = [NSFileManager defaultManager]; - if (![fm fileExistsAtPath:_keyboardsPath]) { - [fm createDirectoryAtPath:_keyboardsPath withIntermediateDirectories:YES attributes:nil error:nil]; - } + _keyboardsPath = [self keymanDataPath]; } return _keyboardsPath; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 7c51eb8599..cac2e576fe 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -42,22 +42,33 @@ NSRange _previousSelRange; return self; } -// This is the public initializer. -- (instancetype)initWithClient:(NSString *)clientAppId client:(id) sender { - self.senderForDeleteBack = sender; - // TODO: Pages and Keynote (and possibly lots of other undiscovered apps that are otherwise compliant - // with Apple's IM framework) have a problem in that if the user selects a different font (or other - // formatting) and then types a sequence that causes characters to be added to the document and then - // subsequently replaced, the replacement causes the formatting decision to be forgotten. This can be - // "fixed" by treating them as legacy apps, but it causes other problems. - BOOL legacy = ([clientAppId isEqual: @"com.github.atom"] || +/** + * Checks if the client app requires legacy input mode, first by checking the user defaults, if they exist, + * then, by our hard-coded list. + */ +- (BOOL)isClientAppLegacy:(NSString *)clientAppId { + NSArray *legacyAppsUserDefaults = [self.AppDelegate legacyAppsUserDefaults]; + + BOOL result = NO; + + if(legacyAppsUserDefaults != nil) { + result = [self isClientAppLegacy:clientAppId fromArray:legacyAppsUserDefaults]; + } + + if(!result) { + // TODO: Pages and Keynote (and possibly lots of other undiscovered apps that are otherwise compliant + // with Apple's IM framework) have a problem in that if the user selects a different font (or other + // formatting) and then types a sequence that causes characters to be added to the document and then + // subsequently replaced, the replacement causes the formatting decision to be forgotten. This can be + // "fixed" by treating them as legacy apps, but it causes other problems. + result = ([clientAppId isEqual: @"com.github.atom"] || [clientAppId isEqual: @"com.collabora.libreoffice-free"] || [clientAppId isEqual: @"org.libreoffice.script"] || [clientAppId isEqual: @"com.axosoft.gitkraken"] || [clientAppId isEqual: @"org.sil.app.builder.scripture.ScriptureAppBuilder"] || [clientAppId isEqual: @"org.sil.app.builder.reading.ReadingAppBuilder"] || [clientAppId isEqual: @"org.sil.app.builder.dictionary.DictionaryAppBuilder"] || - [clientAppId isEqual: @"com.microsoft.Word"] || + //[clientAppId isEqual: @"com.microsoft.Word"] || // 2020-11-24[mcd]: Appears to work well in Word 16.43, disable legacy by default [clientAppId isEqual: @"org.openoffice.script"] || [clientAppId isEqual: @"com.adobe.illustrator"] || [clientAppId isEqual: @"com.adobe.InDesign"] || @@ -67,19 +78,43 @@ NSRange _previousSelRange; [clientAppId isEqual: @"com.google.Chrome"] || [clientAppId hasPrefix: @"net.java"] || [clientAppId isEqual: @"com.Keyman.test.legacyInput"] - /*||[clientAppId isEqual: @"ro.sync.exml.Oxygen"] - Oxygen has worse problems */); + /*||[clientAppId isEqual: @"ro.sync.exml.Oxygen"] - Oxygen has worse problems */ + ); + } - // We used to default to NO, so these were the obvious exceptions. But then we realized that - // in any app, command keys can change the selection, so now we default to YES, and only have - // a few situations where we pretend it can't. This flag should probably be renamed to something - // like "disregardPossibleSelectionChanges". - // if ([clientAppId isEqual: @"com.google.Chrome"] || - // [clientAppId isEqual: @"com.apple.Terminal"] || - // [clientAppId isEqual: @"com.apple.dt.Xcode"]) { - // _clientSelectionCanChangeUnexpectedly = YES; - // } + return result; +} - // In Xcode, if Keyman is the active IM and is in "debugMode" 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 +/** + * Checks user defaults array for a list of possible regexes to match a client app id + */ +- (BOOL)isClientAppLegacy:(NSString *)clientAppId fromArray:(NSArray *)legacyApps { + for(id legacyApp in legacyApps) { + if(![legacyApp isKindOfClass:[NSString class]]) { + NSLog(@"isClientAppLegacy:fromArray: LegacyApps user defaults array should contain only strings"); + } else { + NSError *error = nil; + NSRange range = NSMakeRange(0, clientAppId.length); + + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern: (NSString *) legacyApp options: 0 error: &error]; + if([regex matchesInString:clientAppId options:0 range:range]) { + return YES; + } + } + } + + return NO; +} + +// This is the public initializer. +- (instancetype)initWithClient:(NSString *)clientAppId client:(id) sender { + self.senderForDeleteBack = sender; + + BOOL legacy = [self isClientAppLegacy:clientAppId]; + + // In Xcode, if Keyman is the active IM and is in "debugMode" 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 ([self.AppDelegate debugMode] && [clientAppId isEqual: @"com.apple.dt.Xcode"]) { NSLog(@"Sentry - Preparing to detect Easter egg."); _easterEggForSentry = [[NSMutableString alloc] init];