// // KMInputMethodAppDelegate.m // Keyman4MacIM // // Created by Serkan Kurt on 12/02/2015. // Copyright (c) 2017 SIL International. All rights reserved. // #import "KMInputMethodAppDelegate.h" #import "KMInputMethodLifecycle.h" #import "KMSettingsRepository.h" #import "KMDataRepository.h" #import "ZipArchive.h" #import "KMPackageReader.h" #import "KMPackageInfo.h" #import "PrivacyConsent.h" #import "KMLogs.h" #import "KMSentryHelper.h" @import Sentry; #if TARGET_CPU_ARM64 NSString *processorType = @"ARM"; #elif TARGET_CPU_X86_64 NSString *processorType = @"Intel"; #else NSString *processorType = @"Unknown"; #endif // distributed notifications NSString *const kKeyboardsChanged = @"com.keyman.keyboards.changed"; NSString *const kAccessibilityCheckedRequest = @"com.keyman.accessibility.check.request"; NSString *const kAccessibilityRequest = @"com.keyman.accessibility.request"; @implementation NSString (VersionNumbers) /** * Returns a minimal version number by removing all '.0' from end of string, * e.g. '9.0.0' -> '9', '9.1.0' -> '9.1'. * Use to prepare version numbers for comparison with a `NSNumericSearch`, for example: * ``` * NSString* version1 = [fileVersion minimalVersionNumberString]; * NSString* version2 = [programVersion minimalVersionNumberString]; * [version1 compare:version2 options:NSNumericSearch] * ``` */ - (NSString *)minimalVersionNumberString { static NSString *const unnecessaryVersionSuffix = @".0"; NSString *minimalVersionNumber = self; while ([minimalVersionNumber hasSuffix:unnecessaryVersionSuffix]) { minimalVersionNumber = [minimalVersionNumber substringToIndex:minimalVersionNumber.length - unnecessaryVersionSuffix.length]; } return minimalVersionNumber; } @end @interface KMInputMethodAppDelegate () @property (nonatomic, strong) KMPackageReader *packageReader; @property BOOL receivedKeyDownFromOsk; @property NSEventModifierFlags oskEventModifiers; @property (nonatomic, assign) BOOL sentryTestingEnabled; @end @implementation KMInputMethodAppDelegate @synthesize kme = _kme; @synthesize kmx = _kmx; @synthesize modifierMapping = _modifierMapping; @synthesize kvk = _kvk; @synthesize keyboardName = _keyboardName; @synthesize keyboardsPath = _keyboardsPath; //@synthesize kmxFileList = _kmxFileList; @synthesize selectedKeyboard = _selectedKeyboard; @synthesize enabledKeyboards = _enabledKeyboards; @synthesize contextBuffer = _contextBuffer; id _lastServerWithOSKShowing = nil; - (id)init { self = [super init]; 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){ [self initCompletion]; }]; } return self; } - (void)initCompletion { os_log_info([KMLogs startupLog], "initCompletionHandler method invoked"); self.lowLevelEventTap = CGEventTapCreate(kCGAnnotatedSessionEventTap, kCGHeadInsertEventTap, kCGEventTapOptionListenOnly, CGEventMaskBit(kCGEventFlagsChanged) | CGEventMaskBit(kCGEventLeftMouseDown) | CGEventMaskBit(kCGEventLeftMouseUp) | CGEventMaskBit(kCGEventKeyDown), (CGEventTapCallBack)eventTapFunction, nil); if (!self.lowLevelEventTap) { os_log_error([KMLogs startupLog], "Unable to create lowLevelEventTap!"); } else { os_log([KMLogs startupLog], "Successfully created lowLevelEventTap with CGEventTapCreate."); CFRelease(self.lowLevelEventTap); } self.runLoopEventSrc = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, self.lowLevelEventTap, 0); CFRunLoopRef runLoop = CFRunLoopGetCurrent(); 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]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inputMethodChangedClient:) name:kInputMethodClientChangeNotification object:nil]; // register to receive notifications generated from Keyman Configuration App [[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(handleKeyboardsChanged:) name:kKeyboardsChanged object:nil]; [[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 */ - (void)handleKeyboardsChanged:(NSNotification *)notification { os_log_debug([KMLogs configLog], "***KMInputMethodAppDelegate handleKeyboardsChanged"); [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 */ - (void)inputMethodActivated:(NSNotification *)notification { // enable event tap if (self.lowLevelEventTap && !CGEventTapIsEnabled(self.lowLevelEventTap)) { os_log_debug([KMLogs lifecycleLog], "***KMInputMethodAppDelegate inputMethodActivated, re-enabling event tap..."); CGEventTapEnable(self.lowLevelEventTap, YES); } // show OSK if necessary os_log_debug([KMLogs lifecycleLog], "--- inputMethodActivated, kvk is non-nil: %{public}@ showOskOnActivate: %{public}@", (_kvk!=nil)?@"true":@"false", [KMInputMethodLifecycle.shared shouldShowOskOnActivate]?@"true":@"false"); if ([KMInputMethodLifecycle.shared shouldShowOskOnActivate]) { os_log_debug([KMLogs oskLog], "***KMInputMethodAppDelegate inputMethodActivated, showing OSK"); [KMSentryHelper addDebugBreadCrumb:@"lifecycle" message:@"opening OSK on input method activation"]; [self showOSK]; } } /** * When the input method is deactivated -- notification from KMInputMethodLifecycle */ - (void)inputMethodDeactivated:(NSNotification *)notification { // if the OSK is visible, hide it if ([self.oskWindow.window isVisible]) { os_log_debug([KMLogs oskLog], "***KMInputMethodAppDelegate inputMethodDeactivated, hiding OSK"); [KMSentryHelper addInfoBreadCrumb:@"lifecycle" message:@"hiding OSK on input method deactivation"]; [self.oskWindow.window setIsVisible:NO]; } else { os_log_debug([KMLogs oskLog], "***KMInputMethodAppDelegate inputMethodDeactivated, OSK already hidden"); } [KMSentryHelper addOskVisibleTag:[self.oskWindow.window isVisible]]; // disable the event tap if (self.lowLevelEventTap) { os_log_debug([KMLogs lifecycleLog], "***inputMethodDeactivated, disabling event tap"); CGEventTapEnable(self.lowLevelEventTap, NO); } // deactive the text input client [self.inputController deactivateClient]; } /** * When the user switches to a different text input client -- notification from KMInputMethodLifecycle */ - (void)inputMethodChangedClient:(NSNotification *)notification { os_log_debug([KMLogs lifecycleLog], "***KMInputMethodAppDelegate inputMethodChangedClient"); [self.inputController changeClients:[KMInputMethodLifecycle getRunningApplicationId]]; } - (KeymanVersionInfo)versionInfo { KeymanVersionInfo result; // Get version information from Info.plist, which is filled in // by build.sh. NSDictionary *keymanInfo =[[[NSBundle mainBundle] infoDictionary] objectForKey:@"Keyman"]; result.sentryEnvironment = [keymanInfo objectForKey:@"SentryEnvironment"]; result.tier = [keymanInfo objectForKey:@"Tier"]; result.versionRelease = [keymanInfo objectForKey:@"VersionRelease"]; result.versionWithTag = [keymanInfo objectForKey:@"VersionWithTag"]; result.versionGitTag = [keymanInfo objectForKey:@"VersionGitTag"]; // if([result.tier isEqualToString:@"stable"]) { // #7227 disabling: result.keymanCom = @"keyman.com"; result.helpKeymanCom = @"help.keyman.com"; result.apiKeymanCom = @"api.keyman.com"; /*} else { result.keymanCom = @"keyman-staging.com"; result.helpKeymanCom = @"help.keyman-staging.com"; result.apiKeymanCom = @"api.keyman-staging.com"; }*/ return result; } -(void)applicationDidFinishLaunching:(NSNotification *)aNotification { [[NSUserDefaults standardUserDefaults] registerDefaults:@{ @"NSApplicationCrashOnExceptions": @YES }]; [KMLogs reportLogStatus]; [self startSentry]; [self setDefaultKeymanMenuItems]; [self updateKeyboardMenuItems]; [self setPostLaunchKeymanSentryTags]; self.sentryTestingEnabled = [KMSettingsRepository.shared readForceSentryError]; } - (void)startSentry { KeymanVersionInfo keymanVersionInfo = [self versionInfo]; NSString *releaseName = [NSString stringWithFormat:@"%@", keymanVersionInfo.versionGitTag]; [SentrySDK startWithConfigureOptions:^(SentryOptions *options) { options.dsn = @"https://960f8b8e574c46e3be385d60ce8e1fea@o1005580.ingest.sentry.io/5983522"; options.releaseName = releaseName; options.environment = keymanVersionInfo.sentryEnvironment; }]; } /** * Determine whether to force sentry events. * When true, then typing certain characters will cause Sentry events or crashes to occur. * * To enable, set the flag in UserDefaults * `defaults write keyman.inputmethod.Keyman KMForceSentryError 1` * Also, use the SIL Euro Latin keyboard and type in the Stickies app */ - (BOOL)canForceSentryEvents { NSString* const testKeyboardName = @"sil_euro_latin.kmx"; NSString* const testClientApplicationId = @"com.apple.Stickies"; BOOL canForce = NO; if (self.sentryTestingEnabled) { NSString * applicationId = [KMInputMethodLifecycle.shared clientApplicationId]; NSString * kmxFileName = [self getKmxFileName]; canForce = [kmxFileName isEqualToString:testKeyboardName] && [applicationId isEqualToString:testClientApplicationId]; os_log_info([KMLogs testLog], "canForceSentryEvents, self.sentryTestingEnabled: %{public}@ kmxName: %{public}@ applicationId: %{public}@ testingEnabled: %{public}@", self.sentryTestingEnabled?@"YES":@"NO", kmxFileName, applicationId, canForce?@"YES":@"NO"); } return canForce; } - (NSString*) getKmxFileName { return [[self.kmx filePath] lastPathComponent]; } - (void)setPostLaunchKeymanSentryTags { NSString *keyboardFileName = [self.kmx.filePath lastPathComponent]; os_log_info([KMLogs keyboardLog], "initial kmx set to %{public}@", keyboardFileName); // assign custom keyboard tags in Sentry [KMSentryHelper addKeyboardTag:keyboardFileName]; [KMSentryHelper addHasAccessibilityTag:[PrivacyConsent.shared checkAccessibility]]; [KMSentryHelper addOskVisibleTag:[self.oskWindow.window isVisible]]; [KMSentryHelper addArchitectureTag:processorType]; } + (KMInputMethodAppDelegate *)AppDelegate { return (KMInputMethodAppDelegate *)[NSApp delegate]; } CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) { KMInputMethodAppDelegate *appDelegate = [KMInputMethodAppDelegate AppDelegate]; if (appDelegate != nil) { if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) { // kCGEventTapDisabledByUserInput most likely means we're "sleeping", in which case we want it to stay disabled until we get the wake-up call. if ([KMInputMethodLifecycle.shared shouldEnableEventTap]) { // REVIEW: We might need to consider putting in some kind of counter/flag to ensure that the very next // event is not another disable so we don't end up in an endless cycle. os_log([KMLogs eventsLog], "Event tap disabled by %{public}@! Attempting to restart...", (type == kCGEventTapDisabledByTimeout ? @"timeout" : @"user")); CGEventTapEnable(appDelegate.lowLevelEventTap, YES); if (!CGEventTapIsEnabled(appDelegate.lowLevelEventTap)) { if (appDelegate.runLoopEventSrc) { // This should always be true CFRunLoopRef runLoop = CFRunLoopGetCurrent(); if (runLoop) { CFRunLoopRemoveSource(runLoop, appDelegate.runLoopEventSrc, kCFRunLoopDefaultMode); } appDelegate.runLoopEventSrc = nil; } appDelegate.lowLevelEventTap = nil; } } return event; } NSEvent* sysEvent = [NSEvent eventWithCGEvent:event]; // Too many of these to be useful for most debugging sessions, but we'll keep this around to be // un-commented when needed. os_log_debug([KMLogs keyTraceLog], "System Event: %{public}@", sysEvent); switch (type) { case kCGEventFlagsChanged: os_log_debug([KMLogs eventsLog], "eventTapFunction: system event kCGEventFlagsChanged to: 0x%X", (int) sysEvent.modifierFlags); appDelegate.currentModifiers = sysEvent.modifierFlags; if (appDelegate.currentModifiers & NSEventModifierFlagCommand) { appDelegate.contextChangedByLowLevelEvent = YES; } break; case kCGEventLeftMouseUp: case kCGEventLeftMouseDown: case kCGEventOtherMouseUp: case kCGEventOtherMouseDown: os_log_debug([KMLogs eventsLog], "Event tap context invalidation flagged due to event of type: %u", type); appDelegate.contextChangedByLowLevelEvent = YES; break; case kCGEventKeyDown: appDelegate.receivedKeyDownFromOsk = NO; os_log_debug([KMLogs keyTraceLog], "** KEY DOWN Event **"); os_log_debug([KMLogs keyTraceLog], "** event tap keydown event, keyCode: 0x%X (%d)", sysEvent.keyCode, sysEvent.keyCode); // Pass back low-level backspace events to the input method event handler // because some non-compliant apps do not allow us to see backspace events // that we have generated (and we need to see them, for serialization // of events) if(sysEvent.keyCode == kVK_Delete && appDelegate.inputController != nil) { os_log_debug([KMLogs keyTraceLog], "** event tap handling kVK_Delete"); [appDelegate.inputController handleBackspace:sysEvent]; } else if(sysEvent.keyCode == kVK_Delete) { os_log_debug([KMLogs eventsLog], "** event tap cannot handle kVK_Delete, appDelegate.inputController = %{public}@", appDelegate.inputController); } if(sysEvent.keyCode == 255) { os_log_debug([KMLogs eventsLog], "** event tap received kKeymanEventKeyCode = 0xFF"); } else { if ([OSKView isOskKeyDownEvent:event]) { [KMSentryHelper addInfoBreadCrumb:@"event" message:@"processing OSK-generated keydown event"]; NSEventModifierFlags oskEventModifiers = [OSKView extractModifierFlagsFromOskEvent:event]; appDelegate.receivedKeyDownFromOsk = YES; appDelegate.oskEventModifiers = oskEventModifiers; os_log_debug([KMLogs eventsLog], "*** event tap received event from OSK, modifiers: 0x%lX",(unsigned long)oskEventModifiers); } } switch(sysEvent.keyCode) { case kVK_Home: case kVK_PageUp: case kVK_End: case kVK_PageDown: // While these four may not actually move the cursor, this is application-dependent, // so we'll treat them all as a context change. This means we lose deadkeys if these // are pressed, but that's actually probably a good thing anyway! case kVK_LeftArrow: case kVK_RightArrow: case kVK_DownArrow: case kVK_UpArrow: // Cursor movement means that we need to recheck the context case kVK_Escape: // Escape key should be treated as a context reset case kVK_F1: case kVK_F2: case kVK_F3: case kVK_F4: case kVK_F5: case kVK_F6: case kVK_F7: case kVK_F8: case kVK_F9: case kVK_F10: case kVK_F11: case kVK_F12: case kVK_F13: case kVK_F14: case kVK_F15: case kVK_F16: case kVK_F17: case kVK_F18: case kVK_F19: case kVK_F20: // Function keys are command keys, so context is invalid case kVK_Tab: // A tab between cells in a Word document, for instance, is a context change case kVK_ForwardDelete: // Text modification by the forward delete key should reset context, but // note that normal Delete (aka Bksp) is already managed by Keyman correctly. appDelegate.contextChangedByLowLevelEvent = YES; break; } break; default: break; } } return event; } - (NSMenu *)menu { return _menu; } - (KMEngine *)kme { if (_kme == nil) { _kme = [[KMEngine alloc] initWithKMX:nil context:self.contextBuffer]; } return _kme; } - (KMPackageReader *)packageReader { if (_packageReader == nil) { _packageReader = [[KMPackageReader alloc] init]; } return _packageReader; } - (void)resetKmx { _kmx = nil; _modifierMapping = nil; } - (void)loadKeyboardFromKmxFile:(KMXFile *)kmx { _kmx = kmx; CoreKeyboardInfo *keyboardInfo = [self.kme loadKeyboardFromKmxFile:kmx]; os_log_info([KMLogs keyboardLog], "loadKeyboardFromKmxFile, keyboard info: %{public}@", keyboardInfo); [KMSentryHelper addInfoBreadCrumb:@"configure" message:[NSString stringWithFormat:@"loadKeyboardFromKmxFile: %@", [self getKmxFileName]]]; [KMSentryHelper addKeyboardTag:[self getKmxFileName]]; _modifierMapping = [[KMModifierMapping alloc] init:keyboardInfo]; os_log_info([KMLogs keyboardLog], "modifierMapping bothOptionKeysGenerateRightAlt: %d", self.modifierMapping.bothOptionKeysGenerateRightAlt); } - (void)setKvk:(KVKFile *)kvk { if (kvk != nil) { os_log_info([KMLogs lifecycleLog], "*** setKvk, kvk.associatedKeyboard: %{public}@", kvk.associatedKeyboard); } else { os_log_info([KMLogs lifecycleLog], "*** setKvk, setting to nil"); } _kvk = kvk; if (_oskWindow != nil) { [_oskWindow resetOSK]; } } - (void)setKeyboardName:(NSString *)keyboardName { _keyboardName = keyboardName; if (_oskWindow != nil) [_oskWindow.window setTitle:self.oskWindowTitle]; } - (void)applyPersistedOptions { NSDictionary *selectedPersistedOptions = [[KMSettingsRepository shared] readOptionsForSelectedKeyboard]; // TODO: pass array instead of making repeated calls for (NSString *key in selectedPersistedOptions) { NSString *value = [selectedPersistedOptions objectForKey:key]; os_log_info([KMLogs configLog], "persisted options found in UserDefaults for keyboard %{public}@, key: %{public}@, value: %{public}@", _selectedKeyboard, key, value); [self.kme setCoreOptions:key withValue:value]; } } - (NSString *)oskWindowTitle { if (_keyboardName == nil || !_keyboardName.length) return [NSString stringWithFormat:@"Keyman"]; else return [NSString stringWithFormat:@"%@ - Keyman", _keyboardName]; } #pragma mark - Keyman Data /** * Returns the root folder where keyboards are stored */ - (NSString *)keyboardsPath { if (_keyboardsPath == nil) { _keyboardsPath = [KMDataRepository shared].keyman19KeyboardsDirectory.path; } return _keyboardsPath; } - (NSString *)packageFolderFromPath:(NSString *)path { NSString *packageFolder = nil; NSString *sourcePath = [self keyboardsPath]; NSString *mPath = [NSString stringWithString:[path stringByDeletingLastPathComponent]]; if ([mPath isEqualToString:sourcePath]) return @"Others"; while (![mPath isEqualToString:sourcePath]) { packageFolder = [mPath lastPathComponent]; mPath = [mPath stringByDeletingLastPathComponent]; } return packageFolder; } - (KMPackageInfo *)loadPackageInfo:(NSString *)path { return [self.packageReader loadPackageInfo:path]; } - (NSArray *)keyboardNamesFromFolder:(NSString *)packageFolder { os_log_debug([KMLogs dataLog], "keyboardNamesFromFolder, folder = %{public}@", packageFolder); NSMutableArray *kbNames = [[NSMutableArray alloc] initWithCapacity:0];; for (NSString *kmxFile in [self getKmxFilesAtPath:packageFolder]) { NSDictionary * infoDict = [KMXFile keyboardInfoFromKmxFile:kmxFile]; if (infoDict != nil) { NSString *name = [infoDict objectForKey:kKMKeyboardNameKey]; if (name != nil && [name length]) { os_log_debug([KMLogs configLog], "Adding keyboard name: %{public}@", name); [kbNames addObject:name]; } } } return kbNames; } - (NSString *)selectedKeyboard { if (_selectedKeyboard == nil) { _selectedKeyboard = [[KMSettingsRepository shared] readSelectedKeyboard]; } os_log_debug([KMLogs dataLog], "selectedKeyboard = %{public}@", _selectedKeyboard); return _selectedKeyboard; } - (void)setSelectedKeyboard:(NSString *)selectedKeyboard { [[KMSettingsRepository shared] writeSelectedKeyboard:selectedKeyboard]; _selectedKeyboard = selectedKeyboard; } - (NSMutableArray *)enabledKeyboards { if (!_enabledKeyboards) { os_log_debug([KMLogs dataLog], "initializing enabledKeyboards"); _enabledKeyboards = [[KMSettingsRepository.shared readEnabledKeyboards] mutableCopy]; [KMSentryHelper addEnabledKeyboardCountTag:_enabledKeyboards.count]; } return _enabledKeyboards; } /** * Called when the enabled keyboards have been changed by the Keyman Config app. * Reload the enabled keyboards from the UserDefaults * Then update the Keyman menu */ - (void)reloadEnabledKeyboards { os_log_debug([KMLogs configLog], "reloadEnabledKeyboards"); // read the enabled keyboards from UserDefaults _enabledKeyboards = [[KMSettingsRepository.shared readEnabledKeyboards] mutableCopy]; // set Sentry tag for how many keyboards are enabled [KMSentryHelper addEnabledKeyboardCountTag:_enabledKeyboards.count]; // update the keyboard menu to reflect the updated list of enabled keyboards [self updateKeyboardMenuItems]; } - (void)saveEnabledKeyboards { os_log_debug([KMLogs dataLog], "saveEnabledKeyboards"); [KMSettingsRepository.shared writeEnabledKeyboards:_enabledKeyboards]; [self resetEnabledKeyboards]; [self updateKeyboardMenuItems]; [KMSentryHelper addEnabledKeyboardCountTag:self.enabledKeyboards.count]; } - (void)clearEnabledKeyboards { [KMSettingsRepository.shared clearEnabledKeyboards]; [self updateKeyboardMenuItems]; } - (void)addEnabledKeyboard:(NSString *) partialPath { if (![self.enabledKeyboards containsObject:partialPath]) { os_log_debug([KMLogs keyboardLog], "addActiveKeyboard, adding '%{public}@' to list of active keyboards: ", partialPath); [self.enabledKeyboards addObject:partialPath]; } } - (void)resetEnabledKeyboards { // Remove entries with missing files NSMutableArray *pathsToRemove = [[NSMutableArray alloc] initWithCapacity:0]; for (NSString *path in self.enabledKeyboards) { NSString *fullPath = [KMDataRepository.shared buildFullPath:path]; os_log_debug([KMLogs dataLog], "resetEnabledKeyboards, checking fullPath: '%{public}@' for path: '%{public}@'", fullPath, path); if (![[NSFileManager defaultManager] fileExistsAtPath:fullPath]) { os_log_debug([KMLogs dataLog], "resetEnabledKeyboards, need to remove non-existent path: %{public}@", fullPath); [pathsToRemove addObject:path]; } } BOOL found = FALSE; if (pathsToRemove.count > 0) { [self.enabledKeyboards removeObjectsInArray:pathsToRemove]; found = TRUE; } // Remove duplicate entries NSUInteger i = 0; while(i < [self.enabledKeyboards count]) { NSString *item = self.enabledKeyboards[i]; NSUInteger n = [self.enabledKeyboards indexOfObject:item inRange: NSMakeRange(i+1, [self.enabledKeyboards count]-i-1)]; if(n != NSNotFound) { [self.enabledKeyboards removeObjectAtIndex:n]; found = TRUE; } else { i++; } } if (found) { [KMSettingsRepository.shared writeEnabledKeyboards:_enabledKeyboards]; } } - (NSMutableString *)contextBuffer { if (_contextBuffer == nil) { _contextBuffer = [[NSMutableString alloc] initWithString:@""]; } return _contextBuffer; } - (void)clearContextBuffer { [self.kme clearCoreContext]; } - (void)awakeFromNib { [self prepareStorage]; } /** * Prepare the app environment for all the things that need to be persisted: * namely, the keyboard data on disk and the settings in UserDefaults */ - (void)prepareStorage { SettingsState state = [KMSettingsRepository.shared determineSettingsState]; switch (state) { case KeymanSettingsVersion17: os_log_info([KMLogs dataLog], "prepareStorage, migration needed for Keyman 17 to current"); [KMDataRepository.shared createSharedDirectoriesIfNecessary]; [KMDataRepository.shared migrateDataFromKeyman17]; [KMSettingsRepository.shared migrateSettingsFromKeyman17]; break; case KeymanSettingsVersion18: os_log_info([KMLogs dataLog], "prepareStorage, migration needed for Keyman 18 to current"); [KMDataRepository.shared createSharedDirectoriesIfNecessary]; [KMDataRepository.shared migrateDataFromKeyman18]; [KMSettingsRepository.shared migrateSettingsFromKeyman18]; break; case KeymanSettingsNotFound: os_log_info([KMLogs dataLog], "prepareStorage, settings not found, create them"); [KMDataRepository.shared createSharedDirectoriesIfNecessary]; [KMSettingsRepository.shared createSharedSettingsIfNecessary]; break; case KeymanSettingsVersionCurrent: os_log_info([KMLogs dataLog], "prepareStorage: settings are current, no migration needed"); break; } } - (void)setDefaultKeymanMenuItems { NSMenuItem *osk = [self.menu itemWithTag:OSK_MENUITEM_TAG]; if (osk) { [osk setAction:@selector(menuAction:)]; } NSMenuItem *about = [self.menu itemWithTag:ABOUT_MENUITEM_TAG]; if (about) { [about setAction:@selector(menuAction:)]; } } - (void)updateKeyboardMenuItems { self.numberOfKeyboardMenuItems = [self calculateNumberOfKeyboardMenuItems]; [self removeDynamicKeyboardMenuItems]; [self addDynamicKeyboardMenuItems]; } - (int)calculateNumberOfKeyboardMenuItems { os_log_debug([KMLogs uiLog], "calculateNumberOfKeyboardMenuItems, entered"); if (self.enabledKeyboards.count == 0) { // if there are no active keyboards, then we will insert one placeholder menu item 'No Active Keyboards' return 1; } else { return (int) self.enabledKeyboards.count; } } - (void)removeDynamicKeyboardMenuItems { int numberToRemove = (int) self.menu.numberOfItems - DEFAULT_KEYMAN_MENU_ITEM_COUNT; os_log_info([KMLogs keyboardLog], "*** removeDynamicKeyboardMenuItems, self.menu.numberOfItems = %ld, number of items to remove = %d", (long)self.menu.numberOfItems, numberToRemove); if (numberToRemove > 0) { for (int i = 0; i < numberToRemove; i++) { [self.menu removeItemAtIndex:KEYMAN_FIRST_KEYBOARD_MENUITEM_INDEX]; } } } - (void)addDynamicKeyboardMenuItems { os_log_debug([KMLogs startupLog], "addDynamicKeyboardMenuItems, entered"); BOOL didSetSelectedKeyboard = NO; NSInteger itag = KEYMAN_FIRST_KEYBOARD_MENUITEM_TAG; NSString *keyboardMenuName = @""; int menuItemIndex = KEYMAN_FIRST_KEYBOARD_MENUITEM_INDEX; os_log_debug([KMLogs configLog], "*** populateKeyboardMenuItems, number of active keyboards=%lu", self.enabledKeyboards.count); // loop through the active keyboards list and add them to the menu for (NSString *path in self.enabledKeyboards) { NSString *fullPath = [KMDataRepository.shared buildFullPath:path]; os_log_debug([KMLogs dataLog], "addDynamicKeyboardMenuItems, path = '%{public}@', full path = '%{public}@'", path, fullPath); NSDictionary *infoDict = [KMXFile keyboardInfoFromKmxFile:fullPath]; if (!infoDict) { continue; } keyboardMenuName = [infoDict objectForKey:kKMKeyboardNameKey]; NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:keyboardMenuName action:@selector(menuAction:) keyEquivalent:@""]; [item setTag:itag++]; // if this is the selected keyboard, then configure it as selected if ([path isEqualToString:self.selectedKeyboard]) { [self setSelectedKeyboard:path inMenuItem:item]; didSetSelectedKeyboard = YES; } else { [item setState:NSOffState]; } [self.menu insertItem:item atIndex:menuItemIndex++]; } if (self.enabledKeyboards.count == 0) { [self addKeyboardPlaceholderMenuItem]; } else if (!didSetSelectedKeyboard) { [self setDefaultSelectedKeyboard]; } } - (void) setSelectedKeyboard:(NSString*)keyboardName inMenuItem:(NSMenuItem*) menuItem { KVKFile *kvk = nil; NSString *fullPath = [KMDataRepository.shared buildFullPath:keyboardName]; os_log_debug([KMLogs dataLog], "setSelectedKeyboard, keyboardName = '%{public}@', full path = '%{public}@'", keyboardName, fullPath); [menuItem setState:NSOnState]; KMXFile *kmx = [[KMXFile alloc] initWithFilePath:fullPath]; [self loadKeyboardFromKmxFile:kmx]; NSDictionary *kmxInfo = [KMXFile keyboardInfoFromKmxFile:fullPath]; NSString *kvkFilename = [kmxInfo objectForKey:kKMVisualKeyboardKey]; if (kvkFilename != nil) { NSString *kvkFilePath = [self kvkFilePathFromFilename:kvkFilename]; if (kvkFilePath != nil) { kvk = [[KVKFile alloc] initWithFilePath:kvkFilePath]; } } [self setKvk:kvk]; [self setKeyboardName:[kmxInfo objectForKey:kKMKeyboardNameKey]]; [self setKeyboardIcon:[kmxInfo objectForKey:kKMKeyboardIconKey]]; [self applyPersistedOptions]; } // defaults to the whatever keyboard happens to be first in the list - (void) setDefaultSelectedKeyboard { NSMenuItem* menuItem = [self.menu itemAtIndex:KEYMAN_FIRST_KEYBOARD_MENUITEM_INDEX]; NSString *keyboardName = [self.enabledKeyboards objectAtIndex:0]; [self setSelectedKeyboard:keyboardName inMenuItem:menuItem]; [self setSelectedKeyboard:keyboardName]; [self clearContextBuffer]; [KMSentryHelper addInfoBreadCrumb:@"startup" message:[NSString stringWithFormat:@"setDefaultSelectedKeyboard: %@", keyboardName]]; } - (void) addKeyboardPlaceholderMenuItem { NSString* placeholder = NSLocalizedString(@"no-keyboard-configured-menu-placeholder", nil); NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:placeholder action:NULL keyEquivalent:@""]; [self.menu insertItem:item atIndex:KEYMAN_FIRST_KEYBOARD_MENUITEM_INDEX]; [self resetKmx]; [self setKvk:nil]; [self setKeyboardName:nil]; [self setKeyboardIcon:nil]; [self clearContextBuffer]; [self setSelectedKeyboard:@""]; } - (void)selectKeyboardFromMenu:(NSInteger)tag { NSMenuItem *menuItem = [self.menu itemWithTag:tag]; NSString *title = menuItem.title; os_log_info([KMLogs keyboardLog], "Input Menu, selected Keyboards menu, itag: %lu, title: %{public}@", tag, title); for (NSMenuItem *item in self.menu.itemArray) { // set the state of the keyboard items in the Keyman menu based on the new selection if (item.tag >= KEYMAN_FIRST_KEYBOARD_MENUITEM_TAG) { if (item.tag == tag) { [item setState:NSOnState]; } else { [item setState:NSOffState]; } } } NSString *path = [self.enabledKeyboards objectAtIndex:tag-KEYMAN_FIRST_KEYBOARD_MENUITEM_TAG]; NSString *fullPath = [KMDataRepository.shared buildFullPath:path]; os_log_debug([KMLogs dataLog], "setSelectedKeyboard, keyboardName = '%{public}@', full path = '%{public}@'", path, fullPath); KMXFile *kmx = [[KMXFile alloc] initWithFilePath:fullPath]; [self loadKeyboardFromKmxFile:kmx]; KVKFile *kvk = nil; NSDictionary *kmxInfo = [KMXFile keyboardInfoFromKmxFile:fullPath]; NSString *kvkFilename = [kmxInfo objectForKey:kKMVisualKeyboardKey]; if (kvkFilename != nil) { NSString *kvkFilePath = [self kvkFilePathFromFilename:kvkFilename]; if (kvkFilePath != nil) kvk = [[KVKFile alloc] initWithFilePath:kvkFilePath]; } [self setKvk:kvk]; NSString *keyboardName = [kmxInfo objectForKey:kKMKeyboardNameKey]; os_log_info([KMLogs keyboardLog], "Selected keyboard from menu: %{public}@", keyboardName); [self setKeyboardName:keyboardName]; [self setKeyboardIcon:[kmxInfo objectForKey:kKMKeyboardIconKey]]; [self clearContextBuffer]; [self setSelectedKeyboard:path]; [self applyPersistedOptions]; } - (NSArray *)getKmxFilesInKeyboardsDirectory { return [self getKmxFilesAtPath:self.keyboardsPath]; } - (NSArray *)getKmxFilesAtPath:(NSString *)path { os_log_debug([KMLogs dataLog], "getKmxFilesAtPath, path: '%{public}@'", path); NSDirectoryEnumerator *dirEnum = [[NSFileManager defaultManager] enumeratorAtPath:path]; NSMutableArray *kmxFiles = [[NSMutableArray alloc] initWithCapacity:0]; NSString *filePath; while (filePath = (NSString *)[dirEnum nextObject]) { NSString *extension = [[filePath pathExtension] lowercaseString]; if ([extension isEqualToString:@"kmx"]) { [kmxFiles addObject:[path stringByAppendingPathComponent:filePath]]; os_log_debug([KMLogs dataLog], "file = %{public}@", filePath); } } return kmxFiles; } - (NSArray *)getKvkFilesArray { NSDirectoryEnumerator *dirEnum = [[NSFileManager defaultManager] enumeratorAtPath:self.keyboardsPath]; NSMutableArray *kvkFilesArray = [[NSMutableArray alloc] initWithCapacity:0]; NSString *filePath; while (filePath = (NSString *)[dirEnum nextObject]) { NSString *extension = [[filePath pathExtension] lowercaseString]; if ([extension isEqualToString:@"kvk"]) [kvkFilesArray addObject:[self.keyboardsPath stringByAppendingPathComponent:filePath]]; } return kvkFilesArray; } - (NSString *)kvkFilePathFromFilename:(NSString *)kvkFilename { NSString *kvkFilePath = nil; NSArray *kvkFilesArray = [self getKvkFilesArray]; for (NSString *filePath in kvkFilesArray) { if ([[filePath lastPathComponent] isEqualToString:kvkFilename]) { kvkFilePath = filePath; break; } } return kvkFilePath; } - (NSWindowController *)oskWindow { if (!_oskWindow) { _oskWindow = [[OSKWindowController alloc] initWithWindowNibName:@"OSKWindowController"]; os_log_debug([KMLogs oskLog], "Loaded oskWindow from Nib, isVisible: %{public}@ readShowOskOnActivate: %{public}@", [_oskWindow.window isVisible]?@"true":@"false", [KMSettingsRepository.shared readShowOskOnActivate]?@"true":@"false"); } return _oskWindow; } - (void)showOSK { [self.oskWindow prepareToShowOsk]; [[self.oskWindow window] makeKeyAndOrderFront:nil]; [[self.oskWindow window] setLevel:NSStatusWindowLevel]; [[self.oskWindow window] setTitle:self.oskWindowTitle]; [KMSentryHelper addOskVisibleTag:[self.oskWindow.window isVisible]]; } - (void)showAboutWindow { [self.aboutWindow.window centerInParent]; [self.aboutWindow.window makeKeyAndOrderFront:nil]; [self.aboutWindow.window setLevel:NSFloatingWindowLevel]; } /* * Wrappers for creating and destroying windows * TODO: the side-effect of creating the window and its controller should * probably be managed better. */ // TODO: rewrite confusing pattern, multiple methods differing only by underscore - (NSWindowController *)aboutWindow_ { return _aboutWindow; } - (NSWindowController *)aboutWindow { if (_aboutWindow.window == nil) { _aboutWindow = [[KMAboutWindowController alloc] initWithWindowNibName:@"KMAboutWindowController"]; [self observeCloseFor:_aboutWindow.window]; } return _aboutWindow; } - (NSWindowController *)kbHelpWindow_ { return _kbHelpWindow; } - (NSWindowController *)kbHelpWindow { if (_kbHelpWindow.window == nil) { _kbHelpWindow = [[KMKeyboardHelpWindowController alloc] initWithWindowNibName:@"KMKeyboardHelpWindowController"]; [self observeCloseFor:_kbHelpWindow.window]; } return _kbHelpWindow; } /* * Release windows after closing -- no need to keep them hanging about */ - (void)observeCloseFor:(NSWindow *)window { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(windowWillClose:) name:NSWindowWillCloseNotification object:window]; } - (void)windowWillClose:(NSNotification *)notification { NSWindow* window = notification.object; if(window == _kbHelpWindow.window) { _kbHelpWindow.window = nil; } [[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillCloseNotification object:window]; } - (void)handleKeyEvent:(NSEvent *)event { os_log_debug([KMLogs keyTraceLog], "handleKeyEvent: %{public}@", event); NSArray *stackSymbols = [NSThread callStackSymbols]; os_log_debug([KMLogs keyTraceLog], "call stack: %{public}@", stackSymbols); if (_oskWindow == nil) return; [_oskWindow.oskView handleKeyEvent:event]; } - (NSEventModifierFlags) determineModifiers { NSEventModifierFlags modifierFlags = 0; if (self.receivedKeyDownFromOsk) { /** * the event was generated from the OSK, so use the oskEventModifiers in effect at time of event generation */ modifierFlags = self.oskEventModifiers; os_log_debug([KMLogs eventsLog], "--- use modifiers from OSK, oskEventModifiers: 0x%lX", (unsigned long)modifierFlags); self.oskEventModifiers = 0; } else { /** * the event originated from the physical keyboard, use the modifiers on kCGEventFlagsChanged events */ NSEventModifierFlags originalModifiers = self.currentModifiers; modifierFlags = [self.modifierMapping adjustModifiers:originalModifiers]; os_log_debug([KMLogs eventsLog], "--- use adjusted modifiers from current state: 0x%lX", (unsigned long)modifierFlags); /** * If the OSK is open, then we also need to apply its modifiers, if any, to this event originating from the physical keyboard. * If the OSK is closed, its modifiers will be zero. */ NSEventModifierFlags oskModifiers = [self.oskWindow getOskEventModifierFlags]; if (oskModifiers != 0) { os_log_debug([KMLogs eventsLog], "--- modifiers from OSK to apply to physical keyboard event: 0x%lX", (unsigned long)oskModifiers); // combine osk modifiers with adjusted modifiers modifierFlags = oskModifiers | modifierFlags; os_log_debug([KMLogs eventsLog], "--- combined modifiers to apply to physical keyboard event: 0x%lX", (unsigned long)modifierFlags); } } return modifierFlags; } extern const CGKeyCode kProcessPendingBuffer; // This could more easily and logically be done in the input method, but this // allows us to override the norma behavior for unit testing, where there is no // active event loop to post to. - (void)postKeyboardEventWithSource: (CGEventSourceRef)source code:(CGKeyCode) virtualKey postCallback:(PostEventCallback)postEvent{ CGEventRef ev = CGEventCreateKeyboardEvent (source, virtualKey, true); //down if (postEvent) { os_log_info([KMLogs eventsLog], "postKeyboardEventWithSource, keycode: %d", virtualKey); postEvent(ev); } CFRelease(ev); if (virtualKey != kProcessPendingBuffer) { // special 0xFF code is not a real key-press, so no "up" is needed ev = CGEventCreateKeyboardEvent (source, virtualKey, false); //up if (postEvent) postEvent(ev); CFRelease(ev); } } @end