From 65c151b33cfe0698bdc45ae7fc976a1ff83262d5 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Wed, 4 Feb 2026 14:40:43 -0500 Subject: [PATCH 01/36] fix(mac): improved adherence to backspace rule introduces an alternative to approach to backspace for compliant apps by using the insertText API to replace a character to be deleted and the preceding character from the context with only the character from the context Fixes: #15543 --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 96 ++++++++++++++++--- .../Keyman4MacIM/TextApiCompliance.h | 1 + 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index dd9a8a3cee..20409a3a09 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -8,6 +8,7 @@ #import "KMInputMethodEventHandler.h" #import #import /* For kVK_ constants. */ +#import #import "KeySender.h" #import "TextApiCompliance.h" #import "KMSettingsRepository.h" @@ -370,7 +371,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; contextString = attributedString.string; //only uncomment for testing as we do not want to write context in logs - //os_log_debug([KMLogs testLog], " length: %lu result: %{public}@", contextString.length, contextString); + //os_log_debug([KMLogs keyTraceLog], " length: %lu result: %{public}@", contextString.length, contextString); } } } @@ -405,18 +406,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; [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 - NSString *message = @"applyOutputToTextInputClient, delete only scenario with passthrough"; - os_log_debug([KMLogs keyTraceLog], "%@", message); - [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - handledEvent = NO; - } else { - NSString *message = @"applyOutputToTextInputClient, delete only scenario"; - os_log_debug([KMLogs keyTraceLog], "%@", message); - [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - [self sendEvents:event forOutput:output]; - } + handledEvent = [self handleDeleteOnlyScenario:output keyDownEvent:event client:client]; } else if (output.isDeleteAndInsertScenario) { // TODO: fix issue #10246 /* @@ -511,6 +501,86 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; } } +/** + * 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; + } + // otherwise generate a backspace + else { + 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 do a more precise delete. When generating a backspace event or allowing a backspace + * to pass through, it may delete a combining diacritic and the preceding codepoint that it combines with. + * Instead, we can delete the combining diacritic alone by using the insertText API to replace two code points with one. + * This method only works for compliant apps because non-compliant apps do not support the insertText API. + */ +-(BOOL)handleDeleteWithReplacement:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { + BOOL handledEvent = NO; + NSString *context = [self readContext:event forClient:client]; + int codePointsToDelete = (int) output.codePointsToDeleteBeforeInsert; + + if ((self.apiCompliance.canReplaceText) && ([context length] > codePointsToDelete)) { + int codePointsToReplace = codePointsToDelete + 1; + NSRange replacementStringRange = NSMakeRange([context length] - codePointsToReplace, 1); + NSString *replacementString = [context substringWithRange:replacementStringRange]; + + // replace only works for non-control characters + // if replacementString contains control characters, then return without handling event + NSString *message = nil; + if ([self containsControlCharacter:replacementString]) { + message = @"handleDeleteByReplace, replacementString contains control characters, cannot delete with replace"; + os_log_debug([KMLogs keyTraceLog], "%@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + return NO; + } else { + message = @"handleDeleteByReplace, canReplaceText == true"; + os_log_debug([KMLogs keyTraceLog], "%@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + handledEvent = YES; + } + + NSRange replacementRange = NSMakeRange(replacementStringRange.location, codePointsToReplace); + [client insertText:replacementString replacementRange:replacementRange]; + } + + return handledEvent; +} + +-(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. diff --git a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h index 1f5b55f17f..40c516a8db 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.h @@ -22,6 +22,7 @@ NS_ASSUME_NONNULL_BEGIN -(void) checkComplianceAfterInsert:(NSString *)insertedText deleted:(NSString *)deletedText; -(BOOL)isComplianceUncertain; -(BOOL)canReadText; +-(BOOL)canReplaceText; -(BOOL)mustBackspaceUsingEvents; @end From c2a667635a389c45ff169ca20e3878c40b1f91aa Mon Sep 17 00:00:00 2001 From: Shawn Schantz <89134789+sgschantz@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:53:05 -0500 Subject: [PATCH 02/36] Update mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m Co-authored-by: Marc Durdin --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 20409a3a09..659d723c01 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -537,10 +537,33 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; /** * For compliant apps only. - * This an attempt to do a more precise delete. When generating a backspace event or allowing a backspace - * to pass through, it may delete a combining diacritic and the preceding codepoint that it combines with. - * Instead, we can delete the combining diacritic alone by using the insertText API to replace two code points with one. - * This method only works for compliant apps because non-compliant apps do not support the insertText API. + * + * 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; From 553a2e557184ba5d943f103087fc4863cddbfa57 Mon Sep 17 00:00:00 2001 From: Shawn Schantz <89134789+sgschantz@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:54:00 -0500 Subject: [PATCH 03/36] Apply suggestions from code review Co-authored-by: Marc Durdin --- mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 659d723c01..828daec2f9 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -515,7 +515,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; } // pass through if this was a backspace keydown event - if ((event.keyCode == kVK_Delete) && output.codePointsToDeleteBeforeInsert == 1) { + 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); @@ -524,8 +524,8 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; // instruct system to handle the event return NO; } - // otherwise generate a backspace else { + // otherwise generate a backspace NSString *message = @"handleDeleteOnlyForOutput, send backspace event"; os_log_debug([KMLogs keyTraceLog], "%@", message); [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; From f8e9f7221023254cc4536a2a60435f7b9a290d12 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Sat, 14 Feb 2026 16:02:53 -0500 Subject: [PATCH 04/36] fix(mac): improved adherence to backspace rule deals with preceding character being a surrogate pair when a delete occurs Fixes: #15543 --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 133 +++++++++++++++--- 1 file changed, 114 insertions(+), 19 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 828daec2f9..3dfbd8593b 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -568,33 +568,128 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; -(BOOL)handleDeleteWithReplacement:(CoreKeyOutput*)output keyDownEvent:(nonnull NSEvent *)event client:(id) client { BOOL handledEvent = NO; NSString *context = [self readContext:event forClient:client]; - int codePointsToDelete = (int) output.codePointsToDeleteBeforeInsert; - if ((self.apiCompliance.canReplaceText) && ([context length] > codePointsToDelete)) { - int codePointsToReplace = codePointsToDelete + 1; - NSRange replacementStringRange = NSMakeRange([context length] - codePointsToReplace, 1); - NSString *replacementString = [context substringWithRange:replacementStringRange]; + // 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 ([self deletionWillReplacePartOfCluster: deletionTargetLocation precedingCharacterLocation:precedingCharacterLocation context:context]) { + handledEvent = [self deleteByReplacingWithPrecedingCharacter:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; + } else { + handledEvent = [self deleteByReplacingWithPrecedingCluster: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 string to be deleted is part of the same cluster as the character in the context that precedes it. + */ +-(BOOL) deletionWillReplacePartOfCluster: (NSUInteger)deletionLocation precedingCharacterLocation: (NSUInteger)precedingLocation context:(NSString*) context { + // NSString objects hold UTF-16 characters, so a single unicode composed character + // or grapheme cluster may occupy a range of NSString indices instead of a single character. + // This includes base and combining characters potentially composed of surrogate pairs. + NSRange firstDeletionTargetClusterRange = [context rangeOfComposedCharacterSequenceAtIndex: deletionLocation]; + + // get range of the preceding cluster in the context + NSRange precedingClusterRange = [context rangeOfComposedCharacterSequenceAtIndex: precedingLocation]; + + NSString *firstFullCharacterToDelete = [context substringWithRange:firstDeletionTargetClusterRange]; + NSString *precedingFullCharacter = [context substringWithRange:precedingClusterRange]; + os_log_debug([KMLogs keyTraceLog], "firstDeletionTargetCharacterRange: %{public}@, deletionCharacter: %{public}@, precedingCharacterRange %{public}@, precedingCharacter: %{public}@", NSStringFromRange(firstDeletionTargetClusterRange), firstFullCharacterToDelete, NSStringFromRange(precedingClusterRange), precedingFullCharacter); + + // true when the first character to delete and the preceding character + // from the context are part of the same grapheme cluster + return NSEqualRanges(firstDeletionTargetClusterRange, precedingClusterRange); +} + +/** + * 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, deletion target is part of the same grapheme cluster as the character that precedes it"); + // 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 cluster preceding it solely with the cluster that precedes it. + * The 'cluster' may be just one character, but if contains surrogate pairs, this ensures that they stay together. + * Returns YES if executing the replace/delete and NO otherwise. + */ + -(BOOL) deleteByReplacingWithPrecedingCluster:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { + + os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingCluster, deletion target is independent of the grapheme cluster that precedes it"); - // replace only works for non-control characters - // if replacementString contains control characters, then return without handling event - NSString *message = nil; + // get range of the preceding cluster and the substring from the context + NSRange precedingClusterRange = [context rangeOfComposedCharacterSequenceAtIndex: precedingCharacterLocation]; + NSString *replacementString = [context substringWithRange:precedingClusterRange]; + + // guard: if preceding cluster contains control characters, return NO if ([self containsControlCharacter:replacementString]) { - message = @"handleDeleteByReplace, replacementString contains control characters, cannot delete with replace"; + NSString *message = @"replacementString contains control characters, cannot delete with replace"; os_log_debug([KMLogs keyTraceLog], "%@", message); [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; return NO; - } else { - message = @"handleDeleteByReplace, canReplaceText == true"; - os_log_debug([KMLogs keyTraceLog], "%@", message); - [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; - handledEvent = YES; } - - NSRange replacementRange = NSMakeRange(replacementStringRange.location, codePointsToReplace); + + // perform the replacement + NSUInteger replacementLength = [replacementString length] + deleteLength; + NSRange replacementRange = NSMakeRange([context length] - replacementLength, replacementLength); + os_log_debug([KMLogs keyTraceLog], "replacementRange: %{public}@", NSStringFromRange(replacementRange)); [client insertText:replacementString replacementRange:replacementRange]; - } - - return handledEvent; + + return YES; } -(BOOL) containsControlCharacter:(NSString*)text { From 4c4643644e0702f3492d0a6acc6483b05bd5ab7f Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Thu, 26 Feb 2026 11:30:27 -0500 Subject: [PATCH 05/36] fix(mac): surrogate pair backspace handling replaces with surrogate pair or single character but not with a larger composed character or grapheme cluster Fixes: #15543 --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 3dfbd8593b..09276a3143 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -585,10 +585,13 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; NSUInteger deletionTargetLocation = context.length-deletionTargetLength; NSUInteger precedingCharacterLocation = deletionTargetLocation - 1; - if ([self deletionWillReplacePartOfCluster: deletionTargetLocation precedingCharacterLocation:precedingCharacterLocation context:context]) { - handledEvent = [self deleteByReplacingWithPrecedingCharacter:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; + // 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 { - handledEvent = [self deleteByReplacingWithPrecedingCluster:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; + // otherwise replace with only the preceding character + handledEvent = [self deleteByReplacingWithPrecedingCharacter:precedingCharacterLocation deleteLength:deletionTargetLength context:context client:client]; } return handledEvent; @@ -615,6 +618,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; /** * Check whether the string to be deleted is part of the same cluster as the character in the context that precedes it. + * This function made not be needed, using `precededBySurrogatePair` instead. */ -(BOOL) deletionWillReplacePartOfCluster: (NSUInteger)deletionLocation precedingCharacterLocation: (NSUInteger)precedingLocation context:(NSString*) context { // NSString objects hold UTF-16 characters, so a single unicode composed character @@ -634,13 +638,37 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; return NSEqualRanges(firstDeletionTargetClusterRange, precedingClusterRange); } +/** + * 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 + // this is not expected from Keyman Core; write to log but return false + precedingCharacterIsLowSurrogate = false; + NSString *message = [NSString stringWithFormat:@"High surrogate found for preceding character at %ld", (long)precedingCharacter]; + os_log_debug([KMLogs keyTraceLog], "%{public}@", message); + } else if (CFStringIsSurrogateLowCharacter(precedingCharacter)) { + // preceding character is low surrogate + precedingCharacterIsLowSurrogate = true; + NSString *message = [NSString stringWithFormat:@"Low surrogate found for preceding character at %ld", (long)precedingCharacter]; + os_log_debug([KMLogs keyTraceLog], "%{public}@", message); + } + + 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, deletion target is part of the same grapheme cluster as the character that precedes it"); + os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingCharacter"); // get the preceding character NSRange precedingCharacterRange = NSMakeRange(precedingCharacterLocation, 1); NSString *replacementString = [context substringWithRange:precedingCharacterRange]; @@ -662,10 +690,54 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; 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))) { + NSString *message = [NSString stringWithFormat:@"Replacement string containing surrogate %@", replacementString]; + os_log_debug([KMLogs keyTraceLog], "%{public}@", message); + [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + } 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; +} + /** * Replace both the text to delete and the cluster preceding it solely with the cluster that precedes it. * The 'cluster' may be just one character, but if contains surrogate pairs, this ensures that they stay together. * Returns YES if executing the replace/delete and NO otherwise. + * This function may not be needed, using `deleteByReplacingWithPrecedingSurrogate` instead. */ -(BOOL) deleteByReplacingWithPrecedingCluster:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { From 382b5d45e3fe125efe4c6e457b01cf2acf9681be Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 2 Mar 2026 17:17:36 +0100 Subject: [PATCH 06/36] fix(core): fix keydown/up handling for LDML keyboards For LDML keyboards this change fixes the `emit_key` flag so that it has the same value for the KeyDown and the KeyUp event. This fixes some problems with stuck keys. Previously we would set `emit_key=TRUE` on KeyDown but `emit_key=FALSE` on KeyUp for frame keys. This caused Linux to never see the KeyUp event, resulting in a stuck key. Also add unit tests that verifies that the actions that we get after calling `km_core_process_event` are what we expect. Fixes: #15569 Fixes: #15550 --- core/src/km_core_processevent_api.cpp | 7 + core/src/ldml/ldml_processor.cpp | 49 ++- core/src/ldml/ldml_processor.hpp | 3 + core/src/state.cpp | 3 +- core/src/state.hpp | 7 + .../unit/km_core_process_event.tests.cpp | 355 ++++++++++++++++++ core/tests/unit/meson.build | 12 + 7 files changed, 420 insertions(+), 16 deletions(-) create mode 100644 core/tests/unit/km_core_process_event.tests.cpp diff --git a/core/src/km_core_processevent_api.cpp b/core/src/km_core_processevent_api.cpp index a2bca95ea8..ade2665353 100644 --- a/core/src/km_core_processevent_api.cpp +++ b/core/src/km_core_processevent_api.cpp @@ -50,6 +50,9 @@ km_core_process_event(km_core_state *state, if(state == nullptr) { return KM_CORE_STATUS_INVALID_ARGUMENT; } + if (vk == KM_CORE_VKEY_BKSP && is_key_down) { + state->set_backspace_handled_internally(false); + } km_core_status status = state->processor().process_event(state, vk, modifier_state, is_key_down, event_flags); if (state_should_invalidate_context(state, vk, modifier_state, is_key_down, event_flags)) { @@ -78,6 +81,10 @@ km_core_process_event(km_core_state *state, state->apply_actions_and_merge_app_context(); + if (vk == KM_CORE_VKEY_BKSP) { + state->set_backspace_handled_internally(!state->action_struct().emit_keystroke); + } + return status; } diff --git a/core/src/ldml/ldml_processor.cpp b/core/src/ldml/ldml_processor.cpp index 9ef36b62fc..031669fbe5 100644 --- a/core/src/ldml/ldml_processor.cpp +++ b/core/src/ldml/ldml_processor.cpp @@ -179,21 +179,20 @@ ldml_processor::process_event( ldml_state.clear(); try { - if (!is_key_down) { - process_key_up(ldml_state); - } else { - switch (vk) { - // Currently, only one VK gets spoecial treatment. - // Special handling for backspace VK - case KM_CORE_VKEY_BKSP: - process_backspace(ldml_state); - break; - default: - // all other VKs + switch (vk) { + // Currently, only one VK gets special treatment. + // Special handling for backspace VK + case KM_CORE_VKEY_BKSP: + process_backspace(ldml_state); + break; + default: + // all other VKs + if (is_key_down) { process_key_down(ldml_state); - } // end of switch - } // end of normal processing - + } else { + process_key_up(ldml_state); + } + } // end of switch // all key-up and key-down events end up here. // commit the ldml state into the core state ldml_state.commit(); @@ -210,11 +209,31 @@ void ldml_processor::process_key_up(ldml_event_state &ldml_state) const { // TODO-LDML: Implement caps lock handling - ldml_state.clear(); + + // Look up the key + bool found = false; + const std::u16string key_str = keys.lookup(ldml_state.get_vk(), ldml_state.get_modifier_state(), found); + + if (!found) { + ldml_state.emit_passthrough_keystroke(); + } } void ldml_processor::process_backspace(ldml_event_state &ldml_state) const { + if (ldml_state.get_modifier_state() & K_MODIFIERFLAG) { + // we never process modifier+bksp + ldml_state.emit_passthrough_keystroke(); + return; + } + + if (!ldml_state.is_key_down()) { + if (!ldml_state.get_state()->backspace_handled_internally()) { + ldml_state.emit_passthrough_keystroke(); + } + return; + } + if (!!bksp_transforms) { // process with an empty string via the bksp transforms auto matchedContext = process_output(ldml_state, std::u32string(), bksp_transforms.get()); diff --git a/core/src/ldml/ldml_processor.hpp b/core/src/ldml/ldml_processor.hpp index b673a9275c..a10294cc32 100644 --- a/core/src/ldml/ldml_processor.hpp +++ b/core/src/ldml/ldml_processor.hpp @@ -176,6 +176,9 @@ public: * @return the number of context items consumed */ size_t context_to_string(std::u32string &str, bool include_markers = true); + km_core_state* get_state() const { return state; } + + uint8_t is_key_down() const { return _is_key_down; } private: km_core_virtual_key _vk; diff --git a/core/src/state.cpp b/core/src/state.cpp index c6142b3456..47f29d0a67 100644 --- a/core/src/state.cpp +++ b/core/src/state.cpp @@ -52,6 +52,7 @@ state::state(km::core::abstract_processor & ap, km_core_option_item const *env) env->key, env->value); } + _backspace_handled_internally = false; _imx_callback = nullptr; _imx_object = nullptr; memset(const_cast(&_action_struct), 0, sizeof(km_core_actions)); @@ -117,4 +118,4 @@ void state::apply_actions_and_merge_app_context() { } this->_action_struct.deleted_context = km::core::get_deleted_context(app_context_for_deletion, this->_action_struct.code_points_to_delete); -} \ No newline at end of file +} diff --git a/core/src/state.hpp b/core/src/state.hpp index 49eea8ffd2..fafe78edfa 100644 --- a/core/src/state.hpp +++ b/core/src/state.hpp @@ -130,6 +130,7 @@ protected: core::debug_items _debug_items; km_core_keyboard_imx_platform _imx_callback; void *_imx_object; + bool _backspace_handled_internally; public: state(core::abstract_processor & kb, km_core_option_item const *env); @@ -174,6 +175,12 @@ public: km_core_actions const &actions ); void apply_actions_and_merge_app_context(); + + // This is used to track whether the backspace key was handled internally + // during the keydown event. This is needed so that we can return the same + // value from the keyup event. Only used when processing KM_CORE_VKEY_BKSP. + void set_backspace_handled_internally(bool handled) { _backspace_handled_internally = handled; } + bool backspace_handled_internally() const { return _backspace_handled_internally; } }; } // namespace core } // namespace km diff --git a/core/tests/unit/km_core_process_event.tests.cpp b/core/tests/unit/km_core_process_event.tests.cpp new file mode 100644 index 0000000000..67b84760b3 --- /dev/null +++ b/core/tests/unit/km_core_process_event.tests.cpp @@ -0,0 +1,355 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ +#include +#include "path.hpp" +#include "state.hpp" +#include "kmx/kmx_processevent.h" + +#include "emscripten_filesystem.h" +#include "kmnkbd/action_items.hpp" +#include "load_kmx_file.hpp" + +using namespace km::core::kmx; +km::core::path test_dir; + +km_core_option_item test_env_opts[] = {KM_CORE_OPTIONS_END}; + +#define KEY_DOWN TRUE +#define KEY_UP FALSE + +struct TestData { + const char* test_name; + + const char* keyboard_name; + km_core_virtual_key vkey; + uint16_t modifier_state; + + // Initial context + km_core_cu const* context; + + // Whether keydown handled the event. Only relevant for keyup tests. + bool keydown_handled; + + // Expected actions for keydown and keyup + std::initializer_list keydown_actions; + std::initializer_list keyup_actions; +}; + +std::string GenerateTestName(const testing::TestParamInfo& info) { + return info.param.test_name; +} + +class ProcessEventTests : public testing::TestWithParam { +protected: + km_core_keyboard* keyboard = nullptr; + km_core_state* state = nullptr; + KMX_ProcessEvent process_event; + + void Initialize(TestData const& data) { + km::core::path kmxfile = km::core::path(test_dir / data.keyboard_name); + auto blob = km::tests::load_kmx_file(kmxfile.native().c_str()); + + EXPECT_EQ(km_core_keyboard_load_from_blob(kmxfile.stem().c_str(), blob.data(), blob.size(), &this->keyboard), KM_CORE_STATUS_OK); + EXPECT_EQ(km_core_state_create(this->keyboard, test_env_opts, &this->state), KM_CORE_STATUS_OK); + EXPECT_TRUE(this->process_event.Load(blob.data(), blob.size())); + if (data.context) { + EXPECT_EQ(km_core_state_context_set_if_needed(this->state, data.context), KM_CORE_CONTEXT_STATUS_UPDATED); + } + ((km::core::state*)this->state)->set_backspace_handled_internally(data.keydown_handled); + } + + void TearDown() override { + if (this->state) { + km_core_state_dispose(this->state); + this->state = nullptr; + } + if (this->keyboard) { + km_core_keyboard_dispose(this->keyboard); + this->keyboard = nullptr; + } + } +}; + +void print_all_action_items(km_core_state const* state) { + size_t n = 0; + auto act = km_core_state_action_items(state, &n); + std::cout << "Action items:" << std::endl; + for (size_t i = 0; i < n; i++) { + print_action_item("", *act++); + } + std::cout << "---------------" << std::endl; +} + +TEST_P(ProcessEventTests, ReturnsExpectedActionsForKeyDown) { + auto data = GetParam(); + Initialize(data); + EXPECT_EQ(km_core_process_event(this->state, data.vkey, data.modifier_state, + KEY_DOWN, KM_CORE_EVENT_FLAG_DEFAULT), KM_CORE_STATUS_OK); + // print_all_action_items(this->state); + EXPECT_TRUE(action_items(this->state, data.keydown_actions)); +} + +TEST_P(ProcessEventTests, ReturnsExpectedActionsForKeyUp) { + auto data = GetParam(); + Initialize(data); + EXPECT_EQ(km_core_process_event(this->state, data.vkey, data.modifier_state, KEY_UP, KM_CORE_EVENT_FLAG_DEFAULT), KM_CORE_STATUS_OK); + // print_all_action_items(this->state); + EXPECT_TRUE(action_items(this->state, data.keyup_actions)); +} + +union backspace_union { + km_core_backspace_item backspace; + uint32_t value; +}; +// const backspace_union backspace_with_context = {{KM_CORE_BT_CHAR, 'x'}}; +const backspace_union backspace_no_context = {{KM_CORE_BT_CHAR, 0}}; + +const TestData values[] = { + //-------------------------------------------------------------------- + // KMN + // Key with rule + {"KMN_VKey_A", "kmx/k_005___nul_with_initial_context.kmx", KM_CORE_VKEY_A, 0, u"x", true, + { // KeyDown + {KM_CORE_IT_CHAR, { 0, }, {'d'}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + {"KMN_Ctrl_VKey_A", "kmx/k_005___nul_with_initial_context.kmx", KM_CORE_VKEY_A, KM_CORE_MODIFIER_LCTRL, u"x", true, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Key without rule + {"KMN_VKey_X", "kmx/k_005___nul_with_initial_context.kmx", KM_CORE_VKEY_X, 0, u"x", true, + { // KeyDown + {KM_CORE_IT_CHAR, { 0, }, {'x'}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + {"KMN_Ctrl_VKey_X", "kmx/k_005___nul_with_initial_context.kmx", KM_CORE_VKEY_X, KM_CORE_MODIFIER_LCTRL, u"x", true, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Frame key without rule + {"KMN_VKey_Enter", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_ENTER, 0, u"x", false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // // Backspace (with available context) + // // TODO: fix the implementation to make this test pass. Currently failing in KeyUp + // {"KMN_VKey_Backspace_Ctxt", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_BKSP, 0, u"x", true, + // { // KeyDown + // // Once we use C++ 20 we can use: + // //{KM_CORE_IT_BACK, { 0, }, {.backspace = {KM_CORE_BT_CHAR, 'x'}}}, + // {KM_CORE_IT_BACK, { 0, }, {backspace_with_context.value}}, + // {KM_CORE_IT_END} + // }, + // { // KeyUp + // {KM_CORE_IT_END} + // } + // }, + // Backspace (without context) + {"KMN_VKey_Backspace_NoCtxt", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_BKSP, 0, NULL, false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Ctrl+Backspace (with context) + {"KMN_Ctrl_VKey_Backspace_Ctxt", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_BKSP, KM_CORE_MODIFIER_LCTRL, u"x", true, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Ctrl+Backspace (no context) + {"KMN_Ctrl_VKey_Backspace_NoCtxt", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_BKSP, KM_CORE_MODIFIER_LCTRL, NULL, false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Modifier frame key + {"KMN_VKey_Shift", "kmx/k_000___null_keyboard.kmx", KM_CORE_VKEY_SHIFT, 0, u"x", false, + { // KeyDown + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_END} + } + }, + + //-------------------------------------------------------------------- + // LDML + // Key with rule + {"LDML_VKey_A", "ldml/keyboards/k_020_fr.kmx", KM_CORE_VKEY_A, 0, u"x", true, + { // KeyDown + {KM_CORE_IT_CHAR, { 0, }, {'q'}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_END} + } + }, + {"LDML_Ctrl_VKey_A", "ldml/keyboards/k_020_fr.kmx", KM_CORE_VKEY_A, KM_CORE_MODIFIER_LCTRL, u"x", true, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Key without rule + {"LDML_VKey_X", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_X, 0, u"x", true, + { // KeyDown + {KM_CORE_IT_END} // LDML: no output without rule + }, + { // KeyUp + {KM_CORE_IT_END} + } + }, + {"LDML_Ctrl_VKey_X", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_X, KM_CORE_MODIFIER_LCTRL, u"x", true, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} // LDML: no output without rule + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Frame key without rule + {"LDML_VKey_Enter", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_ENTER, 0, u"x", false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Backspace (with available context) + {"LDML_VKey_Backspace_Ctxt", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_BKSP, 0, u"x", true, + { // KeyDown + // Once we use C++ 20 we can use: + //{KM_CORE_IT_BACK, { 0, }, {.backspace = {KM_CORE_BT_CHAR, 0}}}, + {KM_CORE_IT_BACK, { 0, }, {backspace_no_context.value}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_END} + } + }, + // Backspace (without context) + {"LDML_VKey_Backspace_NoCtxt", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_BKSP, 0, NULL, false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Ctrl+Backspace (with context) + {"LDML_Ctrl_VKey_Backspace_Ctxt", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_BKSP, KM_CORE_MODIFIER_LCTRL, u"x", false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Ctrl+Backspace (no context) + {"LDML_Ctrl_VKey_Backspace_NoCtxt", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_BKSP, KM_CORE_MODIFIER_LCTRL, NULL, false, + { // KeyDown + {KM_CORE_IT_INVALIDATE_CONTEXT, { 0, }, {0}}, + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, + // Modifier frame key + {"LDML_VKey_Shift", "ldml/keyboards/k_000_minimal_keyboard.kmx", KM_CORE_VKEY_SHIFT, 0, u"x", false, + { // KeyDown + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + }, + { // KeyUp + {KM_CORE_IT_EMIT_KEYSTROKE, { 0, }, {0}}, + {KM_CORE_IT_END} + } + }, +}; + +INSTANTIATE_TEST_SUITE_P(KMXProcessEvent, ProcessEventTests, testing::ValuesIn(values), GenerateTestName); + +// provide our own `main` so that we can get the path of the exe so that +// we have a well-defined location to find our test keyboards +int main(int argc, char** argv) { +#ifdef __EMSCRIPTEN__ + test_dir = get_wasm_file_path(km::core::path(argv[0]).parent()); +#else + test_dir = km::core::path(argv[0]).parent(); +#endif + std::cout << "test_dir=" << test_dir.c_str() << std::endl; + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/core/tests/unit/meson.build b/core/tests/unit/meson.build index 0637ace96e..3861d45912 100644 --- a/core/tests/unit/meson.build +++ b/core/tests/unit/meson.build @@ -48,6 +48,18 @@ kmcorekeyboardapitests = executable('km_core_keyboard_api.tests', test('km-core-keyboard-api-tests', kmcorekeyboardapitests) +# tests for km_core_process_even +km_core_process_event_tests_exe = executable( + 'km_core_process_event_tests', + ['km_core_process_event.tests.cpp', common_test_files], + include_directories: [inc, libsrc], + cpp_args: defns + warns, + link_args: [ links, extra_link_args ], + dependencies: [icu_uc, icu_i18n, gtest_dep, gmock_dep], + objects: lib.extract_all_objects(recursive: false), +) +test('km_core_process_event', km_core_process_event_tests_exe) + subdir('json') subdir('utftest') subdir('kmnkbd') From 67468c4dce64329659ffd2acf319fda3af7cf808 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Fri, 27 Feb 2026 18:21:48 +0100 Subject: [PATCH 07/36] fix(core): address code review comments Co-authored-by: Marc Durdin --- core/src/state.hpp | 8 +++++--- core/tests/unit/meson.build | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/core/src/state.hpp b/core/src/state.hpp index fafe78edfa..1be699f3c5 100644 --- a/core/src/state.hpp +++ b/core/src/state.hpp @@ -176,9 +176,11 @@ public: ); void apply_actions_and_merge_app_context(); - // This is used to track whether the backspace key was handled internally - // during the keydown event. This is needed so that we can return the same - // value from the keyup event. Only used when processing KM_CORE_VKEY_BKSP. + /** + * This is used to track whether the backspace key was handled internally + * during the keydown event. This is needed so that we can return the same + * value from the keyup event. Only used when processing KM_CORE_VKEY_BKSP. + */ void set_backspace_handled_internally(bool handled) { _backspace_handled_internally = handled; } bool backspace_handled_internally() const { return _backspace_handled_internally; } }; diff --git a/core/tests/unit/meson.build b/core/tests/unit/meson.build index 3861d45912..5456650372 100644 --- a/core/tests/unit/meson.build +++ b/core/tests/unit/meson.build @@ -48,7 +48,7 @@ kmcorekeyboardapitests = executable('km_core_keyboard_api.tests', test('km-core-keyboard-api-tests', kmcorekeyboardapitests) -# tests for km_core_process_even +# tests for km_core_process_event km_core_process_event_tests_exe = executable( 'km_core_process_event_tests', ['km_core_process_event.tests.cpp', common_test_files], From 9836070299e0894ac02949561cf32faee2a4764e Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 2 Mar 2026 17:43:34 +0100 Subject: [PATCH 08/36] fix(core): clarify and extend comment --- core/src/state.hpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/core/src/state.hpp b/core/src/state.hpp index 1be699f3c5..8e6584b05c 100644 --- a/core/src/state.hpp +++ b/core/src/state.hpp @@ -179,7 +179,15 @@ public: /** * This is used to track whether the backspace key was handled internally * during the keydown event. This is needed so that we can return the same - * value from the keyup event. Only used when processing KM_CORE_VKEY_BKSP. + * value from the keyup event as we did for the keydown event. + * + * Backspace is the only key that we sometimes handle internally (if we + * have enough context) and sometimes not. By the time we get the keyup + * event the context already got updated and so we have no way of knowing + * whether or not the keydown handled it internally. Therefore this + * flag exists. + * + * Only used when processing KM_CORE_VKEY_BKSP. */ void set_backspace_handled_internally(bool handled) { _backspace_handled_internally = handled; } bool backspace_handled_internally() const { return _backspace_handled_internally; } From 50a0333ca3d497d1db170645bbfaae5512379b23 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 16 Mar 2026 15:13:28 +0100 Subject: [PATCH 09/36] maint(ios): clean carthage before builds This change is in response to inconsistent build behavior where we observed better outcomes after manually cleaning the Carthage cache. The cache folder had clearly also become very large over time so this has the side benefit of clearing up space on the build agents. It is unclear how much time this will cost in the build, but we don't suppose it will make them dramatically slower. Note: the same change has been applied in TC for stable-18.0 builds. Test-bot: skip --- resources/teamcity/includes/tc-mac.inc.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/resources/teamcity/includes/tc-mac.inc.sh b/resources/teamcity/includes/tc-mac.inc.sh index 0e9c18013c..a138789cf5 100644 --- a/resources/teamcity/includes/tc-mac.inc.sh +++ b/resources/teamcity/includes/tc-mac.inc.sh @@ -12,9 +12,13 @@ ba_mac_unlock_keychain() { } ba_mac_clean_xcode_derived_data() { - builder_echo start "clean" "Cleaning XCode DerivedData mess" + builder_echo start "clean" "Cleaning XCode DerivedData and Carthage cache mess" rm -rf "${HOME}/Library/Developer/Xcode/DerivedData" - builder_echo end "clean" success "Finished cleaning XCode DerivedData mess" + + # https://stackoverflow.com/a/45504898/1836776 + rm -rf "${HOME}/Library/Caches/org.carthage.CarthageKit" + + builder_echo end "clean" success "Finished cleaning XCode DerivedData and Carthage cache mess" } ba_mac_unmount_volumes_keyman() { From d98efe1ac9695c11205cc482bfa6f627aa9789ff Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Mon, 16 Mar 2026 13:02:02 -0500 Subject: [PATCH 10/36] auto: increment master version to 19.0.217 Test-bot: skip Build-bot: skip --- HISTORY.md | 9 +++++++++ VERSION.md | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 8a045c92e0..0973f37105 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,14 @@ # Keyman Version History +## 19.0.216 alpha 2026-03-16 + +* docs: tweak walkthrough content (#15712) +* chore(deps): bump tar from 7.5.10 to 7.5.11 in /developer/src/server/src/win32/trayicon/addon-src (#15723) +* docs(linux): add documentation how input methods work in GTK (#15749) +* docs(core): improve keyhandling doc (#15736) +* maint(resources): try meson 1.10.1 (#15754) +* maint(ios): clean carthage before builds (#15757) + ## 19.0.215 alpha 2026-03-13 * chore(linux): fix dependency of Debian test suite (#15735) diff --git a/VERSION.md b/VERSION.md index 78ca871179..45ab25dda9 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.216 \ No newline at end of file +19.0.217 \ No newline at end of file From 6395caa783b682eef956641c488c75f58e44f92c Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Mon, 16 Mar 2026 14:08:07 +0700 Subject: [PATCH 11/36] maint(common): Update GitHub actions/checkout to v5.0.1 --- .github/workflows/api-verification.yml | 2 +- .github/workflows/build-test-publish-docker.yml | 4 ++-- .github/workflows/core-arm64-windows-test.yml | 2 +- .github/workflows/crowdin.yml | 2 +- .github/workflows/deb-packaging.yml | 6 +++--- .github/workflows/npm-publish.yml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/api-verification.yml b/.github/workflows/api-verification.yml index af6bc682dc..b51c6be2c8 100644 --- a/.github/workflows/api-verification.yml +++ b/.github/workflows/api-verification.yml @@ -55,7 +55,7 @@ jobs: - name: Checkout if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@v5.0.1 with: ref: '${{ steps.environment_step.outputs.GIT_SHA }}' fetch-depth: 0 diff --git a/.github/workflows/build-test-publish-docker.yml b/.github/workflows/build-test-publish-docker.yml index 3c85d4ff91..e0fa8f9a2c 100644 --- a/.github/workflows/build-test-publish-docker.yml +++ b/.github/workflows/build-test-publish-docker.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Checkout repository id: checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5.0.1 - name: Build Docker images id: build run: | @@ -67,7 +67,7 @@ jobs: steps: - name: Checkout repository id: checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5.0.1 - name: Test Docker images id: test diff --git a/.github/workflows/core-arm64-windows-test.yml b/.github/workflows/core-arm64-windows-test.yml index c8b7dd8a13..54d6f3a99f 100644 --- a/.github/workflows/core-arm64-windows-test.yml +++ b/.github/workflows/core-arm64-windows-test.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@v5.0.1 with: ref: '${{ github.event.client_payload.buildSha }}' diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 7503d51d21..386f72d293 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5.0.1 - name: crowdin action uses: crowdin/github-action@v2.7.0 diff --git a/.github/workflows/deb-packaging.yml b/.github/workflows/deb-packaging.yml index 36bfd0d0ce..25c91031c3 100644 --- a/.github/workflows/deb-packaging.yml +++ b/.github/workflows/deb-packaging.yml @@ -43,7 +43,7 @@ jobs: PRERELEASE_TAG: ${{ steps.prerelease_tag.outputs.PRERELEASE_TAG }} steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@v5.0.1 with: ref: '${{ github.event.client_payload.buildSha }}' @@ -140,7 +140,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@v5.0.1 with: ref: '${{ github.event.client_payload.buildSha }}' sparse-checkout: '.github/actions/' @@ -166,7 +166,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@v5.0.1 with: ref: '${{ github.event.client_payload.buildSha }}' sparse-checkout: '.github/actions/' diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index fc94cc688e..d78e2d8e59 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -50,7 +50,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 + uses: actions/checkout@v5.0.1 with: ref: '${{ github.event.client_payload.buildSha }}' From 9697e7d886f4feb08e8855b313beaf1573889ff2 Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Tue, 17 Mar 2026 09:27:24 +0700 Subject: [PATCH 12/36] maint(common): Update actions/(download|upload)-artifact * download-artifact@v8.0.1 * upload-artifact@v7.0.0 --- .github/actions/build-binary-packages/action.yml | 4 ++-- .github/workflows/api-verification.yml | 2 +- .github/workflows/deb-packaging.yml | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/actions/build-binary-packages/action.yml b/.github/actions/build-binary-packages/action.yml index e764a26ac7..23c01c545a 100644 --- a/.github/actions/build-binary-packages/action.yml +++ b/.github/actions/build-binary-packages/action.yml @@ -25,7 +25,7 @@ runs: using: 'composite' steps: - name: Download Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@v8.0.1 with: name: keyman-srcpkg path: artifacts/keyman-srcpkg @@ -49,7 +49,7 @@ runs: echo '```' >> $GITHUB_STEP_SUMMARY - name: Store binary packages - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@v7.0.0 with: name: keyman-binarypkgs-${{ inputs.dist }}_${{ inputs.arch }} path: | diff --git a/.github/workflows/api-verification.yml b/.github/workflows/api-verification.yml index b51c6be2c8..5f85e8bbbc 100644 --- a/.github/workflows/api-verification.yml +++ b/.github/workflows/api-verification.yml @@ -84,7 +84,7 @@ jobs: - name: Archive .symbols file if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' && always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@v7.0.0 with: name: libkeymancore.symbols path: ${{ github.workspace }}/keyman/linux/debian/tmp/DEBIAN/symbols diff --git a/.github/workflows/deb-packaging.yml b/.github/workflows/deb-packaging.yml index 25c91031c3..013713bc44 100644 --- a/.github/workflows/deb-packaging.yml +++ b/.github/workflows/deb-packaging.yml @@ -121,7 +121,7 @@ jobs: echo "- $(find . -name keyman_\*.dsc)" >> $GITHUB_STEP_SUMMARY - name: Store source package - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@v7.0.0 with: name: keyman-srcpkg path: | @@ -204,13 +204,13 @@ jobs: sudo rm -rf /usr/share/dotnet - name: Download Source Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@v8.0.1 with: name: keyman-srcpkg path: artifacts - name: Download Binary Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@v8.0.1 with: path: artifacts pattern: keyman-binarypkgs-* @@ -281,7 +281,7 @@ jobs: echo "::endgroup::" - name: Download Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@v8.0.1 with: name: keyman-signedpkgs @@ -343,7 +343,7 @@ jobs: steps: - name: Download Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@v8.0.1 with: path: artifacts pattern: keyman-* From b77be4187ad97c0ebb522fc7b88e4d36e7afb37c Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Tue, 17 Mar 2026 09:28:37 +0700 Subject: [PATCH 13/36] maint(common): Update actions/cache/(restore|save) * actions/cache/restore@v5.0.3 * actions/cache/save@v5.0.3 --- .github/workflows/api-verification.yml | 2 +- .github/workflows/deb-packaging.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/api-verification.yml b/.github/workflows/api-verification.yml index 5f85e8bbbc..d274d50090 100644 --- a/.github/workflows/api-verification.yml +++ b/.github/workflows/api-verification.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Restore artifacts - uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache/restore@v5.0.3 with: path: | artifacts diff --git a/.github/workflows/deb-packaging.yml b/.github/workflows/deb-packaging.yml index 013713bc44..c70100d8c6 100644 --- a/.github/workflows/deb-packaging.yml +++ b/.github/workflows/deb-packaging.yml @@ -362,7 +362,7 @@ jobs: echo "SKIP_API_CHECK=${{ github.event.client_payload.skipApiCheck }}" >> artifacts/env - name: Cache artifacts - uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache/save@v5.0.3 with: path: | artifacts From 6c6b620812df2cb502c29b99d283cb905c35cced Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Tue, 17 Mar 2026 09:29:37 +0700 Subject: [PATCH 14/36] maint(common): Update misc actions * actions/setup-python@v6.2.0 * actions/labeler@v6.0.1 * actions/github-script@v8.0.0 --- .github/workflows/core-arm64-windows-test.yml | 2 +- .github/workflows/labeler.yml | 2 +- .github/workflows/pr-build-status.yml | 72 +++++++++---------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.github/workflows/core-arm64-windows-test.yml b/.github/workflows/core-arm64-windows-test.yml index 54d6f3a99f..742cce1864 100644 --- a/.github/workflows/core-arm64-windows-test.yml +++ b/.github/workflows/core-arm64-windows-test.yml @@ -64,7 +64,7 @@ jobs: npm -v node -v - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6.2.0 with: python-version: '3.13' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 42d9f79363..9a60fc9ee9 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Update labels based on changed files - uses: actions/labeler@v4 + uses: actions/labeler@v6.0.1 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" - name: Update labels based on PR title diff --git a/.github/workflows/pr-build-status.yml b/.github/workflows/pr-build-status.yml index 978a2b0995..487a58ae5f 100644 --- a/.github/workflows/pr-build-status.yml +++ b/.github/workflows/pr-build-status.yml @@ -1,40 +1,40 @@ # GENERATED FILE - DO NOT EDIT! -# -# Keyman is copyright (C) SIL Global. MIT License. -# -# Do not modify the script in .github/workflows/pr-build-status.yml directly; -# instead work on the sources in resources/build/pr-build-status and use the -# build.sh script to rebuild the .github/workflows/pr-build-status.yml file from -# them. -# -# build.sh will append the relevant portions of -# resources/build/pr-build-status/pr-build-status.mjs to the content in -# resources/build/pr-build-status/pr-build-status.prefix.yml to form the .github -# workflow file. -# -name: Keyman Build Summary -on: - status: - push: - branches-ignore: - - master - - beta - - stable-* - workflow_dispatch: - inputs: - commit: - description: 'Commit sha' - required: true - type: string -jobs: - run_pr_build_status: - name: Summarize build status checks - runs-on: ubuntu-latest - steps: - - name: Check PR build status - id: run_pr_build_status_script - uses: actions/github-script@v7 - with: +# +# Keyman is copyright (C) SIL Global. MIT License. +# +# Do not modify the script in .github/workflows/pr-build-status.yml directly; +# instead work on the sources in resources/build/pr-build-status and use the +# build.sh script to rebuild the .github/workflows/pr-build-status.yml file from +# them. +# +# build.sh will append the relevant portions of +# resources/build/pr-build-status/pr-build-status.mjs to the content in +# resources/build/pr-build-status/pr-build-status.prefix.yml to form the .github +# workflow file. +# +name: Keyman Build Summary +on: + status: + push: + branches-ignore: + - master + - beta + - stable-* + workflow_dispatch: + inputs: + commit: + description: 'Commit sha' + required: true + type: string +jobs: + run_pr_build_status: + name: Summarize build status checks + runs-on: ubuntu-latest + steps: + - name: Check PR build status + id: run_pr_build_status_script + uses: actions/github-script@v8.0.0 + with: script: | // This code is copied out of resources/build/pr-build-status/pr-build-status.mjs // where it is tested. It is copied inline here in order to avoid requiring the From 44a46f5d45bb71c12f168866c95fb4b075755a18 Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Tue, 17 Mar 2026 09:31:00 +0700 Subject: [PATCH 15/36] maint(common): Update actions/checkout * actions/checkout@v6.0.2 --- .github/workflows/api-verification.yml | 2 +- .github/workflows/build-test-publish-docker.yml | 4 ++-- .github/workflows/core-arm64-windows-test.yml | 2 +- .github/workflows/crowdin.yml | 2 +- .github/workflows/deb-packaging.yml | 6 +++--- .github/workflows/npm-publish.yml | 2 +- resources/build/pr-build-status/pr-build-status.prefix.yml | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/api-verification.yml b/.github/workflows/api-verification.yml index d274d50090..79e4f8ea52 100644 --- a/.github/workflows/api-verification.yml +++ b/.github/workflows/api-verification.yml @@ -55,7 +55,7 @@ jobs: - name: Checkout if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' - uses: actions/checkout@v5.0.1 + uses: actions/checkout@v6.0.2 with: ref: '${{ steps.environment_step.outputs.GIT_SHA }}' fetch-depth: 0 diff --git a/.github/workflows/build-test-publish-docker.yml b/.github/workflows/build-test-publish-docker.yml index e0fa8f9a2c..7ff6bb0495 100644 --- a/.github/workflows/build-test-publish-docker.yml +++ b/.github/workflows/build-test-publish-docker.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Checkout repository id: checkout - uses: actions/checkout@v5.0.1 + uses: actions/checkout@v6.0.2 - name: Build Docker images id: build run: | @@ -67,7 +67,7 @@ jobs: steps: - name: Checkout repository id: checkout - uses: actions/checkout@v5.0.1 + uses: actions/checkout@v6.0.2 - name: Test Docker images id: test diff --git a/.github/workflows/core-arm64-windows-test.yml b/.github/workflows/core-arm64-windows-test.yml index 742cce1864..a818957a7a 100644 --- a/.github/workflows/core-arm64-windows-test.yml +++ b/.github/workflows/core-arm64-windows-test.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5.0.1 + uses: actions/checkout@v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 386f72d293..7294d3e505 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5.0.1 + uses: actions/checkout@v6.0.2 - name: crowdin action uses: crowdin/github-action@v2.7.0 diff --git a/.github/workflows/deb-packaging.yml b/.github/workflows/deb-packaging.yml index c70100d8c6..169ff651c1 100644 --- a/.github/workflows/deb-packaging.yml +++ b/.github/workflows/deb-packaging.yml @@ -43,7 +43,7 @@ jobs: PRERELEASE_TAG: ${{ steps.prerelease_tag.outputs.PRERELEASE_TAG }} steps: - name: Checkout - uses: actions/checkout@v5.0.1 + uses: actions/checkout@v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' @@ -140,7 +140,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5.0.1 + uses: actions/checkout@v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' sparse-checkout: '.github/actions/' @@ -166,7 +166,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5.0.1 + uses: actions/checkout@v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' sparse-checkout: '.github/actions/' diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index d78e2d8e59..8b49e0b7cd 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -50,7 +50,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@v5.0.1 + uses: actions/checkout@v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' diff --git a/resources/build/pr-build-status/pr-build-status.prefix.yml b/resources/build/pr-build-status/pr-build-status.prefix.yml index cbeba112a1..cb4c319c19 100644 --- a/resources/build/pr-build-status/pr-build-status.prefix.yml +++ b/resources/build/pr-build-status/pr-build-status.prefix.yml @@ -32,6 +32,6 @@ jobs: steps: - name: Check PR build status id: run_pr_build_status_script - uses: actions/github-script@v7 + uses: actions/github-script@v8.0.0 with: script: | From bb2659e8226ce78e7eb93b9d6361850d77b6e298 Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Tue, 17 Mar 2026 11:52:24 +0700 Subject: [PATCH 16/36] fix(common): Fix labeler.yml syntax --- .github/labeler.yml | 195 ++++++++++++++++++++++++++++++++------------ 1 file changed, 141 insertions(+), 54 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index fc37bf64b7..2549340624 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -5,86 +5,173 @@ # common ones. The others are commented out. There is still some variance between # folder names and labels; consider this documentation of that ;-) -docs: docs/** +docs: +- changed-files: + - any-glob-to-any-file: 'docs/**' # # Add labels based on changed files using actions/labeler # android/: - - android/** - - resources/teamcity/includes/** - - resources/teamcity/android/** -android/app/: android/KMAPro/** -android/engine/: android/KMEA/** -android/samples/: android/Samples/** + - changed-files: + - any-glob-to-any-file: + - android/** + - resources/teamcity/includes/** + - resources/teamcity/android/** +android/app/: + - changed-files: + - any-glob-to-any-file: + - android/KMAPro/** +android/engine/: + - changed-files: + - any-glob-to-any-file: + - android/KMEA/** +android/samples/: + - changed-files: + - any-glob-to-any-file: + - android/Samples/** common/: - - common/** - - resources/teamcity/includes/** - - resources/teamcity/common/** -common/web/: common/web/** + - changed-files: + - any-glob-to-any-file: + - common/** + - resources/teamcity/includes/** + - resources/teamcity/common/** +common/web/: + - changed-files: + - any-glob-to-any-file: + - common/web/** core/: - - core/** - - resources/teamcity/includes/** - - resources/teamcity/core/** + - changed-files: + - any-glob-to-any-file: + - core/** + - resources/teamcity/includes/** + - resources/teamcity/core/** developer/: - - developer/** - - resources/teamcity/includes/** - - resources/teamcity/developer/** + - changed-files: + - any-glob-to-any-file: + - developer/** + - resources/teamcity/includes/** + - resources/teamcity/developer/** developer/compilers/: - - developer/src/kmc/** - - developer/src/kmcmplib/** - - developer/src/kmc-*/** + - changed-files: + - any-glob-to-any-file: + - developer/src/kmc/** + - developer/src/kmcmplib/** + - developer/src/kmc-*/** developer/ide/: - - developer/src/server/** - - developer/src/tike/** + - changed-files: + - any-glob-to-any-file: + - developer/src/server/** + - developer/src/tike/** ios/: - - ios/** - - resources/teamcity/includes/** - - resources/teamcity/ios/** -ios/app/: ios/keyman/** -ios/engine/: ios/engine/** -ios/samples/: ios/samples/** + - changed-files: + - any-glob-to-any-file: + - ios/** + - resources/teamcity/includes/** + - resources/teamcity/ios/** +ios/app/: + - changed-files: + - any-glob-to-any-file: + - ios/keyman/** +ios/engine/: + - changed-files: + - any-glob-to-any-file: + - ios/engine/** +ios/samples/: + - changed-files: + - any-glob-to-any-file: + - ios/samples/** linux/: - - linux/** - - resources/teamcity/includes/** - - resources/teamcity/linux/** -linux/config/: linux/keyman-config/** -linux/engine/: linux/ibus-keyman/** + - changed-files: + - any-glob-to-any-file: + - linux/** + - resources/teamcity/includes/** + - resources/teamcity/linux/** +linux/config/: + - changed-files: + - any-glob-to-any-file: + - linux/keyman-config/** +linux/engine/: + - changed-files: + - any-glob-to-any-file: + - linux/ibus-keyman/** mac/: - - mac/** - - resources/teamcity/includes/** - - resources/teamcity/mac/** + - changed-files: + - any-glob-to-any-file: + - mac/** + - resources/teamcity/includes/** + - resources/teamcity/mac/** # mac/config/: # mac/engine/: mac/** -oem/: oem/** -oem/fv/: oem/firstvoices/** -oem/fv/android/: oem/firstvoices/android/** -oem/fv/ios/: oem/firstvoices/ios/** -oem/fv/windows/: oem/firstvoices/windows/** +oem/: + - changed-files: + - any-glob-to-any-file: + - oem/** +oem/fv/: + - changed-files: + - any-glob-to-any-file: + - oem/firstvoices/** +oem/fv/android/: + - changed-files: + - any-glob-to-any-file: + - oem/firstvoices/android/** +oem/fv/ios/: + - changed-files: + - any-glob-to-any-file: + - oem/firstvoices/ios/** +oem/fv/windows/: + - changed-files: + - any-glob-to-any-file: + - oem/firstvoices/windows/** -resources/: resources/** +resources/: + - changed-files: + - any-glob-to-any-file: + - resources/** web/: - - web/** - - resources/teamcity/includes/** - - resources/teamcity/web/** + - changed-files: + - any-glob-to-any-file: + - web/** + - resources/teamcity/includes/** + - resources/teamcity/web/** # web/bookmarklet/ -web/engine/: web/source/** -web/ui/: web/source/kmwui* -web/samples/: web/samples/** -web/predictive-text/: web/src/engine/predictive-text/** +web/engine/: + - changed-files: + - any-glob-to-any-file: + - web/source/** +web/ui/: + - changed-files: + - any-glob-to-any-file: + - web/source/kmwui* +web/samples/: + - changed-files: + - any-glob-to-any-file: + - web/samples/** +web/predictive-text/: + - changed-files: + - any-glob-to-any-file: + - web/src/engine/predictive-text/** windows/: - - windows/** - - resources/teamcity/includes/** - - resources/teamcity/windows/** -windows/config/: windows/src/desktop/** -windows/engine/: windows/src/engine/** + - changed-files: + - any-glob-to-any-file: + - windows/** + - resources/teamcity/includes/** + - resources/teamcity/windows/** +windows/config/: + - changed-files: + - any-glob-to-any-file: + - windows/src/desktop/** +windows/engine/: + - changed-files: + - any-glob-to-any-file: + - windows/src/engine/** From 2a24db1819cb7862859cebeabb410b84e842e8d8 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 17 Mar 2026 09:36:30 +0100 Subject: [PATCH 17/36] maint(mac): search for brew-installed rsync Fixes: #15764 Test-bot: skip Build-bot: skip --- resources/teamcity/includes/tc-helpers.inc.sh | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/resources/teamcity/includes/tc-helpers.inc.sh b/resources/teamcity/includes/tc-helpers.inc.sh index b4c62826e1..6f704ef1f4 100644 --- a/resources/teamcity/includes/tc-helpers.inc.sh +++ b/resources/teamcity/includes/tc-helpers.inc.sh @@ -65,8 +65,26 @@ _tc_rsync() { else local RSYNC=rsync if builder_is_macos; then + # We need to look for a newer version of rsync because the version bundled + # with macos does not work correctly; we assume that this is installed + # with Homebrew. On Intel, this is found in /usr/local/bin, and on M1, it + # is found in /opt/homebrew/bin. + RSYNC=/usr/local/bin/rsync - [[ -f /opt/homebrew/bin/rsync ]] && RSYNC=/opt/homebrew/bin/rsync + [[ -x /opt/homebrew/bin/rsync ]] && RSYNC=/opt/homebrew/bin/rsync + + # On build agents, we unlink rsync from homebrew, because otherwise xcode + # builds which use rsync internally fail, so we need to specify the full + # path to rsync. Generally, rsync is not needed on developer machines as + # this is only used for release builds from the build agents. #15764 + if [[ ! -x "${RSYNC}" ]]; then + # Try and find the rsync binary with `brew list`. It should be something + # like `/opt/homebrew/Cellar/rsync/3.4.1/bin/rsync` + RSYNC="$(brew list rsync | grep /bin/rsync\$)" + if [[ ! -x "${RSYNC}" ]]; then + builder_die "Could not find Homebrew-installed rsync anywhere" + fi + fi fi ${RSYNC} "${rsync_args[@]}" From 6be3b2ad114c8c99daba8ec8ea11248c8ff01799 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Tue, 17 Mar 2026 13:01:23 -0500 Subject: [PATCH 18/36] auto: increment master version to 19.0.218 Test-bot: skip Build-bot: skip --- HISTORY.md | 4 ++++ VERSION.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 0973f37105..d66bda7d5b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # Keyman Version History +## 19.0.217 alpha 2026-03-17 + +* maint(mac): search for brew-installed rsync (#15765) + ## 19.0.216 alpha 2026-03-16 * docs: tweak walkthrough content (#15712) diff --git a/VERSION.md b/VERSION.md index 45ab25dda9..1b58d923b5 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.217 \ No newline at end of file +19.0.218 \ No newline at end of file From c097d5b7d0e0ec7ce449dd446f55affa24a0d88c Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Wed, 18 Mar 2026 07:32:48 +0700 Subject: [PATCH 19/36] fix(common): pin sha versions --- .../actions/build-binary-packages/action.yml | 4 ++-- .github/workflows/api-verification.yml | 6 +++--- .../workflows/build-test-publish-docker.yml | 4 ++-- .github/workflows/core-arm64-windows-test.yml | 4 ++-- .github/workflows/crowdin.yml | 2 +- .github/workflows/deb-packaging.yml | 18 +++++++++--------- .github/workflows/labeler.yml | 2 +- .github/workflows/npm-publish.yml | 2 +- .github/workflows/pr-build-status.yml | 2 +- 9 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/actions/build-binary-packages/action.yml b/.github/actions/build-binary-packages/action.yml index 23c01c545a..1483f58e5c 100644 --- a/.github/actions/build-binary-packages/action.yml +++ b/.github/actions/build-binary-packages/action.yml @@ -25,7 +25,7 @@ runs: using: 'composite' steps: - name: Download Artifacts - uses: actions/download-artifact@v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: keyman-srcpkg path: artifacts/keyman-srcpkg @@ -49,7 +49,7 @@ runs: echo '```' >> $GITHUB_STEP_SUMMARY - name: Store binary packages - uses: actions/upload-artifact@v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: keyman-binarypkgs-${{ inputs.dist }}_${{ inputs.arch }} path: | diff --git a/.github/workflows/api-verification.yml b/.github/workflows/api-verification.yml index 79e4f8ea52..56726817dc 100644 --- a/.github/workflows/api-verification.yml +++ b/.github/workflows/api-verification.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Restore artifacts - uses: actions/cache/restore@v5.0.3 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | artifacts @@ -55,7 +55,7 @@ jobs: - name: Checkout if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ steps.environment_step.outputs.GIT_SHA }}' fetch-depth: 0 @@ -84,7 +84,7 @@ jobs: - name: Archive .symbols file if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' && always() - uses: actions/upload-artifact@v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: libkeymancore.symbols path: ${{ github.workspace }}/keyman/linux/debian/tmp/DEBIAN/symbols diff --git a/.github/workflows/build-test-publish-docker.yml b/.github/workflows/build-test-publish-docker.yml index 7ff6bb0495..edb9ebb98f 100644 --- a/.github/workflows/build-test-publish-docker.yml +++ b/.github/workflows/build-test-publish-docker.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Checkout repository id: checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Build Docker images id: build run: | @@ -67,7 +67,7 @@ jobs: steps: - name: Checkout repository id: checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Test Docker images id: test diff --git a/.github/workflows/core-arm64-windows-test.yml b/.github/workflows/core-arm64-windows-test.yml index a818957a7a..0eb4e8f8f6 100644 --- a/.github/workflows/core-arm64-windows-test.yml +++ b/.github/workflows/core-arm64-windows-test.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' @@ -64,7 +64,7 @@ jobs: npm -v node -v - - uses: actions/setup-python@v6.2.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 7294d3e505..d0c14de948 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: crowdin action uses: crowdin/github-action@v2.7.0 diff --git a/.github/workflows/deb-packaging.yml b/.github/workflows/deb-packaging.yml index 169ff651c1..6e7fe54cb3 100644 --- a/.github/workflows/deb-packaging.yml +++ b/.github/workflows/deb-packaging.yml @@ -43,7 +43,7 @@ jobs: PRERELEASE_TAG: ${{ steps.prerelease_tag.outputs.PRERELEASE_TAG }} steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' @@ -121,7 +121,7 @@ jobs: echo "- $(find . -name keyman_\*.dsc)" >> $GITHUB_STEP_SUMMARY - name: Store source package - uses: actions/upload-artifact@v7.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: keyman-srcpkg path: | @@ -140,7 +140,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' sparse-checkout: '.github/actions/' @@ -166,7 +166,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' sparse-checkout: '.github/actions/' @@ -204,13 +204,13 @@ jobs: sudo rm -rf /usr/share/dotnet - name: Download Source Artifacts - uses: actions/download-artifact@v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: keyman-srcpkg path: artifacts - name: Download Binary Artifacts - uses: actions/download-artifact@v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: artifacts pattern: keyman-binarypkgs-* @@ -281,7 +281,7 @@ jobs: echo "::endgroup::" - name: Download Artifacts - uses: actions/download-artifact@v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: keyman-signedpkgs @@ -343,7 +343,7 @@ jobs: steps: - name: Download Artifacts - uses: actions/download-artifact@v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: artifacts pattern: keyman-* @@ -362,7 +362,7 @@ jobs: echo "SKIP_API_CHECK=${{ github.event.client_payload.skipApiCheck }}" >> artifacts/env - name: Cache artifacts - uses: actions/cache/save@v5.0.3 + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | artifacts diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 9a60fc9ee9..ee7fe7d03d 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Update labels based on changed files - uses: actions/labeler@v6.0.1 + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" - name: Update labels based on PR title diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 8b49e0b7cd..551045c056 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -50,7 +50,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: '${{ github.event.client_payload.buildSha }}' diff --git a/.github/workflows/pr-build-status.yml b/.github/workflows/pr-build-status.yml index 487a58ae5f..bd5fe2f46a 100644 --- a/.github/workflows/pr-build-status.yml +++ b/.github/workflows/pr-build-status.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Check PR build status id: run_pr_build_status_script - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | // This code is copied out of resources/build/pr-build-status/pr-build-status.mjs From c60be3711f70ad63b497d22785b4b5ed5e0dff43 Mon Sep 17 00:00:00 2001 From: Darcy Wong Date: Wed, 18 Mar 2026 07:34:53 +0700 Subject: [PATCH 20/36] fix(resources): Pin sha to github-script --- resources/build/pr-build-status/pr-build-status.prefix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/build/pr-build-status/pr-build-status.prefix.yml b/resources/build/pr-build-status/pr-build-status.prefix.yml index cb4c319c19..d82aa9cb77 100644 --- a/resources/build/pr-build-status/pr-build-status.prefix.yml +++ b/resources/build/pr-build-status/pr-build-status.prefix.yml @@ -32,6 +32,6 @@ jobs: steps: - name: Check PR build status id: run_pr_build_status_script - uses: actions/github-script@v8.0.0 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | From a0b7e507b9d34ef85d1ce81bf5fd8dba7adc57ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:35:49 +0000 Subject: [PATCH 21/36] chore(deps-dev): bump flatted from 3.2.5 to 3.4.2 Bumps [flatted](https://github.com/WebReflection/flatted) from 3.2.5 to 3.4.2. - [Commits](https://github.com/WebReflection/flatted/compare/v3.2.5...v3.4.2) --- updated-dependencies: - dependency-name: flatted dependency-version: 3.4.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 84 ++--------------------------------------------- 1 file changed, 3 insertions(+), 81 deletions(-) diff --git a/package-lock.json b/package-lock.json index c05360af53..6da9db9adf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4823,15 +4823,6 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/argparse": { "version": "2.0.1", "dev": true, @@ -5955,15 +5946,6 @@ "version": "1.0.3", "license": "MIT" }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7963,7 +7945,9 @@ } }, "node_modules/flatted": { - "version": "3.2.5", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -9882,15 +9866,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/make-fetch-happen": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", @@ -12895,47 +12870,6 @@ "typescript": ">=4.2.0" } }, - "node_modules/ts-node": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz", - "integrity": "sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "source-map-support": "^0.5.17", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "typescript": ">=2.7" - } - }, - "node_modules/ts-node/node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/tsc-watch": { "version": "4.6.2", "dev": true, @@ -13772,18 +13706,6 @@ "node": ">= 4.0.0" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, From 1806c1baa6123b0ca40fcf13a4cdcca80f27c74d Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 24 Mar 2026 09:40:50 +0100 Subject: [PATCH 22/36] fix(developer): define globalThis for compiled custom lexical models The boilerplate code for custom lexical models has never really been tested. For use in a browser/worker context, we need to define `exports`. The added unit test verifies that the model will build. Test-bot: skip --- .../kmc-model/src/lexical-model-compiler.ts | 1 + .../src/kmc-model/test/compile-model.tests.ts | 7 +++++- .../example.qaa.custom/ExampleCustomModel.ts | 25 +++++++++++++++++++ .../example.qaa.custom.model.ts | 6 +++++ ...man.System.Test.LexicalModelParserTest.pas | 9 +++++-- .../model-ts-parser/assets/custom.model.ts | 2 +- 6 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 developer/src/kmc-model/test/fixtures/example.qaa.custom/ExampleCustomModel.ts create mode 100644 developer/src/kmc-model/test/fixtures/example.qaa.custom/example.qaa.custom.model.ts diff --git a/developer/src/kmc-model/src/lexical-model-compiler.ts b/developer/src/kmc-model/src/lexical-model-compiler.ts index 54b50ba503..9e0a03611a 100644 --- a/developer/src/kmc-model/src/lexical-model-compiler.ts +++ b/developer/src/kmc-model/src/lexical-model-compiler.ts @@ -182,6 +182,7 @@ export class LexicalModelCompiler implements KeymanCompiler { const sources: string[] = modelSource.sources.map(function(source) { return new TextDecoder().decode(callbacks.loadFile(callbacks.path.join(sourcePath, source))); }); + func += `globalThis.exports = globalThis.exports ?? {};\n`; func += this.transpileSources(sources).join('\n'); func += `LMLayerWorker.loadModel(new ${modelSource.rootClass}());\n`; break; diff --git a/developer/src/kmc-model/test/compile-model.tests.ts b/developer/src/kmc-model/test/compile-model.tests.ts index 5c6a8ab104..ac7baa373e 100644 --- a/developer/src/kmc-model/test/compile-model.tests.ts +++ b/developer/src/kmc-model/test/compile-model.tests.ts @@ -19,6 +19,7 @@ describe('LexicalModelCompiler', function () { 'example.qaa.wordbreaker', 'example.qaa.joinwordbreaker', 'example.qaa.scriptusesspaces', + 'example.qaa.custom', ]; for (const modelID of MODELS) { @@ -37,7 +38,11 @@ describe('LexicalModelCompiler', function () { assert.isFalse(compilation.hasSyntaxError, 'model code had syntax error'); assert.isNull(compilation.error, `compilation error: ${compilation.error}`); - assert.equal(compilation.modelConstructorName, 'TrieModel'); + if(modelID == 'example.qaa.custom') { + assert.isNull(compilation.modelConstructorName); + } else { + assert.equal(compilation.modelConstructorName, 'TrieModel'); + } }); } }); diff --git a/developer/src/kmc-model/test/fixtures/example.qaa.custom/ExampleCustomModel.ts b/developer/src/kmc-model/test/fixtures/example.qaa.custom/ExampleCustomModel.ts new file mode 100644 index 0000000000..6cd95e8a35 --- /dev/null +++ b/developer/src/kmc-model/test/fixtures/example.qaa.custom/ExampleCustomModel.ts @@ -0,0 +1,25 @@ +import { LexicalModelTypes } from '@keymanapp/common-types'; + +export class ExampleCustomModel implements LexicalModelTypes.LexicalModel { + configure(capabilities: LexicalModelTypes.Capabilities): LexicalModelTypes.Configuration { + return { + leftContextCodePoints: 16, + rightContextCodePoints: 0, + wordbreaksAfterSuggestions: false, + } + } + + languageUsesCasing: boolean = true; + + predict(transform: LexicalModelTypes.Transform, context: LexicalModelTypes.Context): LexicalModelTypes.Distribution { + if(transform.deleteLeft == 0 && context.left.endsWith('te') && transform.insert == 'h') { + return [ + { p: 0.3, sample: { displayAs: 'the', transform: { deleteLeft: 2, insert: 'the' }, tag: 'correction' } }, + { p: 0.2, sample: { displayAs: 'them', transform: { deleteLeft: 2, insert: 'them' }, tag: 'correction' } }, + { p: 0.1, sample: { displayAs: 'tee hee', transform: { deleteLeft: 2, insert: 'tee hee' }, tag: 'correction' } }, + ]; + } else { + return []; + } + } +} diff --git a/developer/src/kmc-model/test/fixtures/example.qaa.custom/example.qaa.custom.model.ts b/developer/src/kmc-model/test/fixtures/example.qaa.custom/example.qaa.custom.model.ts new file mode 100644 index 0000000000..8f1d188f9d --- /dev/null +++ b/developer/src/kmc-model/test/fixtures/example.qaa.custom/example.qaa.custom.model.ts @@ -0,0 +1,6 @@ +const source: LexicalModelSource = { + format: 'custom-1.0', + rootClass: 'ExampleCustomModel', + sources: ['ExampleCustomModel.ts'], +}; +export default source; \ No newline at end of file diff --git a/developer/src/test/auto/model-ts-parser/Keyman.System.Test.LexicalModelParserTest.pas b/developer/src/test/auto/model-ts-parser/Keyman.System.Test.LexicalModelParserTest.pas index 11dcd5a4a9..b26fb863f8 100644 --- a/developer/src/test/auto/model-ts-parser/Keyman.System.Test.LexicalModelParserTest.pas +++ b/developer/src/test/auto/model-ts-parser/Keyman.System.Test.LexicalModelParserTest.pas @@ -91,12 +91,17 @@ var begin lm := TLexicalModelParser.Create(m.Text); try + // TODO: LexicalModelParser does not support `rootClass` which is needed for + // custom lexical models, and the property `Wordlists` is inappropriate for + // the list of sources files. This is part of a bigger project for better + // custom model support in TIKE + // + // For now, this test excludes the `rootClass` property in order to pass lm.Comment := 'Testing'; lm.Format := lmfCustom10; lm.WordBreaker := lmwbAscii; lm.Wordlists.Clear; - lm.Wordlists.Add('foo.tsv'); - lm.Wordlists.Add('bar.tsv'); + lm.Wordlists.Add('CustomModel.ts'); Assert.AreEqual(mcustom.Text.Trim, lm.Text.Trim); // Ignoring whitespace before/after finally lm.Free; diff --git a/developer/src/test/auto/model-ts-parser/assets/custom.model.ts b/developer/src/test/auto/model-ts-parser/assets/custom.model.ts index 64e8f8e122..c22dfe0a1a 100644 --- a/developer/src/test/auto/model-ts-parser/assets/custom.model.ts +++ b/developer/src/test/auto/model-ts-parser/assets/custom.model.ts @@ -2,7 +2,7 @@ const source: LexicalModelSource = { format: 'custom-1.0', wordBreaker: 'ascii', - sources: ['foo.tsv', 'bar.tsv'] + sources: ['CustomModel.ts'] }; export default source; From aa36cfe9617b49779b59b5c2bb318da2f97fe271 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 24 Mar 2026 12:43:11 +0100 Subject: [PATCH 23/36] fix(core): address code review comments --- core/src/ldml/ldml_processor.cpp | 19 ++++++------------- core/src/state.hpp | 2 +- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/core/src/ldml/ldml_processor.cpp b/core/src/ldml/ldml_processor.cpp index 031669fbe5..e79a1f90be 100644 --- a/core/src/ldml/ldml_processor.cpp +++ b/core/src/ldml/ldml_processor.cpp @@ -179,20 +179,13 @@ ldml_processor::process_event( ldml_state.clear(); try { - switch (vk) { - // Currently, only one VK gets special treatment. - // Special handling for backspace VK - case KM_CORE_VKEY_BKSP: + if (vk == KM_CORE_VKEY_BKSP) { process_backspace(ldml_state); - break; - default: - // all other VKs - if (is_key_down) { - process_key_down(ldml_state); - } else { - process_key_up(ldml_state); - } - } // end of switch + } else if (is_key_down) { + process_key_down(ldml_state); + } else { + process_key_up(ldml_state); + } // all key-up and key-down events end up here. // commit the ldml state into the core state ldml_state.commit(); diff --git a/core/src/state.hpp b/core/src/state.hpp index 8e6584b05c..0cf58fb614 100644 --- a/core/src/state.hpp +++ b/core/src/state.hpp @@ -187,7 +187,7 @@ public: * whether or not the keydown handled it internally. Therefore this * flag exists. * - * Only used when processing KM_CORE_VKEY_BKSP. + * Only used when processing KM_CORE_VKEY_BKSP with LDML keyboards. */ void set_backspace_handled_internally(bool handled) { _backspace_handled_internally = handled; } bool backspace_handled_internally() const { return _backspace_handled_internally; } From fe84bab1d391f1be4d2930d6bbfcf12aa4743d18 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Tue, 24 Mar 2026 13:01:46 -0500 Subject: [PATCH 24/36] auto: increment master version to 19.0.219 Test-bot: skip Build-bot: skip --- HISTORY.md | 4 ++++ VERSION.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index d66bda7d5b..d4323e5999 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # Keyman Version History +## 19.0.218 alpha 2026-03-24 + +* fix(developer): define globalThis for compiled custom lexical models (#15777) + ## 19.0.217 alpha 2026-03-17 * maint(mac): search for brew-installed rsync (#15765) diff --git a/VERSION.md b/VERSION.md index 1b58d923b5..8f605e49c0 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.218 \ No newline at end of file +19.0.219 \ No newline at end of file From b5aa0a86dd3830a3744bfbce3ec547f4cf4ef546 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Wed, 25 Mar 2026 13:01:51 -0500 Subject: [PATCH 25/36] auto: increment master version to 19.0.220 Test-bot: skip Build-bot: skip --- HISTORY.md | 5 +++++ VERSION.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index d4323e5999..d067124d74 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,10 @@ # Keyman Version History +## 19.0.219 alpha 2026-03-25 + +* maint(common): Update GitHub actions for Node 24 (#15762) +* fix(core): fix keydown/up handling for LDML keyboards (#15609) + ## 19.0.218 alpha 2026-03-24 * fix(developer): define globalThis for compiled custom lexical models (#15777) diff --git a/VERSION.md b/VERSION.md index 8f605e49c0..d3129b50b7 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.219 \ No newline at end of file +19.0.220 \ No newline at end of file From 2ece72e2f3f2af4491f12068849c011a8780d33a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:27:04 +0000 Subject: [PATCH 26/36] chore(deps): bump picomatch from 2.3.1 to 2.3.2 Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2. - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2) --- updated-dependencies: - dependency-name: picomatch dependency-version: 2.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 87 +++-------------------------------------------- 1 file changed, 4 insertions(+), 83 deletions(-) diff --git a/package-lock.json b/package-lock.json index c05360af53..dd7268feda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4823,15 +4823,6 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/argparse": { "version": "2.0.1", "dev": true, @@ -5955,15 +5946,6 @@ "version": "1.0.3", "license": "MIT" }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9882,15 +9864,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/make-fetch-happen": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", @@ -11288,10 +11261,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -12895,47 +12869,6 @@ "typescript": ">=4.2.0" } }, - "node_modules/ts-node": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz", - "integrity": "sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "source-map-support": "^0.5.17", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "typescript": ">=2.7" - } - }, - "node_modules/ts-node/node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/tsc-watch": { "version": "4.6.2", "dev": true, @@ -13772,18 +13705,6 @@ "node": ">= 4.0.0" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, From fb8dae50906a1f257d81fdc9478bdc3cbafe314f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:39:34 +0000 Subject: [PATCH 27/36] chore(deps): bump picomatch Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.3 to 4.0.4. - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4) --- updated-dependencies: - dependency-name: picomatch dependency-version: 4.0.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../src/win32/trayicon/addon-src/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/developer/src/server/src/win32/trayicon/addon-src/package-lock.json b/developer/src/server/src/win32/trayicon/addon-src/package-lock.json index 3b320611f1..08befe8d71 100644 --- a/developer/src/server/src/win32/trayicon/addon-src/package-lock.json +++ b/developer/src/server/src/win32/trayicon/addon-src/package-lock.json @@ -540,9 +540,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "engines": { "node": ">=12" }, @@ -1099,9 +1099,9 @@ } }, "picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==" }, "proc-log": { "version": "6.1.0", From b22b036f6b997daf8a30922bd3c627f86f7ea3e9 Mon Sep 17 00:00:00 2001 From: sgschantz Date: Thu, 22 Jan 2026 15:11:34 -0500 Subject: [PATCH 28/36] maint(mac): upgrade to Xcode 26 change cocoa-sentry version to one compatible with Xcode 26 --- mac/Keyman4MacIM/Podfile | 6 +++--- mac/Keyman4MacIM/Podfile.lock | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/mac/Keyman4MacIM/Podfile b/mac/Keyman4MacIM/Podfile index a5c62be447..9f797cfd79 100644 --- a/mac/Keyman4MacIM/Podfile +++ b/mac/Keyman4MacIM/Podfile @@ -1,5 +1,5 @@ # Uncomment the next line to define a global platform for your project -platform :osx, '10.13' +platform :osx, '11.0' use_frameworks! target 'Keyman' do @@ -7,11 +7,11 @@ target 'Keyman' do # use_frameworks! # Pods for Keyman - pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '8.38.0' + pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '8.57.3' target 'KeymanTests' do inherit! :search_paths - pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '8.38.0' + pod 'Sentry', :git => 'https://github.com/getsentry/sentry-cocoa.git', :tag => '8.57.3' use_frameworks! # Pods for testing end diff --git a/mac/Keyman4MacIM/Podfile.lock b/mac/Keyman4MacIM/Podfile.lock index 004e0acc65..5c349d43aa 100644 --- a/mac/Keyman4MacIM/Podfile.lock +++ b/mac/Keyman4MacIM/Podfile.lock @@ -1,24 +1,24 @@ PODS: - - Sentry (8.38.0-beta.1): - - Sentry/Core (= 8.38.0-beta.1) - - Sentry/Core (8.38.0-beta.1) + - Sentry (8.57.3): + - Sentry/Core (= 8.57.3) + - Sentry/Core (8.57.3) DEPENDENCIES: - - Sentry (from `https://github.com/getsentry/sentry-cocoa.git`, tag `8.38.0`) + - Sentry (from `https://github.com/getsentry/sentry-cocoa.git`, tag `8.57.3`) EXTERNAL SOURCES: Sentry: :git: https://github.com/getsentry/sentry-cocoa.git - :tag: 8.38.0 + :tag: 8.57.3 CHECKOUT OPTIONS: Sentry: :git: https://github.com/getsentry/sentry-cocoa.git - :tag: 8.38.0 + :tag: 8.57.3 SPEC CHECKSUMS: - Sentry: 4d6027fbfde9ddc35e5c368292843097d039db5f + Sentry: c643eb180df401dd8c734c5036ddd9dd9218daa6 -PODFILE CHECKSUM: 19b128c35d9c5e59f90d09522c053de65096696a +PODFILE CHECKSUM: d45d4bde6c75c2c91314777c88c421e5e604d84d COCOAPODS: 1.15.2 From 7f203cd6c7c9256e8ca5db255b5f67e2db302070 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Thu, 26 Mar 2026 06:27:04 -0400 Subject: [PATCH 29/36] fix(mac): removed unused code and extra logging --- .../Keyman4MacIM/KMInputMethodEventHandler.m | 66 ++----------------- 1 file changed, 5 insertions(+), 61 deletions(-) diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m index 09276a3143..9cf29e5b89 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodEventHandler.m @@ -616,28 +616,6 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; return doesMatch; } -/** - * Check whether the string to be deleted is part of the same cluster as the character in the context that precedes it. - * This function made not be needed, using `precededBySurrogatePair` instead. - */ --(BOOL) deletionWillReplacePartOfCluster: (NSUInteger)deletionLocation precedingCharacterLocation: (NSUInteger)precedingLocation context:(NSString*) context { - // NSString objects hold UTF-16 characters, so a single unicode composed character - // or grapheme cluster may occupy a range of NSString indices instead of a single character. - // This includes base and combining characters potentially composed of surrogate pairs. - NSRange firstDeletionTargetClusterRange = [context rangeOfComposedCharacterSequenceAtIndex: deletionLocation]; - - // get range of the preceding cluster in the context - NSRange precedingClusterRange = [context rangeOfComposedCharacterSequenceAtIndex: precedingLocation]; - - NSString *firstFullCharacterToDelete = [context substringWithRange:firstDeletionTargetClusterRange]; - NSString *precedingFullCharacter = [context substringWithRange:precedingClusterRange]; - os_log_debug([KMLogs keyTraceLog], "firstDeletionTargetCharacterRange: %{public}@, deletionCharacter: %{public}@, precedingCharacterRange %{public}@, precedingCharacter: %{public}@", NSStringFromRange(firstDeletionTargetClusterRange), firstFullCharacterToDelete, NSStringFromRange(precedingClusterRange), precedingFullCharacter); - - // true when the first character to delete and the preceding character - // from the context are part of the same grapheme cluster - return NSEqualRanges(firstDeletionTargetClusterRange, precedingClusterRange); -} - /** * 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. @@ -647,16 +625,15 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; unichar precedingCharacter = [context characterAtIndex:precedingLocation]; if (CFStringIsSurrogateHighCharacter(precedingCharacter)) { - // preceding character is high character - // this is not expected from Keyman Core; write to log but return false + // preceding character is high character -- unexpected from Keyman Core + // write to log and return false precedingCharacterIsLowSurrogate = false; - NSString *message = [NSString stringWithFormat:@"High surrogate found for preceding character at %ld", (long)precedingCharacter]; + 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; - NSString *message = [NSString stringWithFormat:@"Low surrogate found for preceding character at %ld", (long)precedingCharacter]; - os_log_debug([KMLogs keyTraceLog], "%{public}@", message); } return precedingCharacterIsLowSurrogate; @@ -714,9 +691,7 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; // verify that preceding characters comprise a surrogate pair if ((CFStringIsSurrogateHighCharacter(highCharacter)) && (CFStringIsSurrogateLowCharacter(lowCharacter))) { - NSString *message = [NSString stringWithFormat:@"Replacement string containing surrogate %@", replacementString]; - os_log_debug([KMLogs keyTraceLog], "%{public}@", message); - [KMSentryHelper addDebugBreadCrumb:@"event" message:message]; + // 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); @@ -733,37 +708,6 @@ CGEventSourceRef _sourceForGeneratedEvent = nil; return YES; } -/** - * Replace both the text to delete and the cluster preceding it solely with the cluster that precedes it. - * The 'cluster' may be just one character, but if contains surrogate pairs, this ensures that they stay together. - * Returns YES if executing the replace/delete and NO otherwise. - * This function may not be needed, using `deleteByReplacingWithPrecedingSurrogate` instead. - */ - -(BOOL) deleteByReplacingWithPrecedingCluster:(NSUInteger)precedingCharacterLocation deleteLength:(NSUInteger)deleteLength context:(NSString*) context client:(id) client { - - os_log_debug([KMLogs keyTraceLog], "deleteByReplacingWithPrecedingCluster, deletion target is independent of the grapheme cluster that precedes it"); - - // get range of the preceding cluster and the substring from the context - NSRange precedingClusterRange = [context rangeOfComposedCharacterSequenceAtIndex: precedingCharacterLocation]; - NSString *replacementString = [context substringWithRange:precedingClusterRange]; - - // guard: if preceding cluster contains control characters, 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([context length] - replacementLength, 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]; From d97fb2c5bb664a08cb94203612337bb948c20531 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 26 Mar 2026 14:51:37 +0100 Subject: [PATCH 30/36] fix(common): add missing `default` property to keyman-touch-layout.clean.spec.json Matches property in keyman-touch-layout.spec.json. Update to version 2.1.2. Test-bot: skip See-also: keymanapp/api.keyman.com#338 --- common/schemas/keyman-touch-layout/README.md | 4 ++++ .../keyman-touch-layout.clean.spec.json | 13 +++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/common/schemas/keyman-touch-layout/README.md b/common/schemas/keyman-touch-layout/README.md index 1276bed54c..e6984ca142 100644 --- a/common/schemas/keyman-touch-layout/README.md +++ b/common/schemas/keyman-touch-layout/README.md @@ -457,6 +457,10 @@ string") into the appropriate spec format. # .keyman-touch-layout version history +## 2026-03-26 2.1.2 stable +* Add missing 'default' property for longpress (sk) keys to clean spec. No other + changes. + ## 2024-02-23 2.1.1 stable * Loosen `layer.id` requirements to any non-whitespace characters, recommend only alphanumeric, -, _. clean spec enforces this recommendation. diff --git a/common/schemas/keyman-touch-layout/keyman-touch-layout.clean.spec.json b/common/schemas/keyman-touch-layout/keyman-touch-layout.clean.spec.json index 8bdbf7f66a..032d6ee49f 100644 --- a/common/schemas/keyman-touch-layout/keyman-touch-layout.clean.spec.json +++ b/common/schemas/keyman-touch-layout/keyman-touch-layout.clean.spec.json @@ -85,9 +85,9 @@ "text": { "type": "string" }, "layer": { "$ref": "#/definitions/layer-id" }, "nextlayer": { "$ref": "#/definitions/layer-id" }, - "font": { "$ref": "#/definitions/font-spec" }, "fontsize": { "$ref": "#/definitions/fontsize-spec" }, - "sp": { "$ref": "#/definitions/key-sp" }, + "font": { "$ref": "#/definitions/font-spec" }, + "sp": { "$ref" : "#/definitions/key-sp" }, "pad": { "$ref" : "#/definitions/key-pad" }, "width": { "$ref" : "#/definitions/key-width" }, "sk": { "$ref": "#/definitions/subkeys" }, @@ -140,11 +140,12 @@ "text": { "type": "string" }, "layer": { "$ref": "#/definitions/layer-id" }, "nextlayer": { "$ref": "#/definitions/layer-id" }, - "font": { "$ref": "#/definitions/font-spec" }, - "fontsize": { "$ref": "#/definitions/fontsize-spec" }, - "sp": { "$ref": "#/definitions/key-sp" }, + "sp": { "$ref" : "#/definitions/key-sp" }, "pad": { "$ref" : "#/definitions/key-pad" }, - "width": { "$ref" : "#/definitions/key-width" } + "width": { "$ref" : "#/definitions/key-width" }, + "fontsize": { "$ref": "#/definitions/fontsize-spec" }, + "font": { "$ref": "#/definitions/font-spec" }, + "default": { "type": "boolean" } }, "required": ["id"], "additionalProperties": false From 7396de816e7db3155b9a95bda050efe8be7b9552 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Thu, 26 Mar 2026 13:01:36 -0500 Subject: [PATCH 31/36] auto: increment master version to 19.0.221 Test-bot: skip Build-bot: skip --- HISTORY.md | 4 ++++ VERSION.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index d067124d74..0f0f3078da 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # Keyman Version History +## 19.0.220 alpha 2026-03-26 + +* fix(mac): improved adherence to backspace rules for compliant apps (#15561) + ## 19.0.219 alpha 2026-03-25 * maint(common): Update GitHub actions for Node 24 (#15762) diff --git a/VERSION.md b/VERSION.md index d3129b50b7..6659914f35 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.220 \ No newline at end of file +19.0.221 \ No newline at end of file From 0205ada4aae7d8b4beb1e93b20af4ae5784c6892 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:20:26 +0000 Subject: [PATCH 32/36] chore(deps): bump brace-expansion Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.4 to 5.0.5. - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.4...v5.0.5) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 5.0.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../src/win32/trayicon/addon-src/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/developer/src/server/src/win32/trayicon/addon-src/package-lock.json b/developer/src/server/src/win32/trayicon/addon-src/package-lock.json index 3b320611f1..5ff05821d4 100644 --- a/developer/src/server/src/win32/trayicon/addon-src/package-lock.json +++ b/developer/src/server/src/win32/trayicon/addon-src/package-lock.json @@ -88,9 +88,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dependencies": { "balanced-match": "^4.0.2" }, @@ -769,9 +769,9 @@ } }, "brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "requires": { "balanced-match": "^4.0.2" } From ba4ff8a868180ac157590d41cd0c0a14a1967ffe Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Fri, 27 Mar 2026 05:43:49 -0500 Subject: [PATCH 33/36] auto: cherry-pick 18.0.249 history to alpha Build-bot: skip Test-bot: skip --- HISTORY.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/HISTORY.md b/HISTORY.md index d067124d74..f3ec65f1ee 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1310,6 +1310,15 @@ * refactor(windows): rename `TKeymanMutex.MutexOwned` to `TakeOwnership` and add `ReleaseOwnership` (#13168) * chore: increment to alpha 19.0 (#13187) +## 18.0.249 stable 2026-03-27 + +* chore(linux): Update debian changelog (#15717) +* chore(linux): fix dependency of Debian test suite (#15734) +* chore(linux): Update debian changelog (#15731) +* maint(ios): Upgrade Sentry to 8.58.0 to support XCode 26 (#15755) +* fix(developer): define globalThis for compiled custom lexical models (#15778) +* fix(common): add missing `default` property to keyman-touch-layout.clean.spec.json (#15792) + ## 18.0.248 stable 2026-03-12 * fix(ios): Revert Sentry to 8.38.0 to fix FirstVoices crash on startup with 18.0.247 (#15726) From da7333d8c216b9eb9860ffc9d4d55d5647318796 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Fri, 27 Mar 2026 13:02:24 -0500 Subject: [PATCH 34/36] auto: increment master version to 19.0.222 Test-bot: skip Build-bot: skip --- HISTORY.md | 8 ++++++++ VERSION.md | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index e2fc104021..35a5626afe 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,13 @@ # Keyman Version History +## 19.0.221 alpha 2026-03-27 + +* fix(common): add missing `default` property to keyman-touch-layout.clean.spec.json (#15787) +* chore(deps): bump picomatch from 4.0.3 to 4.0.4 in /developer/src/server/src/win32/trayicon/addon-src (#15786) +* chore(deps): bump picomatch from 2.3.1 to 2.3.2 (#15784) +* chore(deps-dev): bump flatted from 3.2.5 to 3.4.2 (#15773) +* chore(deps): bump brace-expansion from 5.0.4 to 5.0.5 in /developer/src/server/src/win32/trayicon/addon-src (#15791) + ## 19.0.220 alpha 2026-03-26 * fix(mac): improved adherence to backspace rules for compliant apps (#15561) diff --git a/VERSION.md b/VERSION.md index 6659914f35..64f8c1199b 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.221 \ No newline at end of file +19.0.222 \ No newline at end of file From b765b5566074578d1ab7496b6d11c672d1333c03 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 7 Apr 2026 15:13:55 +0200 Subject: [PATCH 35/36] fix(linux): fix memory problem This fixes a problem identified by devin.ai: Because of the operator precedence the previous code caused `memmove` to read `sizeof(commit_queue_item) - 1` bytes past the end of `commit_queue`. With this change `memmove` now reads the intended `MAX_QUEUE_SIZE - 1` `commit_queue_items`. Test-bot: skip --- linux/ibus-keyman/src/engine.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/ibus-keyman/src/engine.c b/linux/ibus-keyman/src/engine.c index 4ed09bc0ea..5b1ccb6252 100644 --- a/linux/ibus-keyman/src/engine.c +++ b/linux/ibus-keyman/src/engine.c @@ -782,7 +782,7 @@ commit_current_queue_item(IBusKeymanEngine *keyman) { ibus_engine_forward_key_event(engine, current_item->keyval, current_item->keycode, current_item->state); } keyman->commit_item--; - memmove(keyman->commit_queue, &keyman->commit_queue[1], sizeof(commit_queue_item) * MAX_QUEUE_SIZE - 1); + memmove(keyman->commit_queue, &keyman->commit_queue[1], sizeof(commit_queue_item) * (MAX_QUEUE_SIZE - 1)); initialize_queue_items(keyman, MAX_QUEUE_SIZE - 1, 1); } From f494a10e2406e5c7adeb19ebc8b42f0ea5f83e4a Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Wed, 8 Apr 2026 13:02:18 -0500 Subject: [PATCH 36/36] auto: increment master version to 19.0.223 Test-bot: skip Build-bot: skip --- HISTORY.md | 4 ++++ VERSION.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 35a5626afe..2ac9f698eb 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # Keyman Version History +## 19.0.222 alpha 2026-04-08 + +* fix(linux): fix memory problem (#15823) + ## 19.0.221 alpha 2026-03-27 * fix(common): add missing `default` property to keyman-touch-layout.clean.spec.json (#15787) diff --git a/VERSION.md b/VERSION.md index 64f8c1199b..ac94731296 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.222 \ No newline at end of file +19.0.223 \ No newline at end of file