diff --git a/HISTORY.md b/HISTORY.md index 6cffd0589a..fc8e7f8edd 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,120 @@ # Keyman Version History +## 18.0.175 alpha 2025-01-22 + +* feat(windows): Fixes check now to use SM and LastUpdateCheckTime key on first run. (#12922) +* chore(developer): validate keyboard and model IDs in kmc-generate (#12958) +* fix(developer): use correct terms in kmc-generate lexical model help (#12960) +* fix(developer): use 1.0.0 as default for ldml keyboards in kmc-generate (#12962) +* chore: adds h1 tags (#12967) +* fix(web): prevent warning that occurs when multitapping longpressable modifier keys (#12964) +* chore(developer): add test for basic transform to kmc-generate (#12966) +* fix(android/engine): Revert how keyboard picker menu launches (#12986) + +## 18.0.174 alpha 2025-01-21 + +* chore(developer): validate targets parameter in kmc-generate (#12955) +* fix(developer): support `&displayMap` font in web debugger (#12929) + +## 18.0.173 alpha 2025-01-20 + +* feat(developer): rewrite font data in .kvk from package metadata (#12949) +* docs(ios): document gestures within iOS app help (#12900) +* fix(developer): include developer-utils deps in server build (#12951) + +## 18.0.172 alpha 2025-01-19 + +* chore(common): Add 17.0.333 to version history (#12926) + +## 18.0.171 alpha 2025-01-18 + +* fix(common/web): add StrsItem.isEqual() method (#12868) +* fix(common/web): handle invalid order and tertiary arguments to ElementString.fromString() (#12882) + +## 18.0.170 alpha 2025-01-17 + +* chore(linux): update copyright year (#12918) +* fix(web): patch unit test with execution dependent on autocorrect state (#12940) +* test(common/web): unit tests for element-string (#12811) +* chore(linux): Update debian changelog (#12917) + +## 18.0.169 alpha 2025-01-17 + +* chore(windows): remove `postinstall` state from mermaid diagram (#12923) + +## 18.0.168 alpha 2025-01-16 + +* chore: update macOS environment-variable shell script (#12878) +* fix(android): use main looper to dispatch key events when OSK is hidden (#12871) +* chore(web): integrates predictive-text builds to top level script, reconnects headless tests (#12866) +* chore(ios): Update crowdin strings for Khmer (#12910) +* chore(windows): merge master epic windows updates (#12904) +* fix(developer): detect invalid key ids in touch layout files (#12895) +* chore: use GitHub PR titles when writing HISTORY.md (#12907) +* fix(developer): filter incorrect fonts out of .keyboard_info (#12909) +* change(web): make 'keep' transform pattern match standard suggestion pattern by including the prefix string (#12906) +* chore: Add docker images for building the different platforms (#11397) + +## 18.0.167 alpha 2025-01-15 + +* chore: changes to use https and removes anchor in docs (#12838) +* fix(developer): ensure license parameter is required and add tests (#12879) +* fix(web): Improve predictive-text handling of text wordbreaking transitions (#12864) +* chore(common): Update crowdin strings for Spanish (#12886) +* docs(developer): Clarify square brackets in `U_####[_####]` shortcut (#12876) +* feat: add builder tab-completion script (#12296) +* docs(android): document longpress up-flick to quick-display shortcut (#12899) +* docs(web): fix `adding-keyboards.md` (#12890) + +## 18.0.166 alpha 2025-01-10 + +* fix(web): numpad + and - with zoom shortcut use (#12865) + +## 18.0.165 alpha 2025-01-09 + +* feat(web): add KeymanWeb API for auto-correct (#12857) +* chore(web): disable auto-correct (#12858) +* refactor(web): modernize `web/ci.sh` script (#12862) + +## 18.0.164 alpha 2025-01-08 + +* fix(windows): add language and close button fit inside pop-window (#12855) +* chore(windows): add bcp47 code and tips to diagnostic (#12843) +* fix(android/engine): Cleanup KMManager API keys and calls (#12861) +* docs(developer,mac): add playable video links to the help documentation (#12828) + +## 18.0.163 alpha 2025-01-06 + +* docs: clarify that sections in KMX file might not be aligned (#12856) + +## 18.0.162 alpha 2024-12-19 + +* fix(mac): standardize interaction between OSK and physical keyboard (#12836) + +## 18.0.161 alpha 2024-12-17 + +* fix(mac): OSK layers displayed consistently for hardware and OSK modifiers (#12829) + +## 18.0.160 alpha 2024-12-16 + +* feat(ios): configurable keyboard height (#12571) +* chore(common/web): remove .c8rc.json from types and merge exclusions into package.json (#12813) +* fix(android/engine): Initialize index when resuming KeyboardPicker (#12832) + +## 18.0.159 alpha 2024-12-13 + +* chore(core): remove meson warnings for wasm builds (#12827) +* chore(linux): Update debian changelog (#12023) + +## 18.0.158 alpha 2024-12-11 + +* fix(linux): pushing of updated changelog branch (#12818) +* fix(linux): work around Lintian errors (#12815) + +## 18.0.157 alpha 2024-12-10 + +* test(common/web): unit tests for kvk-file-writer (#12734) + ## 18.0.156 alpha 2024-12-09 * fix(developer): remove platforms from kmc-generate LM readme (#12803) @@ -153,7 +268,7 @@ ## 18.0.134 alpha 2024-11-04 -* (#12606) +* fix(developer): ldml don't allow a uset as right-hand-side variable (#12606) ## 18.0.133 alpha 2024-11-01 @@ -876,7 +991,7 @@ ## 18.0.41 alpha 2024-05-22 * fix(developer): handle `KM_CORE_IT_INVALIDATE_CONTEXT` in debugger (#11488) -* chore(linux): Trigger GHA packaging for stable builds :cherries: (#11495) +* chore(linux): Trigger GHA packaging for stable builds (#11495) * chore(android,mac,windows): Update crowdin strings for DE (#11497) * feat(web): custom infrastructure for @web/test-runner use (#11403) * chore(web): conversion of lm-worker browser-test for @web/test-runner use (#11404) @@ -1072,6 +1187,17 @@ * chore(common): move to 18.0 alpha (#10713) * chore: move to 18.0 alpha +## 17.0.333 stable 2025-01-16 + +* fix(core): permanently disable logging (#12674) +* fix(linux): pushing of updated changelog branch (#12819) +* fix(linux): work around Lintian errors (#12817) +* fix(core): implement ldml_processor::get_key_list() (#12816) +* chore(linux): Update debian changelog (#12022) +* fix(android): use main looper to dispatch key events when OSK is hidden (#12875) +* chore: use GitHub PR titles when writing HISTORY.md (#12908) +* fix(developer): filter incorrect fonts out of .keyboard_info (#12913) + ## 17.0.332 stable 2024-11-06 * fix(developer): create Server config directory before options save (#12609) diff --git a/VERSION.md b/VERSION.md index 4f4515d945..99dea35f58 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -18.0.157 \ No newline at end of file +18.0.176 \ No newline at end of file diff --git a/android/KMAPro/kMAPro/src/main/res/values-es-rES/strings.xml b/android/KMAPro/kMAPro/src/main/res/values-es-rES/strings.xml index 836362fadb..850cdd027c 100644 --- a/android/KMAPro/kMAPro/src/main/res/values-es-rES/strings.xml +++ b/android/KMAPro/kMAPro/src/main/res/values-es-rES/strings.xml @@ -26,7 +26,6 @@ Escriba aquí… - Tamaño del texto: %1$d @@ -34,6 +33,8 @@ Agrandar texto Achicar texto + + Control deslizante de tamaño de texto \nTodo el texto será borrado\n @@ -52,6 +53,7 @@ Para instalar los paquetes de teclado, permita que Keyman acceda al almacenamiento externo. Se ha denegado la solicitud de permiso de almacenamiento. La instalación del paquete de teclado podría fallar + Fallo en la solicitud de permiso de almacenamiento. Pruebe Configuración de Keyman - Instalar desde archivo local Configuración @@ -66,6 +68,8 @@ Ajustar altura del teclado + Ajustar demora de pulsación larga + Leyenda del teclado Teclado @@ -85,11 +89,11 @@ No mostrar leyenda en la barra espaciadora Vibrar al escribir - + Mostrar siempre el banner - + Próximamente implementado - + Cuando esté desactivado, sólo mostrado cuando el texto predictivo esté habilitado Permitir el envío de informes de fallos a través de la red @@ -121,6 +125,14 @@ Rotar el dispositivo para ajustar a modo vertical u horizontal Restablecer valores predeterminados + + Tiempo de retraso: %1$.1f segundos + + Tiempo de retraso más largo + + Tiempo de retraso más corto + + Deslizador de tiempo de retraso de pulsación larga Buscar o escribir una dirección web diff --git a/android/KMEA/app/src/main/assets/android-host.js b/android/KMEA/app/src/main/assets/android-host.js index 21f783ffc5..5a25367223 100644 --- a/android/KMEA/app/src/main/assets/android-host.js +++ b/android/KMEA/app/src/main/assets/android-host.js @@ -241,11 +241,11 @@ function enableSuggestions(model, suggestionType) { [true, true, false], // 2 = SuggestionType.PREDICTIONS_WITH_CORRECTIONS [true, true, true], // 3 = SuggestionType.PREDICTIONS_WITH_AUTO_CORRECT ]; - const t = suggestionSettings[suggestionType] + const t = suggestionSettings[suggestionType] ? suggestionSettings[suggestionType] : suggestionSettings[0]; keyman.core.languageProcessor.mayPredict = t[0]; keyman.core.languageProcessor.maySuggest = t[1]; - // keyman.core.languageProcessor.mayAutoCorrect = t[2]; + keyman.core.languageProcessor.mayAutoCorrect = t[2]; registerModel(model); } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index 7d731f2b63..de01e0bcc5 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -31,6 +31,7 @@ import android.content.pm.ApplicationInfo; import android.content.res.Configuration; import android.net.Uri; import android.os.Handler; +import android.os.Looper; import android.util.DisplayMetrics; import android.util.Log; import android.view.GestureDetector; @@ -70,6 +71,10 @@ final class KMKeyboard extends WebView { protected KeyboardType keyboardType = KeyboardType.KEYBOARD_TYPE_UNDEFINED; protected ArrayList javascriptAfterLoad = new ArrayList<>(); + // .getMainLooper() returns the looper associated with the main UI thread. + // https://stackoverflow.com/questions/13974661/runonuithread-vs-looper-getmainlooper-post-in-android + private Handler jsQueuer = new Handler(Looper.getMainLooper()); + private static String currentKeyboard = null; /** @@ -368,7 +373,7 @@ final class KMKeyboard extends WebView { if(this.javascriptAfterLoad.size() > 0) { // Don't call this WebView method on just ANY thread - run it on the main UI thread. // https://stackoverflow.com/a/22611010 - this.postDelayed(new Runnable() { + jsQueuer.postDelayed(new Runnable() { @Override public void run() { StringBuilder allCalls = new StringBuilder(); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index e776476aff..3cfa41c3a0 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -249,7 +249,7 @@ public final class KMManager { public final static String predictionPrefSuffix = ".mayPredict"; public final static String correctionPrefSuffix = ".mayCorrect"; - public final static String autoCorrectionPrefSuffix = ".mayAutoCorect"; + public final static String autoCorrectionPrefSuffix = ".mayAutoCorrect"; // Special override for when the keyboard may have haptic feedback when typing. // haptic feedback disabled for hardware keystrokes @@ -305,11 +305,6 @@ public final class KMManager { public static final String KMKey_LexicalModelVersion = "lmVersion"; public static final String KMKey_LexicalModelPackageFilename = "kmpPackageFilename"; - // DEPRECATED keys - public static final String KMKey_CustomKeyboard = "CustomKeyboard"; - public static final String KMKey_CustomModel = "CustomModel"; - public static final String KMKey_HelpLink = "helpLink"; - // Keyman internal keys protected static final String KMKey_ShouldShowHelpBubble = "ShouldShowHelpBubble"; @@ -1714,26 +1709,6 @@ public final class KMManager { return KeyboardPickerActivity.addKeyboard(context, keyboardInfo); } - // Intend to deprecate in Keyman 15.0 - public static boolean addKeyboard(Context context, HashMap keyboardInfo) { - String packageID = keyboardInfo.get(KMManager.KMKey_PackageID); - String keyboardID = keyboardInfo.get(KMManager.KMKey_KeyboardID); - String keyboardName = keyboardInfo.get(KMManager.KMKey_KeyboardName); - String languageID = keyboardInfo.get(KMManager.KMKey_LanguageID); - String languageName = keyboardInfo.get(KMManager.KMKey_LanguageName); - String version = keyboardInfo.get(KMManager.KMKey_KeyboardVersion); - String helpLink = keyboardInfo.get(KMManager.KMKey_CustomHelpLink); - String kmpLink = MapCompat.getOrDefault(keyboardInfo, KMManager.KMKey_KMPLink, ""); - String font = keyboardInfo.get(KMManager.KMKey_Font); - String oskFont = keyboardInfo.get(KMManager.KMKey_OskFont); - boolean isNewKeyboard = true; - - Keyboard k = new Keyboard(packageID, keyboardID, keyboardName, - languageID, languageName, version, helpLink, kmpLink, - isNewKeyboard, font, oskFont); - return addKeyboard(context, k); - } - public static boolean removeKeyboard(Context context, int position) { return KeyboardPickerActivity.removeKeyboard(context, position); } @@ -2292,6 +2267,7 @@ public final class KMManager { if (kbType == KeyboardType.KEYBOARD_TYPE_SYSTEM) { i.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); } i.putExtra(KMKey_DisplayKeyboardSwitcher, kbType == KeyboardType.KEYBOARD_TYPE_SYSTEM); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java b/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java index 48295f7297..95902bd26e 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KeyboardPickerActivity.java @@ -25,6 +25,7 @@ import com.keyman.engine.util.MapCompat; import android.content.ComponentName; import android.content.Context; +import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; @@ -252,8 +253,17 @@ public final class KeyboardPickerActivity extends BaseActivity { adapter.notifyDataSetChanged(); } - int curKbPos = KeyboardController.getInstance().getKeyboardIndex(KMKeyboard.currentKeyboard()); - setSelection(curKbPos); + + // Determine the index to the current keyboard position to highlight as the selected keyboard + int currentKeyboardIndex; + String currentKeyboardKey = KMKeyboard.currentKeyboard(); + if (currentKeyboardKey == null) { + SharedPreferences prefs = this.getSharedPreferences(this.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); + currentKeyboardIndex = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); + } else { + currentKeyboardIndex = KeyboardController.getInstance().getKeyboardIndex(currentKeyboardKey); + } + setSelection(currentKeyboardIndex); imeList = getIMEList(this); BaseAdapter imeAdapter = (BaseAdapter)imeListAdapter; diff --git a/android/KMEA/app/src/main/res/values-es-rES/strings.xml b/android/KMEA/app/src/main/res/values-es-rES/strings.xml index 11b94984ae..4dbc47302d 100644 --- a/android/KMEA/app/src/main/res/values-es-rES/strings.xml +++ b/android/KMEA/app/src/main/res/values-es-rES/strings.xml @@ -107,7 +107,7 @@ La descarga del diccionario inició en segundo plano El diccionario seleccionado ya se está descargando; ¡por favor, inténtalo de nuevo en un momento! - + La descarga del diccionario ha finalizado. Descargar falló @@ -119,7 +119,7 @@ "¡Todos los recursos están actualizados!" ¡Uno o más recursos no se han actualizado! - + ¡Recursos actualizados con éxito! Versión del diccionario @@ -133,10 +133,15 @@ Teclado %1$s instalado Teclado eliminado - + Habilitar correcciones - + Habilitar predicciones + + Desactivar sugerencias + Sólo predicciones + Predicciones con correcciones + Predicciones con autocorrecciones Diccionario diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java index 59a0237b37..91fc7dd370 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java @@ -126,7 +126,6 @@ public class KMManagerTest { HashMap kmInfo = new HashMap(); kmInfo.put(KMManager.KMKey_PackageID, "khmer_angkor"); kmInfo.put(KMManager.KMKey_KeyboardID, "khmer_angkor"); - kmInfo.put(KMManager.KMKey_CustomKeyboard, "Y"); kmInfo.put(KMManager.KMKey_LanguageID, "km"); kmInfo.put(KMManager.KMKey_KeyboardName, "Khmer Angkor"); kmInfo.put(KMManager.KMKey_LanguageName, "Central Khmer"); diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/view/FunctionalTestHelper.java b/android/KMEA/app/src/test/java/com/keyman/engine/view/FunctionalTestHelper.java index b56a4063eb..0d4b7c81de 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/view/FunctionalTestHelper.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/view/FunctionalTestHelper.java @@ -66,7 +66,21 @@ public class FunctionalTestHelper { Assert.assertEquals(installedKbds.size(),1); - KMManager.addKeyboard(ApplicationProvider.getApplicationContext(),(HashMap) installedKbds.get(0)); + HashMap map = (HashMap)installedKbds.get(0); + Keyboard keyboard = new Keyboard( + map.get(KMManager.KMKey_PackageID), + map.get(KMManager.KMKey_KeyboardID), + map.get(KMManager.KMKey_KeyboardName), + map.get(KMManager.KMKey_LanguageID), + map.get(KMManager.KMKey_LanguageName), + map.get(KMManager.KMKey_KeyboardVersion), + map.get(KMManager.KMKey_CustomHelpLink), + map.get(KMManager.KMKey_KMPLink), + true, + null, + null + ); + KMManager.addKeyboard(ApplicationProvider.getApplicationContext(), keyboard); } diff --git a/android/docs/help/context/gestures.md b/android/docs/help/context/gestures.md index fe5aaf8138..d46ab541f6 100644 --- a/android/docs/help/context/gestures.md +++ b/android/docs/help/context/gestures.md @@ -8,6 +8,8 @@ There are now several gestures available to interact with some keys on the keybo ![](../android_images/touch-hold-ap.png) * **Long press**: Press and hold a key, and after a moment a submenu will appear. Slide the finger to the submenu to select a key. Release the finger, and the highlighted key from the submenu will be output. -* **Flick**: Press and hold a key, and then slide the finger in various directions on a key to reach alternate outputs. The key will animate to show the expected output when the finger gets released. A common flick is to slide down to reach numerals on the top row of the keyboard. -* **Multitap**: Some keys can be pressed repeatedly and rapidly to reach alternate outputs. Tapping rapidly twice on Shift will activate Caps Lock on many keyboards. +* **Flick**: Press and hold a key, and then slide the finger in various directions on a key to reach alternate outputs. The key will animate to show the expected output when the finger gets released. A common flick is to slide down to reach numerals on the top row of the keyboard. + + An up-flick on keys that support longpresses but do not have a defined up-flick will skip the wait and immediately display their longpress submenu. +* **Multitap**: Some keys can be pressed repeatedly and rapidly to reach alternate outputs. Tapping rapidly twice on Shift will activate Caps Lock on many keyboards. \ No newline at end of file diff --git a/common/web/types/.c8rc.json b/common/web/types/.c8rc.json deleted file mode 100644 index f7137ed999..0000000000 --- a/common/web/types/.c8rc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "exclude": [ - "src/keyboard-object.ts", - "src/lexical-model-types.ts", - "src/outputTarget.interface.ts", - "src/*.d.ts", - "src/main.ts", - "src/schemas/*", - "src/schema-validators.ts", - "src/schemas.ts" - ] -} diff --git a/common/web/types/build.sh b/common/web/types/build.sh index 418840d1a8..597962aeee 100755 --- a/common/web/types/build.sh +++ b/common/web/types/build.sh @@ -92,7 +92,7 @@ function do_test() { readonly C8_THRESHOLD=60 - # Excludes are defined in .c8rc.json + # Excludes are defined in package.json c8 -skip-full --reporter=lcov --reporter=text --lines $C8_THRESHOLD --statements $C8_THRESHOLD --branches $C8_THRESHOLD --functions $C8_THRESHOLD mocha ${MOCHA_FLAGS} "${builder_extra_params[@]}" builder_echo warning "Coverage thresholds are currently $C8_THRESHOLD%, which is lower than ideal." builder_echo warning "Please increase threshold in build.sh as test coverage improves." diff --git a/common/web/types/package.json b/common/web/types/package.json index d6efc63503..429dfbcddb 100644 --- a/common/web/types/package.json +++ b/common/web/types/package.json @@ -76,7 +76,13 @@ "src/keyman-touch-layout/keyman-touch-layout-file-writer.ts", "src/osk/osk.ts", "src/schemas/*", - "tests/" + "tests/", + "src/keyboard-object.ts", + "src/outputTarget.interface.ts", + "src/*.d.ts", + "src/main.ts", + "src/schema-validators.ts", + "src/schemas.ts" ] }, "sideEffects": false diff --git a/common/web/types/src/kmx/kmx-plus/element-string.ts b/common/web/types/src/kmx/kmx-plus/element-string.ts index b389107cce..d14c4a9011 100644 --- a/common/web/types/src/kmx/kmx-plus/element-string.ts +++ b/common/web/types/src/kmx/kmx-plus/element-string.ts @@ -19,8 +19,8 @@ export class ElemElement { order: number; // -128 to +127; used only by reorder element values tertiary: number; // -128 to +127; used only by reorder element values flags: ElemElementFlags; - isEqual(a: ElemElement) { - return a.value === this.value && + isEqual(a: ElemElement): boolean { + return a.value.isEqual(this.value) && a.order === this.order && a.tertiary === this.tertiary && a.flags === this.flags; @@ -104,8 +104,8 @@ export class ElementString extends Array { typeFlag |= constants.elem_flags_type_str; } } - elem.order = orders.length ? parseInt(orders[i], 10) : 0; - elem.tertiary = tertiaries.length ? parseInt(tertiaries[i], 10) : 0; + elem.order = orders.length ? this.parseIntOrZero(orders[i]) : 0; + elem.tertiary = tertiaries.length ? this.parseIntOrZero(tertiaries[i]) : 0; elem.flags = ElemElementFlags.none | (ElemElementFlags.type & typeFlag) | (tertiary_bases?.[i] == '1' /* TODO-LDML: or 'true'? */ ? ElemElementFlags.tertiary_base : 0) | @@ -125,5 +125,9 @@ export class ElementString extends Array { } return true; } + private static parseIntOrZero(str: string): number { + const num = parseInt(str, 10); + return !Number.isNaN(num) ? num : 0; + } } ; diff --git a/common/web/types/src/kmx/kmx-plus/kmx-plus.ts b/common/web/types/src/kmx/kmx-plus/kmx-plus.ts index a88688f7ab..d0de2989d0 100644 --- a/common/web/types/src/kmx/kmx-plus/kmx-plus.ts +++ b/common/web/types/src/kmx/kmx-plus/kmx-plus.ts @@ -131,6 +131,10 @@ export class StrsItem { get isOneChar() { return this.char !== undefined; } + + isEqual(a: StrsItem): boolean { + return a.value === this.value && a.char === this.char; + } }; /** diff --git a/common/web/types/tests/kmx/kmx-plus/element-string.tests.ts b/common/web/types/tests/kmx/kmx-plus/element-string.tests.ts new file mode 100644 index 0000000000..49a4c2c954 --- /dev/null +++ b/common/web/types/tests/kmx/kmx-plus/element-string.tests.ts @@ -0,0 +1,498 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Dr Mark C. Sinclair on 2024-12-09 + * + * Test code for element-string.ts + */ + +import 'mocha'; +import { assert } from 'chai'; +import { constants } from '@keymanapp/ldml-keyboard-constants'; +import { ElemElementFlags, ElemElement, ElementString } from '../../../src/kmx/kmx-plus/element-string.js'; +import { StrsItem, UsetItem, Strs, StrsOptions, DependencySections, CharStrsItem, Uset } from '../../../src/kmx/kmx-plus/kmx-plus.js'; +import { UnicodeSet, UnicodeSetParser } from '../../../src/ldml-keyboard/unicodeset-parser-api.js'; +import { ElementParser, ElementSegment, ElementType } from '../../../src/ldml-keyboard/pattern-parser.js'; + +const GOTHIC_A = new StrsItem("𐌰", 0x10330); +const GOTHIC_B = new StrsItem("𐌱", 0x10331); +const GOTHIC_C = new StrsItem("𐌲", 0x10332); +const GOTHIC_D = new StrsItem("𐌳", 0x10333); +const HI_GOTHIC_A = new StrsItem('\ud800', 0xd800); +const LO_GOTHIC_A = new StrsItem('\udf30', 0xdf30); +const GOTHIC_PATTERN = "[𐌰-𐍊]"; +const GOTHIC_STRSITEM = new StrsItem(GOTHIC_PATTERN); +const GOTHIC_SET = new UnicodeSet(GOTHIC_PATTERN, [[0x10330,0x1034A]]); +const GOTHIC_USETITEM = new UsetItem(GOTHIC_SET, GOTHIC_STRSITEM); +const UGARITIC_PATTERN = "[𐎀-𐎟]"; +const UGARITIC_STRSITEM = new StrsItem(UGARITIC_PATTERN); +const UGARITIC_SET = new UnicodeSet(UGARITIC_PATTERN, [[0x10380,0x1039F]]); +const UGARITIC_USETITEM = new UsetItem(UGARITIC_SET, UGARITIC_STRSITEM); + +class TestUnicodeSetParser implements UnicodeSetParser { + parseUnicodeSet = (pattern: string, rangeCount: number) : UnicodeSet | null => GOTHIC_SET; + sizeUnicodeSet = (pattern: string) : number => 1; +}; + +const origElementParserSegment = ElementParser.segment; +let sections: DependencySections = null; + +describe('Test of ElementString file', () => { + describe('Test of ElemElement', () => { + describe('Test of isEqual()', () => { + it('returns true when elems identical', () => { + const one = initElemElement(); + const two = initElemElement(); + assert.isTrue(one.isEqual(two)); + }); + it('returns true when elems are clones', () => { + const one = initElemElement(new StrsItem("𐌰", 0x10330)); + const two = initElemElement(new StrsItem("𐌰", 0x10330)); + assert.isTrue(one.isEqual(two)); + }); + it('returns false when value differs', () => { + const one = initElemElement(GOTHIC_A); + const two = initElemElement(GOTHIC_B); + assert.isFalse(one.isEqual(two)); + }); + it('returns false when order differs', () => { + const one = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 0); + const two = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 1); + assert.isFalse(one.isEqual(two)); + }); + it('returns false when tertiary differs', () => { + const one = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 0, 0); + const two = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 0, 1); + assert.isFalse(one.isEqual(two)); + }); + it('returns false when flags differs', () => { + const one = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 0, 0, ElemElementFlags.none); + const two = initElemElement(GOTHIC_A, GOTHIC_USETITEM, 0, 0, ElemElementFlags.type); + assert.isFalse(one.isEqual(two)); + }); + it('returns true even though uset differs', () => { + const one = initElemElement(GOTHIC_A, GOTHIC_USETITEM); + const two = initElemElement(GOTHIC_A, UGARITIC_USETITEM); + assert.isTrue(one.isEqual(two)); + }); + }); + }); + describe('Test of ElementString class', () => { + beforeEach(() => { + sections = { + strs: new Strs(), + uset: new Uset(), + usetparser: new TestUnicodeSetParser(), + }; + ElementParser.segment = stubElementParserSegment_CodePoint; + }); + afterEach(() => { + ElementParser.segment = origElementParserSegment; + }); + describe('Test of fromStrings()', () => { + it('returns an empty ElementString if source is null', () => { + const es = ElementString.fromStrings({}, null); + assert.deepEqual(es, new ElementString()); + }); + it('can create an ElementString from a string array', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings(sections, ["𐌰", "𐌱", "𐌲"]); + const expected = [ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]; + assert.deepEqual(actual, expected); + }); + it('can create an ElementString from a string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings(sections, "𐌰𐌱𐌲"); + const expected = [ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply order string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + "1 2 3", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 1), + initElemElement(GOTHIC_B, undefined, 2), + initElemElement(GOTHIC_C, undefined, 3), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply single order to all', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + "1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 1), + initElemElement(GOTHIC_B, undefined, 1), + initElemElement(GOTHIC_C, undefined, 1), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle order string that is too short', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + "1 2", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 1), + initElemElement(GOTHIC_B, undefined, 2), + initElemElement(GOTHIC_C, undefined, 0), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle non-number in order string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + "1 A 3", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 1), + initElemElement(GOTHIC_B, undefined, 0), + initElemElement(GOTHIC_C, undefined, 3), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply tertiary string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + "1 2 3", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 1), + initElemElement(GOTHIC_B, undefined, 0, 2), + initElemElement(GOTHIC_C, undefined, 0, 3), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply single tertiary to all', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + "1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 1), + initElemElement(GOTHIC_B, undefined, 0, 1), + initElemElement(GOTHIC_C, undefined, 0, 1), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle tertiary string that is too short', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + "1 2", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 1), + initElemElement(GOTHIC_B, undefined, 0, 2), + initElemElement(GOTHIC_C, undefined, 0, 0), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle non-number in tertiary string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + "1 A 3", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 1), + initElemElement(GOTHIC_B, undefined, 0, 0), + initElemElement(GOTHIC_C, undefined, 0, 3), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply tertiary_base string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + "1 0 1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.tertiary_base), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.none), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.tertiary_base), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply single tertiary_base to all', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + "1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.tertiary_base), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.tertiary_base), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.tertiary_base), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle tertiary_base string that is too short', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + "1 0", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.tertiary_base), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.none), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.none), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply prebase string', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + null, + "1 0 1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.prebase), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.none), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.prebase), + ]; + assert.deepEqual(actual, expected); + }); + it('can apply single prebase to all', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + null, + "1", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.prebase), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.prebase), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.prebase), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle prebase string that is too short', () => { + sections.strs.allocString = stubStrsAllocString_Char; + const actual = ElementString.fromStrings( + sections, + "𐌰𐌱𐌲", + null, + null, + null, + "1 0", + ); + const expected = [ + initElemElement(GOTHIC_A, undefined, 0, 0, ElemElementFlags.prebase), + initElemElement(GOTHIC_B, undefined, 0, 0, ElemElementFlags.none), + initElemElement(GOTHIC_C, undefined, 0, 0, ElemElementFlags.none), + ]; + assert.deepEqual(actual, expected); + }); + it('can create an ElementString from a uset string', () => { + ElementParser.segment = stubElementParserSegment_Uset; + sections.strs.allocString = stubStrsAllocString_Str; + sections.uset.allocUset = stubUsetAllocUset; + const actual = ElementString.fromStrings(sections, "[𐌰-𐍊]"); + const expected = [ + initElemElement( + new StrsItem(''), + GOTHIC_USETITEM, + 0, + 0, + constants.elem_flags_type_uset, + ), + ]; + assert.deepEqual(actual, expected); + }); + it('returns null for an invalid unicode set size', () => { + ElementParser.segment = stubElementParserSegment_Uset; + sections.usetparser.sizeUnicodeSet = (pattern: string) : number => -1; + assert.isNull(ElementString.fromStrings(sections, "[𐌰-𐍊]")); + }); + it('returns null if it cannot parse the unicode set', () => { + ElementParser.segment = stubElementParserSegment_Uset; + sections.usetparser.parseUnicodeSet = (pattern: string, rangeCount: number) : UnicodeSet | null => null; + assert.isNull(ElementString.fromStrings(sections, "[𐌰-𐍊]")); + }); + it('can handle quad strings', () => { + sections.strs.allocString = stubStrsAllocString_Char; + ElementParser.segment = stubElementParserSegment_Escaped; + const actual = ElementString.fromStrings(sections, "\\ud800\\udf30"); + const expected = [ + initElemElement(HI_GOTHIC_A), + initElemElement(LO_GOTHIC_A), + ]; + assert.deepEqual(actual, expected); + }); + it('can handle ElemElement of string type', () => { + sections.strs.allocString = stubStrsAllocString_Str; + const actual = ElementString.fromStrings(sections, ["𐌰𐌱𐌲",]); + const expected = [ + initElemElement( + new StrsItem("𐌰𐌱𐌲"), + undefined, + 0, + 0, + constants.elem_flags_type_str, + ), + ]; + assert.deepEqual(actual, expected); + }); + }); + describe('Test of isEqual()', () => { + it('returns true when ElementStrings are identical', () => { + const es = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]); + assert.isTrue(es.isEqual(es)); + }); + it('returns true when ElementStrings are clones', () => { + const one = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]); + const two = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]); + assert.isTrue(one.isEqual(two)); + }); + it('returns false when ElementStrings are different lengths', () => { + const one = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]); + const two = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + ]); + assert.isFalse(one.isEqual(two)); + }); + it('returns false when ElementStrings have different ElemElements', () => { + const one = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_C), + ]); + const two = initElementString([ + initElemElement(GOTHIC_A), + initElemElement(GOTHIC_B), + initElemElement(GOTHIC_D), + ]); + assert.isFalse(one.isEqual(two)); + }); + }); + describe('Test of parseIntOrZero()', () => { + it('returns a number for a valid string', () => { + const num = ElementString['parseIntOrZero']('1'); + assert.equal(num, 1); + }); + it('returns zero for an invalid string', () => { + const num = ElementString['parseIntOrZero']('A'); + assert.equal(num, 0); + }); + it('returns zero for undefined', () => { + const num = ElementString['parseIntOrZero'](undefined); + assert.equal(num, 0); + }); + it('returns zero for a null string', () => { + const num = ElementString['parseIntOrZero'](null); + assert.equal(num, 0); + }); + }); + }); +}); + +function initElemElement( + value: StrsItem = GOTHIC_A, + uset: UsetItem = undefined, + order: number = 0, + tertiary: number = 0, + flags: ElemElementFlags = ElemElementFlags.none, +): ElemElement { + const ee = new ElemElement(); + ee.value = value; + ee.uset = uset; + ee.order = order; + ee.tertiary = tertiary; + ee.flags = flags; + return ee; +}; + +function initElementString(elemElements: ElemElement[]): ElementString { + const es: ElementString = new ElementString(); + elemElements.forEach((ee) => {es.push(ee)}); + return es; +}; + +function stubStrsAllocString_Char(s?: string, opts?: StrsOptions, sections?: DependencySections): StrsItem { + return new CharStrsItem(s); +}; + +function stubStrsAllocString_Str(s?: string, opts?: StrsOptions, sections?: DependencySections): StrsItem { + return new StrsItem(s); +}; + +function stubElementParserSegment_CodePoint(str: string): ElementSegment[] { + return [...str].map(s => new ElementSegment(s, ElementType.codepoint)); +}; + +function stubElementParserSegment_Uset(str: string): ElementSegment[] { + return [new ElementSegment(str, ElementType.uset)]; +}; + +function stubElementParserSegment_Escaped(str: string): ElementSegment[] { + const strs = str.match(/\\u[0-9a-fA-F]{4}/g); + return strs.map((s) => new ElementSegment(s, ElementType.escaped)); +}; + +function stubUsetAllocUset(set: UnicodeSet, sections: DependencySections) : UsetItem { + return new UsetItem(set, new StrsItem(set.pattern)); +}; + diff --git a/common/web/types/tests/kvk/kvk-file-writer.tests.ts b/common/web/types/tests/kvk/kvk-file-writer.tests.ts new file mode 100644 index 0000000000..b579446cf6 --- /dev/null +++ b/common/web/types/tests/kvk/kvk-file-writer.tests.ts @@ -0,0 +1,325 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Dr Mark C. Sinclair on 2024-12-05 + * + * Test code for kvk-file-writer.ts + */ + +import 'mocha'; +import { assert } from 'chai'; +import { KvkFileWriter } from '../../src/main.js'; +import { VisualKeyboard, + VisualKeyboardKey, + VisualKeyboardFont, + DEFAULT_KVK_FONT +} from '../../src/kvk/visual-keyboard.js'; +import { BUILDER_KVK_FILE, + BUILDER_KVK_HEADER_FLAGS, + BUILDER_KVK_STRING, + BUILDER_KVK_HEADER_IDENTIFIER, + BUILDER_KVK_HEADER_VERSION, + BUILDER_KVK_KEY, + BUILDER_KVK_KEY_FLAGS, + BUILDER_KVK_SHIFT_STATE, +} from '../../src/kvk/kvk-file.js'; + +const VISUAL_KEYBOARD_TEXT_COLOR = 0xFF000008; + +describe('Test of KVK-File-Writer', () => { + describe('Test of write()', () => { + it('can create a visual keyboard', () => { + const writer = new KvkFileWriter; + const vk = initVisualKeyboard([ + initVisualKeyboardKey(0), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ]); + const file = writer.write(vk); + assert.isNotNull(file); + }); + }); + describe('Test of build()', () => { + it('can build a BUILDER_KVK_FILE', () => { + const vk = initVisualKeyboard([ + initVisualKeyboardKey(0), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ]); + const writer = new KvkFileWriter; + const binary: BUILDER_KVK_FILE = writer['build'](vk); + checkBuilderKvkFile(binary, vk); + }); + it('does not take account of the VisualKeyboard version', () => { + const vk_version = (BUILDER_KVK_HEADER_VERSION + 0x0100); + const vk = initVisualKeyboard([ + initVisualKeyboardKey(0), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ], + vk_version, + BUILDER_KVK_HEADER_FLAGS.kvkhNone, + "associatedKeyboard", + DEFAULT_KVK_FONT, + DEFAULT_KVK_FONT, + undefined, + ); + const writer = new KvkFileWriter; + const binary: BUILDER_KVK_FILE = writer['build'](vk); + checkBuilderKvkFile(binary, vk); + }); + it.skip('can handle a null associatedKeyboard', () => { + const vk_associatedKeyboard: string = null; + const vk = initVisualKeyboard([ + initVisualKeyboardKey(0), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ], + undefined, + BUILDER_KVK_HEADER_FLAGS.kvkhNone, + vk_associatedKeyboard, + DEFAULT_KVK_FONT, + DEFAULT_KVK_FONT, + undefined, + ); + const writer = new KvkFileWriter; + const binary: BUILDER_KVK_FILE = writer['build'](vk); + checkBuilderKvkFile(binary, vk); + }); + it.skip('can handle a null ansiFont name', () => { + const vk_ansiFont_name: string = null; + const vk = initVisualKeyboard([ + initVisualKeyboardKey(0), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ], + undefined, + BUILDER_KVK_HEADER_FLAGS.kvkhNone, + "associatedKeyboard", + { name: vk_ansiFont_name, size: -12 }, + DEFAULT_KVK_FONT, + undefined, + ); + const writer = new KvkFileWriter; + const binary: BUILDER_KVK_FILE = writer['build'](vk); + checkBuilderKvkFile(binary, vk); + }); + it.skip('can handle a null unicodeFont name', () => { + const vk_unicodeFont_name: string = null; + const vk = initVisualKeyboard([ + initVisualKeyboardKey(0), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ], + undefined, + BUILDER_KVK_HEADER_FLAGS.kvkhNone, + "associatedKeyboard", + DEFAULT_KVK_FONT, + { name: vk_unicodeFont_name, size: -12 }, + undefined, + ); + const writer = new KvkFileWriter; + const binary: BUILDER_KVK_FILE = writer['build'](vk); + checkBuilderKvkFile(binary, vk); + }); + it('can handle a null vkey', () => { + const vkk_vkey: number = null; + const vk = initVisualKeyboard([ + initVisualKeyboardKey( + vkk_vkey, + BUILDER_KVK_KEY_FLAGS.kvkkBitmap, + BUILDER_KVK_SHIFT_STATE.KVKS_NORMAL, + "text", + null, + ), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ]); + const writer = new KvkFileWriter; + const binary: BUILDER_KVK_FILE = writer['build'](vk); + checkBuilderKvkFile(binary, vk); + }); + it('can handle a null key flags', () => { + const vkk_flags: BUILDER_KVK_KEY_FLAGS = null; + const vk = initVisualKeyboard([ + initVisualKeyboardKey( + 0, + vkk_flags, + BUILDER_KVK_SHIFT_STATE.KVKS_NORMAL, + "text", + null, + ), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ]); + const writer = new KvkFileWriter; + const binary: BUILDER_KVK_FILE = writer['build'](vk); + checkBuilderKvkFile(binary, vk); + }); + it('can handle a null key shift', () => { + const vkk_shift: BUILDER_KVK_SHIFT_STATE = null; + const vk = initVisualKeyboard([ + initVisualKeyboardKey( + 0, + BUILDER_KVK_KEY_FLAGS.kvkkBitmap, + vkk_shift, + "text", + null, + ), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ]); + const writer = new KvkFileWriter; + const binary: BUILDER_KVK_FILE = writer['build'](vk); + checkBuilderKvkFile(binary, vk); + }); + it('can handle a null key text', () => { + const vkk_text: string = null; + const vk = initVisualKeyboard([ + initVisualKeyboardKey( + 0, + BUILDER_KVK_KEY_FLAGS.kvkkBitmap, + BUILDER_KVK_SHIFT_STATE.KVKS_NORMAL, + vkk_text, + null, + ), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ]); + const writer = new KvkFileWriter; + const binary: BUILDER_KVK_FILE = writer['build'](vk); + checkBuilderKvkFile(binary, vk); + }); + it('can handle a non-null key bitmap', () => { + const vkk_bitmap: number[] = [0]; + const vk = initVisualKeyboard([ + initVisualKeyboardKey( + 0, + BUILDER_KVK_KEY_FLAGS.kvkkBitmap, + BUILDER_KVK_SHIFT_STATE.KVKS_NORMAL, + "text", + vkk_bitmap, + ), + initVisualKeyboardKey(1), + initVisualKeyboardKey(2), + ]); + const writer = new KvkFileWriter; + const binary: BUILDER_KVK_FILE = writer['build'](vk); + checkBuilderKvkFile(binary, vk); + }); + }); + describe('Test of setString()', () => { + it('can set a BUILDER_KVK_STRING', () => { + const bks: BUILDER_KVK_STRING = { len: 0, str: null }; + const writer = new KvkFileWriter; + const str = "hello"; + writer['setString'](bks, str); + assert.equal(bks.len, str.length + 1); + assert.deepEqual(bks.str, str); + }); + it('can handle an empty string', () => { + const bks: BUILDER_KVK_STRING = { len: 0, str: null }; + const writer = new KvkFileWriter; + const str = ""; + writer['setString'](bks, str); + assert.equal(bks.len, str.length + 1); + assert.deepEqual(bks.str, str); + }); + it('throws TypeError for a null BUILDER_KVK_STRING', () => { + assert.throws(() => { + const writer = new KvkFileWriter; + writer['setString'](null, ""); + }, TypeError); + }); + it('throws TypeError for a null value', () => { + assert.throws(() => { + const bks: BUILDER_KVK_STRING = { len: 0, str: null }; + const writer = new KvkFileWriter; + writer['setString'](bks, null); + }, TypeError); + }); + }); +}); + +function initVisualKeyboard( + vkks: VisualKeyboardKey[], + version: number = undefined, + flags: BUILDER_KVK_HEADER_FLAGS = BUILDER_KVK_HEADER_FLAGS.kvkhNone, + associatedKeyboard: string = "associatedKeyboard", + ansiFont: VisualKeyboardFont = DEFAULT_KVK_FONT, + unicodeFont: VisualKeyboardFont = DEFAULT_KVK_FONT, + underlyingLayout: string = undefined, +): VisualKeyboard { + const vkh = { + version: version, + flags: flags, + associatedKeyboard: associatedKeyboard, + ansiFont: ansiFont, + unicodeFont: unicodeFont, + underlyingLayout: underlyingLayout, + }; + return { header: vkh, keys: vkks }; +}; + +function initVisualKeyboardKey( + vkey: number, + flags: BUILDER_KVK_KEY_FLAGS = BUILDER_KVK_KEY_FLAGS.kvkkBitmap, + shift: BUILDER_KVK_SHIFT_STATE = BUILDER_KVK_SHIFT_STATE.KVKS_NORMAL, + text: string = "text", + bitmap: number[] = null, +): VisualKeyboardKey { + const vkk: VisualKeyboardKey = { + vkey, + flags, + shift, + text, + bitmap: bitmap ? new Uint8Array(bitmap) : null, + }; + return vkk; +} + +function checkBuilderKvkFile(binary: BUILDER_KVK_FILE, vk: VisualKeyboard) { + assert.equal(binary.header.identifier, BUILDER_KVK_HEADER_IDENTIFIER); + assert.equal(binary.header.version, BUILDER_KVK_HEADER_VERSION); + if (vk.header.associatedKeyboard != null) { + assert.deepEqual(binary.header.associatedKeyboard.str, vk.header.associatedKeyboard); + } else { + assert.deepEqual(binary.header.associatedKeyboard.str, ''); + } + assert.equal(binary.header.flags, vk.header.flags); + assert.equal(binary.header.ansiFont.color, VISUAL_KEYBOARD_TEXT_COLOR); + assert.equal(binary.header.ansiFont.size, vk.header.ansiFont.size); + if (vk.header.ansiFont.name != null) { + assert.deepEqual(binary.header.ansiFont.name.str, vk.header.ansiFont.name); + } else { + assert.deepEqual(binary.header.ansiFont.name.str, ''); + } + assert.equal(binary.header.unicodeFont.color, VISUAL_KEYBOARD_TEXT_COLOR); + assert.equal(binary.header.unicodeFont.size, vk.header.unicodeFont.size); + if (vk.header.unicodeFont.name != null) { + assert.deepEqual(binary.header.unicodeFont.name.str, vk.header.unicodeFont.name); + } else { + assert.deepEqual(binary.header.unicodeFont.name.str, ''); + } + for (let idx=0; idx 0; + if not Result then + begin + if GetLastError <> ERROR_FILE_NOT_FOUND then + begin + // TODO-WINDOWS-UPDATES: log to Sentry + end; + end; +end; + +end. diff --git a/common/windows/delphi/general/Keyman.System.UpdateCheckResponse.pas b/common/windows/delphi/general/Keyman.System.UpdateCheckResponse.pas index 918c396411..adc90ab152 100644 --- a/common/windows/delphi/general/Keyman.System.UpdateCheckResponse.pas +++ b/common/windows/delphi/general/Keyman.System.UpdateCheckResponse.pas @@ -35,6 +35,7 @@ type TUpdateCheckResponse = record private + FOriginalData: string; FInstallSize: Int64; FInstallURL: string; FNewVersion: string; @@ -46,9 +47,13 @@ type FFileName: string; function ParseKeyboards(nodes: TJSONObject): Boolean; function ParseLanguages(i: Integer; v: TJSONValue): Boolean; + function DoParse(const message, app, currentVersion: string): Boolean; public function Parse(const message: AnsiString; const app, currentVersion: string): Boolean; + procedure SaveToFile(const Filename: string); + function LoadFromFile(const Filename, app, currentVersion: string): Boolean; + property CurrentVersion: string read FCurrentVersion; property NewVersion: string read FNewVersion; property NewVersionWithTag: string read FNewVersionWithTag; @@ -58,25 +63,32 @@ type property ErrorMessage: string read FErrorMessage; property Status: TUpdateCheckResponseStatus read FStatus; property Packages: TUpdateCheckResponsePackages read FPackages; + property OriginalData: string read FOriginalData; end; implementation uses + System.Classes, System.Generics.Collections, versioninfo; { TUpdateCheckResponse } function TUpdateCheckResponse.Parse(const message: AnsiString; const app, currentVersion: string): Boolean; +begin + Result := DoParse(string(UTF8String(message)), app, currentVersion); +end; + +function TUpdateCheckResponse.DoParse(const message, app, currentVersion: string): Boolean; var node, doc: TJSONObject; begin + FOriginalData := message; FCurrentVersion := currentVersion; FStatus := ucrsNoUpdate; - // TODO: test with UTF8 characters in response - doc := TJSONObject.ParseJSONValue(UTF8String(message)) as TJSONObject; + doc := TJSONObject.ParseJSONValue(UTF8String(FOriginalData)) as TJSONObject; if doc = nil then begin FErrorMessage := Format('Invalid response:'#13#10'%s', [string(message)]); @@ -168,4 +180,29 @@ begin Result := True; end; +function TUpdateCheckResponse.LoadFromFile(const Filename, app, currentVersion: string): Boolean; +var + ss: TStringStream; +begin + ss := TStringStream.Create('', TEncoding.UTF8); + try + ss.LoadFromFile(Filename); + Result := DoParse(ss.DataString, app, currentVersion); + finally + ss.Free; + end; +end; + +procedure TUpdateCheckResponse.SaveToFile(const Filename: string); +var + ss: TStringStream; +begin + ss := TStringStream.Create(FOriginalData, TEncoding.UTF8); + try + ss.SaveToFile(Filename); + finally + ss.Free; + end; +end; + end. diff --git a/common/windows/delphi/general/KeymanPaths.pas b/common/windows/delphi/general/KeymanPaths.pas index fbfa9961d7..08554852cc 100644 --- a/common/windows/delphi/general/KeymanPaths.pas +++ b/common/windows/delphi/general/KeymanPaths.pas @@ -17,6 +17,8 @@ type const S_CEF_SubProcess = 'kmbrowserhost.exe'; const S_CEF_SubProcess_Developer = 'kmdbrowserhost.exe'; const S_CustomisationFilename = 'desktop_pro.pxx'; + + const S_KeymanAppData_UpdateCache = 'Keyman\UpdateCache\'; public const S_KMShell = 'kmshell.exe'; const S_TSysInfoExe = 'tsysinfo.exe'; @@ -27,8 +29,10 @@ type const S_FallbackKeyboardPath = 'Keyboards\'; const S__Package = '_Package\'; const S_MCompileExe = 'mcompile.exe'; + const S_UpdateCache_Metadata = 'cache.json'; class function ErrorLogPath(const app: string = ''): string; static; class function KeymanHelpPath(const HelpFile: string): string; static; + class function KeymanUpdateCachePath(const filename: string = ''): string; static; class function KeymanDesktopInstallPath(const filename: string = ''): string; static; class function KeymanEngineInstallPath(const filename: string = ''): string; static; class function KeymanDesktopInstallDir: string; static; @@ -423,6 +427,11 @@ begin Result := ''; end; +class function TKeymanPaths.KeymanUpdateCachePath(const filename: string): string; +begin + Result := GetFolderPath(CSIDL_LOCAL_APPDATA) + S_KeymanAppData_UpdateCache + filename; +end; + class function TKeymanPaths.RunningFromSource(var keyman_root: string): Boolean; begin // On developer machines, if we are running within the source repo, then use diff --git a/common/windows/delphi/general/RegistryKeys.pas b/common/windows/delphi/general/RegistryKeys.pas index e351b3860f..bacca938c6 100644 --- a/common/windows/delphi/general/RegistryKeys.pas +++ b/common/windows/delphi/general/RegistryKeys.pas @@ -175,8 +175,10 @@ const SRegValue_CharMapSourceData = 'charmap source data'; // LM - SRegValue_AvailableLanguages = 'available languages'; //CU - SRegValue_CurrentLanguage = 'current language'; //CU + SRegValue_AvailableLanguages = 'available languages'; // CU + SRegValue_CurrentLanguage = 'current language'; // CU + + SRegValue_Update_State = 'update state'; // CU { Privacy } @@ -312,8 +314,10 @@ const SRegValue_ActiveProject_Filename = 'project filename'; SRegValue_ActiveProject_SourcePath = 'source path'; + SRegValue_AutomaticUpdates = 'automatic updates'; //CU SRegValue_CheckForUpdates = 'check for updates'; // CU SRegValue_LastUpdateCheckTime = 'last update check time'; // CU + SRegValue_ApplyNow = 'apply now'; // CU Start the install now even though it will require an restart SRegValue_UpdateCheck_UseProxy = 'update check use proxy'; // CU SRegValue_UpdateCheck_ProxyHost = 'update check proxy host'; // CU diff --git a/common/windows/delphi/keyboards/kmxfileconsts.pas b/common/windows/delphi/keyboards/kmxfileconsts.pas index e8f0e0d740..8e1955aaa6 100644 --- a/common/windows/delphi/keyboards/kmxfileconsts.pas +++ b/common/windows/delphi/keyboards/kmxfileconsts.pas @@ -230,7 +230,8 @@ type ssPlatform = 31, ssBaseLayout = 32, ssLayer = 33, ssVKDictionary = 34, ssLayoutFile = 35, // I3438 // I3483 ssKeyboardVersion = 36, ssKMW_EmbedCSS = 37, ssTargets = 38, ssCasedKeys = 39, - ssBegin_NewContext = 40, ssBegin_PostKeystroke = 41, ssNewLayer = 42, ssOldLayer = 43); // I4140 // I4368 // I4504 + ssBegin_NewContext = 40, ssBegin_PostKeystroke = 41, ssNewLayer = 42, ssOldLayer = 43, // I4140 // I4368 // I4504 + ssDisplayMap = 44); const SystemStoreNames: array[TSystemStore] of WideString = ( @@ -245,7 +246,8 @@ const '', '', '', '', 'LAYOUTFILE', 'KEYBOARDVERSION', 'KMW_EMBEDCSS', 'TARGETS', //9.0 // I3483 // I4140 // I4368 // I4504 'CASEDKEYS', //14.0, - '', '', 'NEWLAYER', 'OLDLAYER'); //15.0 + '', '', 'NEWLAYER', 'OLDLAYER', //15.0 + 'DISPLAYMAP'); //17.0 implementation diff --git a/core/docs/api/options.md b/core/docs/api/options.md index 41d5fe28c9..f51528eafe 100644 --- a/core/docs/api/options.md +++ b/core/docs/api/options.md @@ -17,7 +17,7 @@ value. ------------------------------------------------------------------------------- -# km_core_option_scope enum {#km_core_option_scope} +# km_core_option_scope enum ## Description @@ -48,7 +48,7 @@ enum km_core_option_scope { ------------------------------------------------------------------------------- -# km_core_option_item struct {#km_core_option_item} +# km_core_option_item struct ## Description @@ -78,7 +78,7 @@ struct km_core_option_item { ------------------------------------------------------------------------------- -# km_core_options_list_size() {#km_core_options_list_size} +# km_core_options_list_size() ## Description Return the length of a terminated [km_core_option_item] array (options @@ -150,7 +150,7 @@ km_core_state_option_lookup(km_core_state const *state, ------------------------------------------------------------------------------- -# km_core_state_options_update() {#km_core_state_options_update} +# km_core_state_options_update() ## Description @@ -188,7 +188,7 @@ km_core_state_options_update(km_core_state *state, ------------------------------------------------------------------------------- -# km_core_state_options_to_json() {#km_core_state_options_to_json} +# km_core_state_options_to_json() ## Description diff --git a/core/tests/unit/km_core_keyboard_api.tests.cpp b/core/tests/unit/km_core_keyboard_api.tests.cpp index 2898f9e7cd..06e604c023 100644 --- a/core/tests/unit/km_core_keyboard_api.tests.cpp +++ b/core/tests/unit/km_core_keyboard_api.tests.cpp @@ -56,10 +56,8 @@ TEST_F(KmCoreKeyboardApiTests, LoadFromBlobNull) { // Setup km::core::path kmxfile = ""; - std::unique_ptr data(new uint8_t[0]); - // Execute - auto status = km_core_keyboard_load_from_blob(kmxfile.stem().c_str(), data.get(), 0, &this->keyboard); + auto status = km_core_keyboard_load_from_blob(kmxfile.stem().c_str(), nullptr, 0, &this->keyboard); // Verify EXPECT_EQ(status, KM_CORE_STATUS_INVALID_ARGUMENT); diff --git a/core/tests/unit/kmx/fixtures/binary/meson.build b/core/tests/unit/kmx/fixtures/binary/meson.build index cf96bc75ce..a6fc50150a 100644 --- a/core/tests/unit/kmx/fixtures/binary/meson.build +++ b/core/tests/unit/kmx/fixtures/binary/meson.build @@ -16,11 +16,7 @@ binary_tests = [ ] foreach kbd : binary_tests - configure_file( - input: kbd + '.kmn', - output: kbd + '.kmn', - copy: true - ) + fs.copyfile(kbd + '.kmn', kbd + '.kmn') configure_file( command: hextobin_cmd + ['@INPUT@', '@OUTPUT@'], diff --git a/core/tests/unit/kmx/meson.build b/core/tests/unit/kmx/meson.build index a7a69079e4..421a133eef 100644 --- a/core/tests/unit/kmx/meson.build +++ b/core/tests/unit/kmx/meson.build @@ -139,7 +139,10 @@ foreach kbd : tests cfg.set('CONTENT', content) kbd_kmn = configure_file( - copy: true, + # not really a file that needs to be configured, but the `copy` parameter + # is deprecated and `fs.copyfile` doesn't work with files that are not in + # the source directory + configuration: cfg, input: kbd_src_path, output: kbd + '.kmn' ) diff --git a/core/tests/unit/ldml/invalid-keyboards/meson.build b/core/tests/unit/ldml/invalid-keyboards/meson.build index e85646b68e..f487cb83b9 100644 --- a/core/tests/unit/ldml/invalid-keyboards/meson.build +++ b/core/tests/unit/ldml/invalid-keyboards/meson.build @@ -11,11 +11,7 @@ invalid_tests = [ # Build all keyboards in output folder foreach kbd : invalid_tests - configure_file( - input: kbd + '.xml', - output: kbd + '.xml', - copy: true - ) + fs.copyfile(kbd + '.xml', kbd + '.xml') configure_file( command: hextobin_cmd + ['@INPUT@', '@OUTPUT@'], diff --git a/core/tests/unit/ldml/keyboards/meson.build b/core/tests/unit/ldml/keyboards/meson.build index 15f458c587..394b8a6811 100644 --- a/core/tests/unit/ldml/keyboards/meson.build +++ b/core/tests/unit/ldml/keyboards/meson.build @@ -65,11 +65,7 @@ kmc_cmd = [node, '--enable-source-maps', kmc_root] # Build all keyboards in output folder foreach kbd : tests_needing_copy - configure_file( - copy: true, - input: kbd + '.xml', - output: kbd + '.xml' - ) + fs.copyfile(kbd + '.xml', kbd + '.xml') configure_file( command: kmc_cmd + ['build', '@INPUT@', '--out-file', '@OUTPUT@'], @@ -86,13 +82,8 @@ foreach kbd : tests_with_testdata ) endforeach - foreach kbd : tests_from_cldr - configure_file( - copy: true, - input: ldml_data / kbd + '.xml', - output: kbd + '.xml' - ) + fs.copyfile(ldml_data / kbd + '.xml', kbd + '.xml') configure_file( command: kmc_cmd + ['build', '@INPUT@', '--out-file', '@OUTPUT@'], input: join_paths(ldml_data, kbd + '.xml'), diff --git a/core/tests/unit/ldml/meson.build b/core/tests/unit/ldml/meson.build index 08512bd550..e7a271b91f 100644 --- a/core/tests/unit/ldml/meson.build +++ b/core/tests/unit/ldml/meson.build @@ -43,16 +43,8 @@ else endif # copy package.json into build dir for test use -configure_file( - copy: true, - input: '../../../../package.json', - output: 'package.json', -) -configure_file( - copy: true, - input: '../../../../resources/standards-data/unicode-character-database/Blocks.txt', - output: 'Blocks.txt', -) +fs.copyfile('../../../../package.json', 'package.json') +fs.copyfile('../../../../resources/standards-data/unicode-character-database/Blocks.txt', 'Blocks.txt') configure_file( command: [node, meson.current_source_dir() / 'write_node_versions.js','@OUTPUT@'], diff --git a/core/wasm.build.linux.in b/core/wasm.build.linux.in index 27cb7ba68f..90a31bf75b 100644 --- a/core/wasm.build.linux.in +++ b/core/wasm.build.linux.in @@ -2,3 +2,4 @@ c = ['$EMSCRIPTEN_BASE/emcc'] cpp = ['$EMSCRIPTEN_BASE/em++'] ar = ['$EMSCRIPTEN_BASE/emar'] +strip = ['$EMSCRIPTEN_BASE/emstrip'] diff --git a/core/wasm.build.mac.in b/core/wasm.build.mac.in index 27cb7ba68f..90a31bf75b 100644 --- a/core/wasm.build.mac.in +++ b/core/wasm.build.mac.in @@ -2,3 +2,4 @@ c = ['$EMSCRIPTEN_BASE/emcc'] cpp = ['$EMSCRIPTEN_BASE/em++'] ar = ['$EMSCRIPTEN_BASE/emar'] +strip = ['$EMSCRIPTEN_BASE/emstrip'] diff --git a/core/wasm.build.win.in b/core/wasm.build.win.in index c846fbc740..948287d8b8 100644 --- a/core/wasm.build.win.in +++ b/core/wasm.build.win.in @@ -2,5 +2,6 @@ c = ['python.exe', '$EMSCRIPTEN_BASE/emcc.py'] cpp = ['python.exe', '$EMSCRIPTEN_BASE/em++.py'] ar = ['python.exe', '$EMSCRIPTEN_BASE/emar.py'] +strip = ['python.exe', '$EMSCRIPTEN_BASE/emstrip.py'] [properties] diff --git a/core/wasm.defs.build b/core/wasm.defs.build index f41af337a3..9e0859b49b 100644 --- a/core/wasm.defs.build +++ b/core/wasm.defs.build @@ -2,6 +2,7 @@ c = ['emcc'] cpp = ['em++'] ar = ['emar'] +strip = ['emstrip'] exe_wrapper = 'node' [properties] diff --git a/developer/docs/help/context/keyboard-editor.md b/developer/docs/help/context/keyboard-editor.md index ef43a9b4e8..fb0be6d34c 100644 --- a/developer/docs/help/context/keyboard-editor.md +++ b/developer/docs/help/context/keyboard-editor.md @@ -382,22 +382,24 @@ ID required, and a default code will usually be generated automatically by Keyman Developer. - - `K_xxxx` is used for a standard Keyman for Windows's key name, e.g. - `K_W`, `K_ENTER`. You cannot make up your own `K_xxxx` names. - Many of the `K_` ids have overloaded output behaviour, for - instance, if no rule is matched for `K_W`, Keyman will output - 'w' when it is touched. The standard key names are listed in - [Virtual Keys and Virtual Character Keys](/developer/language/guide/virtual-keys). Typically, - you would use only the "common" virtual ID. - - `T_xxxx` is used for any user defined names, e.g. `T_SCHWA`. - If you wanted to use it, `T_ENTER` would also be valid. If no - rule matches it, the key will have no output behaviour. - - `U_####[_####]` is used as a shortcut for a key that - will output those Unicode values, if no rule matches it. This is - similar to the overloaded behaviour for `K_` ids. Thus `####` - must be valid Unicode characters. E.g. `U_0259` would generate a - schwa if no rule matches. It is still valid to have a rule such - as `+ [U_0259] > ...` +- `K_xxxx` is used for a standard Keyman for Windows's key name, e.g. + `K_W`, `K_ENTER`. You cannot make up your own `K_xxxx` names. + Many of the `K_` ids have overloaded output behaviour, for + instance, if no rule is matched for `K_W`, Keyman will output + 'w' when it is touched. The standard key names are listed in + [Virtual Keys and Virtual Character Keys](/developer/language/guide/virtual-keys). Typically, + you would use only the "common" virtual ID. +- `T_xxxx` is used for any user defined names, e.g. `T_SCHWA`. + If you wanted to use it, `T_ENTER` would also be valid. If no + rule matches it, the key will have no output behaviour. +- `U_####[_####]` is used as a shortcut for a key that + will output those Unicode values, if no rule matches it. This is + similar to the overloaded behaviour for `K_` ids. Thus `####` + must be valid Unicode characters. + The square bracket characters `[` and `]` indicate an optional portion of the sequence + and are not to be included in the shortcut. E.g. `U_0259` would generate a + schwa if no rule matches. It is still valid to have a rule such + as `+ [U_0259] > ...` Padding Left : Padding to the left of each key can be adjusted, and specified as a diff --git a/developer/docs/help/guides/develop/creating-a-touch-keyboard-layout-for-amharic-the-nitty-gritty.md b/developer/docs/help/guides/develop/creating-a-touch-keyboard-layout-for-amharic-the-nitty-gritty.md index 21bf08df14..f33ea20c34 100644 --- a/developer/docs/help/guides/develop/creating-a-touch-keyboard-layout-for-amharic-the-nitty-gritty.md +++ b/developer/docs/help/guides/develop/creating-a-touch-keyboard-layout-for-amharic-the-nitty-gritty.md @@ -131,7 +131,9 @@ Character Keys"). Typically, you would use only the "common" virtual key - `U_####[_####]` is used as a shortcut for a key that will output those Unicode values, if no rule matches it. This is similar to the overloaded - behaviour for `K_` ids. Thus `####` must be valid Unicode characters. E.g. + behaviour for `K_` ids. Thus `####` must be valid Unicode characters. + The square bracket characters `[` and `]` indicate an optional portion of the sequence + and are not to be included in the shortcut. E.g. `U_0259` would generate a schwa if no rule matches. It is still valid to have a rule such as `+ [U_0259] > ...` diff --git a/developer/docs/help/guides/distribute/tutorial/step-2.md b/developer/docs/help/guides/distribute/tutorial/step-2.md index 17dc613ba8..7ed9aea576 100644 --- a/developer/docs/help/guides/distribute/tutorial/step-2.md +++ b/developer/docs/help/guides/distribute/tutorial/step-2.md @@ -58,7 +58,7 @@ welcome.htm > ### Tip If you want links to your website to open in the user's preferred browser, preface the href link with `link:`, e.g. - `website` + `website` The `link:` sceheme will open the referred file in the default application - that is, a web browser for URLs and links, Notepad for .txt files, Adobe Reader for PDFs. You can use `link:` to open any diff --git a/developer/docs/help/guides/distribute/tutorial/step-4.md b/developer/docs/help/guides/distribute/tutorial/step-4.md index 67c55fe66b..b1860df2a1 100644 --- a/developer/docs/help/guides/distribute/tutorial/step-4.md +++ b/developer/docs/help/guides/distribute/tutorial/step-4.md @@ -54,7 +54,7 @@ Website : Enter the name of the website where you will have information about this keyboard. If you want to host it on keyman.com, you could enter - `http://www.keyman.com/` + `https://keyman.com/` Image file : Select the splash image file that you created in Step 2 from the diff --git a/developer/docs/help/guides/intro.md b/developer/docs/help/guides/intro.md index fd72c898f9..d9d6fa116c 100644 --- a/developer/docs/help/guides/intro.md +++ b/developer/docs/help/guides/intro.md @@ -12,8 +12,9 @@ Linux. There are two core applications included in Keyman Developer: the Keyman Developer IDE (formerly called TIKE), and the command line compiler kmc. -[Watch a video](https://youtu.be/kwhgx_eX4Es) that highlights some of -the Keyman Developer features. +Watch a video that highlights some of the Keyman Developer 14.0 features (57:23) + +:Keyman Developer 14.0 Webinar ## Keyman Developer IDE diff --git a/developer/docs/help/guides/lexical-models/distribute/tutorial/step-5.md b/developer/docs/help/guides/lexical-models/distribute/tutorial/step-5.md index ed873c69ca..1453156461 100644 --- a/developer/docs/help/guides/lexical-models/distribute/tutorial/step-5.md +++ b/developer/docs/help/guides/lexical-models/distribute/tutorial/step-5.md @@ -38,7 +38,7 @@ recommendations: ZIP archive file!) and you won't save much space by recompressing it. 3. Include a link to the Keyman download page: - `http://keyman.com/downloads/` + `https://keyman.com/downloads/` ## Distributing a package by email diff --git a/developer/docs/help/reference/file-types/keyman-touch-layout.md b/developer/docs/help/reference/file-types/keyman-touch-layout.md index 8fa7312808..9f9bbd6fc3 100644 --- a/developer/docs/help/reference/file-types/keyman-touch-layout.md +++ b/developer/docs/help/reference/file-types/keyman-touch-layout.md @@ -97,7 +97,9 @@ Character Keys"). Typically, you would use only the "common" virtual key - `U_####[_####]` is used as a shortcut for a key that will output those Unicode values, if no rule matches it. This is similar to the overloaded - behaviour for `K_` ids. Thus `####` must be valid Unicode characters. E.g. + behaviour for `K_` ids. Thus `####` must be valid Unicode characters. + The square bracket characters `[` and `]` indicate an optional portion of the sequence + and are not to be included in the shortcut. E.g. `U_0259` would generate a schwa if no rule matches. It is still valid to have a rule such as `+ [U_0259] > ...` diff --git a/developer/src/common/delphi/general/KeyboardFonts.pas b/developer/src/common/delphi/general/KeyboardFonts.pas index 5c80392521..f1a2bac2b9 100644 --- a/developer/src/common/delphi/general/KeyboardFonts.pas +++ b/developer/src/common/delphi/general/KeyboardFonts.pas @@ -21,7 +21,8 @@ unit KeyboardFonts; interface type - TKeyboardFont = (kfontCode, kfontChar, kfontOSK, kfontTouchLayoutPhone, kfontTouchLayoutTablet, kfontTouchLayoutDesktop); + TKeyboardFont = (kfontCode, kfontChar, kfontOSK, kfontTouchLayoutPhone, kfontTouchLayoutTablet, kfontTouchLayoutDesktop, + kfontDisplayMap); TKeyboardFontArray = array[TKeyboardFont] of string; // I4409 @@ -34,7 +35,7 @@ type const KeyboardFontId: array[TKeyboardFont] of string = ( - 'Code', 'Char', 'Osk', 'TouchLayoutPhone', 'TouchLayoutTablet', 'TouchLayoutDesktop' + 'Code', 'Char', 'Osk', 'TouchLayoutPhone', 'TouchLayoutTablet', 'TouchLayoutDesktop', 'DisplayMap' ); implementation diff --git a/developer/src/kmc-keyboard-info/src/font-family.ts b/developer/src/common/web/utils/src/font-family.ts similarity index 51% rename from developer/src/kmc-keyboard-info/src/font-family.ts rename to developer/src/common/web/utils/src/font-family.ts index a78e341de7..d8fad53e37 100644 --- a/developer/src/kmc-keyboard-info/src/font-family.ts +++ b/developer/src/common/web/utils/src/font-family.ts @@ -24,5 +24,32 @@ export async function getFontFamily(source: Uint8Array) { return null; } + return font.meta.property.find(prop => prop.name == 'font-family')?.text ?? null; +} + +/** + * Extracts the font-family from an in-memory TTF or WOFF blob in `source` + * parameter. + * + * @param source In-memory TTF or WOFF font blob + * + * @throws Uncaught exceptions from ttfMeta.promise if the font file is invalid. + * + * @returns If the file is invalid or cannot be parsed, returns `null`, + * otherwise returns the font family as a string. + */ +export function getFontFamilySync(source: Uint8Array) { + /* c8 ignore next 3 */ + if(!source) { + return null; + } + + const buffer = Buffer.from(source); + const font = ttfMeta.ttfInfoSync(buffer); + /* c8 ignore next 3 */ + if(!font) { + return null; + } + return font.meta.property.find(prop => prop.name == 'font-family')?.text ?? null; } \ No newline at end of file diff --git a/developer/src/common/web/utils/src/index.ts b/developer/src/common/web/utils/src/index.ts index 290efd5fc7..f8ee6246da 100644 --- a/developer/src/common/web/utils/src/index.ts +++ b/developer/src/common/web/utils/src/index.ts @@ -68,4 +68,8 @@ export * as SourceFilenamePatterns from './source-filename-patterns.js'; export { KeymanXMLType, KeymanXMLWriter, KeymanXMLReader } from './xml-utils.js'; export * as GitHubUrls from './github-urls.js'; -export * as CloudUrls from './cloud-urls.js'; \ No newline at end of file +export * as CloudUrls from './cloud-urls.js'; + +export { getFontFamily, getFontFamilySync } from './font-family.js'; + +export * as ValidIds from './valid-ids.js'; diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/LICENSE b/developer/src/common/web/utils/src/ttfmeta/LICENSE similarity index 100% rename from developer/src/kmc-keyboard-info/src/ttfmeta/LICENSE rename to developer/src/common/web/utils/src/ttfmeta/LICENSE diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/README.md b/developer/src/common/web/utils/src/ttfmeta/README.md similarity index 100% rename from developer/src/kmc-keyboard-info/src/ttfmeta/README.md rename to developer/src/common/web/utils/src/ttfmeta/README.md diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/lib/index.js b/developer/src/common/web/utils/src/ttfmeta/lib/index.js similarity index 73% rename from developer/src/kmc-keyboard-info/src/ttfmeta/lib/index.js rename to developer/src/common/web/utils/src/ttfmeta/lib/index.js index eb94d53c1f..53972297f1 100644 --- a/developer/src/kmc-keyboard-info/src/ttfmeta/lib/index.js +++ b/developer/src/common/web/utils/src/ttfmeta/lib/index.js @@ -31,6 +31,20 @@ export function ttfInfo(pathOrData, callback) { } } +/** + * @namespace + * @param {string | number | Buffer | URL} pathOrData + * @returns {typeof ttf.result} + */ +export function ttfInfoSync(pathOrData) { + if (pathOrData instanceof Buffer) { + return ttf.ttfInfoSync(ttf.view(pathOrData)); + } else { + const data = fs.readFileSync(pathOrData); + return ttf.ttfInfoSync(ttf.view(data)); + } +} + /** * @param {string | number | Buffer | URL} pathOrData * @returns {Promise} @@ -48,5 +62,5 @@ export function promise(pathOrData) { } /** @namespace */ -export const ttfMeta = { ttfInfo, promise }; +export const ttfMeta = { ttfInfo, ttfInfoSync, promise }; export default ttfMeta; diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/lib/main.js b/developer/src/common/web/utils/src/ttfmeta/lib/main.js similarity index 97% rename from developer/src/kmc-keyboard-info/src/ttfmeta/lib/main.js rename to developer/src/common/web/utils/src/ttfmeta/lib/main.js index 239a6244dc..a952512f26 100644 --- a/developer/src/kmc-keyboard-info/src/ttfmeta/lib/main.js +++ b/developer/src/common/web/utils/src/ttfmeta/lib/main.js @@ -300,6 +300,16 @@ export function ttfInfo(data, callback) { } } +/** + * @namespace + * @param {*} data + * @returns {typeof result} + */ +export function ttfInfoSync(data) { + resultTables(data); + return result; +} + /** * @param {string | number | Buffer | URL | DataView} pathOrData * @returns {Promise} diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/lib/meta.js b/developer/src/common/web/utils/src/ttfmeta/lib/meta.js similarity index 100% rename from developer/src/kmc-keyboard-info/src/ttfmeta/lib/meta.js rename to developer/src/common/web/utils/src/ttfmeta/lib/meta.js diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/test/local.mjs b/developer/src/common/web/utils/src/ttfmeta/test/local.mjs similarity index 100% rename from developer/src/kmc-keyboard-info/src/ttfmeta/test/local.mjs rename to developer/src/common/web/utils/src/ttfmeta/test/local.mjs diff --git a/developer/src/kmc-keyboard-info/src/ttfmeta/test/mocha-index.mjs b/developer/src/common/web/utils/src/ttfmeta/test/mocha-index.mjs similarity index 100% rename from developer/src/kmc-keyboard-info/src/ttfmeta/test/mocha-index.mjs rename to developer/src/common/web/utils/src/ttfmeta/test/mocha-index.mjs diff --git a/developer/src/common/web/utils/src/utils/KeymanSentry.ts b/developer/src/common/web/utils/src/utils/KeymanSentry.ts index c11e4995c2..94409eafc8 100644 --- a/developer/src/common/web/utils/src/utils/KeymanSentry.ts +++ b/developer/src/common/web/utils/src/utils/KeymanSentry.ts @@ -66,6 +66,12 @@ export class KeymanSentry { static async captureException(e: any): Promise { if(isInit) { + // For local development, we don't want to bury the trace; we need the cast to avoid + // TS2367 (comparison appears to be unintentional) + if((KEYMAN_VERSION.VERSION_ENVIRONMENT as string) == 'local') { + throw e; + } + const eventId = Sentry.captureException(e); process.stderr.write(` Fatal error: ${(e??'').toString()} @@ -73,11 +79,6 @@ export class KeymanSentry { this.writeSentryMessage(eventId); await this.close(); - // For local development, we don't want to bury the trace; we need the cast to avoid - // TS2367 (comparison appears to be unintentional) - if((KEYMAN_VERSION.VERSION_ENVIRONMENT as string) == 'local') { - throw e; - } process.exit(1); } else { throw e; @@ -86,7 +87,11 @@ export class KeymanSentry { static async close() { if(isInit) { - await Sentry.close(CLOSE_TIMEOUT); + try { + await Sentry.close(CLOSE_TIMEOUT); + } catch(e) { + // ignore any errors here + } } } } \ No newline at end of file diff --git a/developer/src/common/web/utils/src/valid-ids.ts b/developer/src/common/web/utils/src/valid-ids.ts new file mode 100644 index 0000000000..717a173f5d --- /dev/null +++ b/developer/src/common/web/utils/src/valid-ids.ts @@ -0,0 +1,38 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ + + +/** + * Verifies that an id passed in is valid as an identifier for a Keyman .kmn + * keyboard: starts with alpha or underscore and remainder is alphanumeric + * @param id + * @returns + */ +export function isValidKeymanKeyboardId(id: string) { + return /^[a-z_][a-z0-9_]*$/.test(id); +} + +/** + * Verifies that an id passed in is valid as an identifier for an LDML xml + * keyboard: starts with alpha or underscore and remainder is alphanumeric. + * Note that this test is not the same as the CLDR test for valid LDML xml + * filename, as this test has a narrower validity policy to match the Keyman + * implementation requirements. + * @param id + * @returns + */ +export function isValidLdmlKeyboardId(id: string) { + return /^[a-z_][a-z0-9_]*$/.test(id); +} + +/** + * Verifies that an id passed in is valid as an identifier for a Keyman + * lexical model: starts with alpha or underscore and remainder is alphanumeric, + * in three components -- author.bcp47.uniq + * @param id + * @returns + */ +export function isValidLexicalModelId(id: string) { + return /^[a-z_][a-z0-9_]*\.[a-z][a-z0-9_]+\.[a-z_][a-z0-9_]*$/.test(id); +} diff --git a/developer/src/common/web/utils/test/valid-ids.tests.ts b/developer/src/common/web/utils/test/valid-ids.tests.ts new file mode 100644 index 0000000000..c285332b66 --- /dev/null +++ b/developer/src/common/web/utils/test/valid-ids.tests.ts @@ -0,0 +1,76 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ + +import { assert } from 'chai'; +import 'mocha'; +import { isValidKeymanKeyboardId, isValidLdmlKeyboardId, isValidLexicalModelId } from '../src/valid-ids.js'; + +describe('valid-ids', function () { + it('should accept a valid Keyman keyboard ID', function() { + [ + 'khmer_angkor', + 'm', + 'fancypants1_2', + 'keyboard___', + '_100', + ].forEach(id => assert.isTrue(isValidKeymanKeyboardId(id), `expected '${id}' to be valid`)); + }); + + it('should reject invalid Keyman keyboard IDs', function() { + [ + 'khmer_angkor@example.com', + 'keyboard ', + 'Khmer', + ' keyboard', + '111', + '?', + 'keyboard.version', + ].forEach(id => assert.isFalse(isValidKeymanKeyboardId(id), `expected '${id}' to be invalid`)); + }); + + it('should accept a valid LDML keyboard ID', function() { + [ + 'khmer_angkor', + 'm', + 'fancypants1_2', + 'keyboard___', + '_100', + ].forEach(id => assert.isTrue(isValidLdmlKeyboardId(id), `expected '${id}' to be valid`)); + }); + + it('should reject invalid LDML keyboard IDs', function() { + [ + 'khmer_angkor@example.com', + 'keyboard ', + ' keyboard', + 'Khmer', + '111', + '?', + 'keyboard.version', + ].forEach(id => assert.isFalse(isValidLdmlKeyboardId(id), `expected '${id}' to be invalid`)); + }); + + it('should accept a valid lexical model ID', function() { + [ + 'khmer.km.khmer_angkor', + 'nrc.en.mtnt', + ].forEach(id => assert.isTrue(isValidLexicalModelId(id), `expected '${id}' to be valid`)); + }); + + it('should reject invalid lexical model IDs', function() { + [ + 'khmer', + 'en-us', + 'khmer.km-KM.angkor', + 'Khmer.km_km.angkor', + 'model ', + ' model', + '111', + '?', + 'lexical.model', + 'lexical..model', + '.en.model', + ].forEach(id => assert.isFalse(isValidLexicalModelId(id), `expected '${id}' to be invalid`)); + }); +}); diff --git a/developer/src/common/web/utils/tsconfig.json b/developer/src/common/web/utils/tsconfig.json index 6f96f75ee7..362ec3a972 100644 --- a/developer/src/common/web/utils/tsconfig.json +++ b/developer/src/common/web/utils/tsconfig.json @@ -5,8 +5,12 @@ "rootDir": ".", "outDir": "./build/", "baseUrl": ".", + + /** for ttfmeta */ + "allowJs": true, }, "include": [ "src/**/*.ts", + "src/ttfmeta/lib/*.js", ], } diff --git a/developer/src/kmc-generate/package.json b/developer/src/kmc-generate/package.json index a8e0810338..ba488ff575 100644 --- a/developer/src/kmc-generate/package.json +++ b/developer/src/kmc-generate/package.json @@ -54,7 +54,8 @@ "exclude-after-remap": true, "exclude": [ "test/", - "src/template/" + "src/template/", + "src/main.ts" ] }, "repository": { diff --git a/developer/src/kmc-generate/src/basic-generator.ts b/developer/src/kmc-generate/src/basic-generator.ts index cb69ef5fdd..fe5a5cd304 100644 --- a/developer/src/kmc-generate/src/basic-generator.ts +++ b/developer/src/kmc-generate/src/basic-generator.ts @@ -7,6 +7,7 @@ import { KeymanTargets } from "@keymanapp/common-types"; import KEYMAN_VERSION from "@keymanapp/keyman-version"; import { AbstractGenerator, GeneratorArtifacts } from "./abstract-generator.js"; +import { GeneratorMessages } from "./generator-messages.js"; /** * @internal @@ -28,7 +29,19 @@ export class BasicGenerator extends AbstractGenerator { this.languageTags = this.options.languageTags.length ? this.options.languageTags.map(tag => new Intl.Locale(tag).minimize().toString()) : [this.DEFAULT_LOCALE]; - //TODO-GENERATE: validate targets + + // Verify that targets passed in are valid + let failed = false; + for(const target of this.options.targets) { + if(!KeymanTargets.AllKeymanTargets.includes(target)) { + this.callbacks.reportMessage(GeneratorMessages.Error_InvalidTarget({target})); + failed = true; + } + } + if(failed) { + return false; + } + if(this.options.targets.includes(KeymanTargets.KeymanTarget.any) || this.options.targets.length == 0) { this.resolvedTargets = KeymanTargets.AllKeymanTargets.filter(target => target != KeymanTargets.KeymanTarget.any); } else { @@ -50,6 +63,8 @@ export class BasicGenerator extends AbstractGenerator { dt.getDate().toString().padStart(2, '0'); this.tokenMap['$PACKAGE_LANGUAGES'] = this.generateLanguageListForPackage(); this.tokenMap['$PLATFORMS_DOTLIST_README'] = this.getPlatformDotListForReadme(); + + return true; } protected generate(artifacts: GeneratorArtifacts): boolean { @@ -89,6 +104,8 @@ export class BasicGenerator extends AbstractGenerator { sourceFile = this.callbacks.path.join(this.templateBasePath, this.templatePath, sourceFile); const sourceData = this.callbacks.loadFile(sourceFile); + + /* c8 ignore next 4 */ if(sourceData == null) { // internal error -- source file should be readable throw new Error(`source file ${sourceFile} does not exist`); diff --git a/developer/src/kmc-generate/src/generator-messages.ts b/developer/src/kmc-generate/src/generator-messages.ts index cda0d82fd2..ad562dbd29 100644 --- a/developer/src/kmc-generate/src/generator-messages.ts +++ b/developer/src/kmc-generate/src/generator-messages.ts @@ -17,30 +17,65 @@ const SevFatal = CompilerErrorSeverity.Fatal | Namespace; * @internal */ export class GeneratorMessages { - static Fatal_UnexpectedException = (o:{e: any}) => m(this.FATAL_UnexpectedException, null, o.e ?? 'unknown error'); static FATAL_UnexpectedException = SevFatal | 0x0001; + static Fatal_UnexpectedException = (o:{e: any}) => m( + this.FATAL_UnexpectedException, null, o.e ?? 'unknown error' + ); - static Info_GeneratingProject = (o:{type: string, id: string}) => m(this.INFO_GeneratingProject, - `Generating project of type ${def(o.type)} with id ${def(o.id)}`); static INFO_GeneratingProject = SevInfo | 0x0002; + static Info_GeneratingProject = (o:{type: string, id: string}) => m( + this.INFO_GeneratingProject, + `Generating project of type ${def(o.type)} with id ${def(o.id)}` + ); static ERROR_CannotCreateFolder = SevError | 0x0003; - static Error_CannotCreateFolder = (o:{folderName:string, e: any}) => CompilerMessageSpecWithException(this.ERROR_CannotCreateFolder, null, - `Unable to create folder ${def(o.folderName)}: ${o.e ?? 'unknown error'}`); + static Error_CannotCreateFolder = (o:{folderName:string, e: any}) => CompilerMessageSpecWithException( + this.ERROR_CannotCreateFolder, null, + `Unable to create folder ${def(o.folderName)}: ${o.e ?? 'unknown error'}` + ); static ERROR_OutputPathAlreadyExists = SevError | 0x0004; static Error_OutputPathAlreadyExists = (o:{outPath:string}) => m( this.ERROR_OutputPathAlreadyExists, - `Output path ${def(o.outPath)} already exists, not overwriting`); + `Output path ${def(o.outPath)} already exists, not overwriting` + ); static ERROR_CannotWriteOutputFile = SevError | 0x0005; - static Error_CannotWriteOutputFile = (o:{filename:string, e: any}) => CompilerMessageSpecWithException(this.ERROR_CannotWriteOutputFile, null, - `Unable to write file ${def(o.filename)}: ${o.e ?? 'unknown error'}`); + static Error_CannotWriteOutputFile = (o:{filename:string, e: any}) => CompilerMessageSpecWithException( + this.ERROR_CannotWriteOutputFile, null, + `Unable to write file ${def(o.filename)}: ${o.e ?? 'unknown error'}` + ); // See also PackageCompilerMessages.WARN_PackageNameDoesNotFollowLexicalModelConventions static WARN_ModelIdDoesNotFollowLexicalModelConventions = SevWarn | 0x0006; - static Warn_ModelIdDoesNotFollowLexicalModelConventions = (o:{id: string}) => m(this.WARN_ModelIdDoesNotFollowLexicalModelConventions, + static Warn_ModelIdDoesNotFollowLexicalModelConventions = (o:{id: string}) => m( + this.WARN_ModelIdDoesNotFollowLexicalModelConventions, `The id ${def(o.id)} does not follow the recommended model id conventions. The id should be all lower case, `+ `include only alphanumeric characters and underscore (_), not start with a digit, and should have the structure `+ - `..`); + `..` + ); + + static ERROR_InvalidTarget = SevError | 0x0007; + static Error_InvalidTarget = (o:{target:string}) => m( + this.ERROR_InvalidTarget, + `The specified target platform '${def(o.target)}' is not a valid target.` + ); + + static ERROR_InvalidKeymanKeyboardId = SevError | 0x0008; + static Error_InvalidKeymanKeyboardId = (o:{id:string}) => m( + this.ERROR_InvalidKeymanKeyboardId, + `The specified keyboard id '${def(o.id)}' contains characters that are not permitted for a .kmn keyboard id or filename.` + ); + + static ERROR_InvalidLdmlKeyboardId = SevError | 0x0009; + static Error_InvalidLdmlKeyboardId = (o:{id:string}) => m( + this.ERROR_InvalidLdmlKeyboardId, + `The specified keyboard id '${def(o.id)}' contains characters that are not permitted for an LDML .xml keyboard id or filename.` + ); + + static ERROR_InvalidLexicalModelId = SevError | 0x000A; + static Error_InvalidLexicalModelId = (o:{id:string}) => m( + this.ERROR_InvalidLexicalModelId, + `The specified lexical model id '${def(o.id)}' contains characters that are not permitted or does not match the required pattern of 'author.bcp47.uniq'.` + ); }; diff --git a/developer/src/kmc-generate/src/keyman-keyboard-generator.ts b/developer/src/kmc-generate/src/keyman-keyboard-generator.ts index c3d5c7665a..ec76beff65 100644 --- a/developer/src/kmc-generate/src/keyman-keyboard-generator.ts +++ b/developer/src/kmc-generate/src/keyman-keyboard-generator.ts @@ -5,9 +5,10 @@ */ import { KeymanFileTypes, KeymanTargets } from '@keymanapp/common-types'; -import { KeymanCompiler, } from '@keymanapp/developer-utils'; +import { KeymanCompiler, ValidIds, } from '@keymanapp/developer-utils'; import { GeneratorArtifacts, GeneratorResult } from './abstract-generator.js'; import { BasicGenerator } from './basic-generator.js'; +import { GeneratorMessages } from './generator-messages.js'; /** * @public @@ -39,7 +40,15 @@ export class KeymanKeyboardGenerator extends BasicGenerator implements KeymanCom * @returns Binary artifacts on success, null on failure. */ async run(): Promise { - this.preGenerate(); + if(!ValidIds.isValidKeymanKeyboardId(this.options.id)) { + this.callbacks.reportMessage(GeneratorMessages.Error_InvalidKeymanKeyboardId({id:this.options.id})); + return null; + } + + if(!this.preGenerate()) { + // errors will have been reported in preGenerate + return null; + } const artifacts: GeneratorArtifacts = this.defaultArtifacts(); diff --git a/developer/src/kmc-generate/src/ldml-keyboard-generator.ts b/developer/src/kmc-generate/src/ldml-keyboard-generator.ts index e9885e7fb3..f8afb49013 100644 --- a/developer/src/kmc-generate/src/ldml-keyboard-generator.ts +++ b/developer/src/kmc-generate/src/ldml-keyboard-generator.ts @@ -5,9 +5,10 @@ */ import { KeymanFileTypes } from '@keymanapp/common-types'; -import { KeymanCompiler, } from '@keymanapp/developer-utils'; +import { KeymanCompiler, ValidIds, } from '@keymanapp/developer-utils'; import { GeneratorArtifacts, GeneratorResult } from './abstract-generator.js'; import { BasicGenerator } from './basic-generator.js'; +import { GeneratorMessages } from './generator-messages.js'; /** * @public @@ -28,7 +29,15 @@ export class LdmlKeyboardGenerator extends BasicGenerator implements KeymanCompi * @returns Binary artifacts on success, null on failure. */ async run(): Promise { - this.preGenerate(); + if(!ValidIds.isValidLdmlKeyboardId(this.options.id)) { + this.callbacks.reportMessage(GeneratorMessages.Error_InvalidLdmlKeyboardId({id:this.options.id})); + return null; + } + + if(!this.preGenerate()) { + // errors will have been reported in preGenerate + return null; + } const artifacts: GeneratorArtifacts = this.defaultArtifacts(); this.templatePath = 'ldml-keyboard'; diff --git a/developer/src/kmc-generate/src/lexical-model-generator.ts b/developer/src/kmc-generate/src/lexical-model-generator.ts index f78807798c..86316549cf 100644 --- a/developer/src/kmc-generate/src/lexical-model-generator.ts +++ b/developer/src/kmc-generate/src/lexical-model-generator.ts @@ -5,7 +5,7 @@ */ import { KeymanFileTypes } from '@keymanapp/common-types'; -import { KeymanCompiler, SourceFilenamePatterns } from '@keymanapp/developer-utils'; +import { KeymanCompiler, SourceFilenamePatterns, ValidIds } from '@keymanapp/developer-utils'; import { GeneratorArtifacts, GeneratorResult } from './abstract-generator.js'; import { BasicGenerator } from './basic-generator.js'; import { GeneratorMessages } from './generator-messages.js'; @@ -31,7 +31,15 @@ export class LexicalModelGenerator extends BasicGenerator implements KeymanCompi * @returns Binary artifacts on success, null on failure. */ async run(): Promise { - this.preGenerate(); + if(!ValidIds.isValidLexicalModelId(this.options.id)) { + this.callbacks.reportMessage(GeneratorMessages.Error_InvalidLexicalModelId({id:this.options.id})); + return null; + } + + if(!this.preGenerate()) { + // errors will have been reported in preGenerate + return null; + } const artifacts: GeneratorArtifacts = this.defaultArtifacts(); diff --git a/developer/src/kmc-generate/test/basic-generator.tests.ts b/developer/src/kmc-generate/test/basic-generator.tests.ts index b1eb1e420d..f5cff4044a 100644 --- a/developer/src/kmc-generate/test/basic-generator.tests.ts +++ b/developer/src/kmc-generate/test/basic-generator.tests.ts @@ -2,22 +2,71 @@ * Keyman is copyright (C) SIL International. MIT License. */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + import 'mocha'; import { assert } from 'chai'; -import { BasicGenerator } from '../src/basic-generator.js'; import { TestCompilerCallbacks } from '@keymanapp/developer-test-helpers'; +import { BasicGenerator } from '../src/basic-generator.js'; +import { AbstractGenerator, GeneratorArtifacts } from '../src/abstract-generator.js'; import { options } from './shared-options.js'; +import { makePathToFixture } from './helpers/index.js'; + +// this class is here to make it possible to access protected members +class BasicGeneratorTest extends BasicGenerator { + static files() { + return [ + 'sample/' + AbstractGenerator.SFile_WelcomeHTM, + 'sample/' + AbstractGenerator.SFile_ReadmeHTM, + 'sample/' + AbstractGenerator.SFile_HistoryMD, + 'sample/' + AbstractGenerator.SFile_LicenseMD, + 'sample/' + AbstractGenerator.SFile_ReadmeMD, + 'sample/' + AbstractGenerator.SFile_GitIgnore, + ]; + }; + + setup() { + this.templatePath = 'kmn-keyboard'; + } +} describe('BasicGenerator', function () { it('should configure default settings', async function() { const bg = new BasicGenerator(); const callbacks = new TestCompilerCallbacks(); - assert(await bg.init(callbacks, options)); - bg.test_preGenerate(); + assert.isTrue(await bg.init(callbacks, options)); + assert.isTrue(bg.test_preGenerate()); assert.equal(bg.test_tokenMap['$NAME'], 'Sample Project'); }); - // TODO-GENERATE: test transform + it('should successfully transform common project files', async function() { + const bg = new BasicGeneratorTest(); + bg.setup(); + + const callbacks = new TestCompilerCallbacks(); + assert.isTrue(await bg.init(callbacks, options)); + assert.isTrue(bg.test_preGenerate()); + + const artifacts: GeneratorArtifacts = { "kmc-generate:outputPath": { data: null, filename: '' } }; + assert.isTrue(bg.test_generate(artifacts)); + + // standard artifacts are listed in AbstractGenerator + const keys = [].concat( + 'kmc-generate:outputPath', + BasicGeneratorTest.files().map(f => f.replaceAll('/', path.sep)) + ); + + assert.hasAllKeys(artifacts, keys); + + // pick one file and compare it + const filename = BasicGeneratorTest.files()[0].replaceAll('/', path.sep); // welcome.htm + assert.equal(artifacts[filename].filename, filename); + const fixturePath = makePathToFixture('basic', 'welcome.htm'); + const fixture = fs.readFileSync(fixturePath, 'utf-8').replace(/\r\n/g, '\n'); + const actual = new TextDecoder().decode(artifacts[filename].data); + assert.equal(actual, fixture); + }); }); diff --git a/developer/src/kmc-generate/test/fixtures/basic/welcome.htm b/developer/src/kmc-generate/test/fixtures/basic/welcome.htm new file mode 100644 index 0000000000..1454a604b6 --- /dev/null +++ b/developer/src/kmc-generate/test/fixtures/basic/welcome.htm @@ -0,0 +1,26 @@ + + + + + + Start Using Sample Project + + + + +

Start Using Sample Project

+ +

+ # A mighty description +

+ +

Keyboard Layout

+ + + + + \ No newline at end of file diff --git a/developer/src/kmc-generate/test/messages.tests.ts b/developer/src/kmc-generate/test/generator-messages.tests.ts similarity index 50% rename from developer/src/kmc-generate/test/messages.tests.ts rename to developer/src/kmc-generate/test/generator-messages.tests.ts index 68c7f741fa..b5d3a8837a 100644 --- a/developer/src/kmc-generate/test/messages.tests.ts +++ b/developer/src/kmc-generate/test/generator-messages.tests.ts @@ -2,15 +2,24 @@ * Keyman is copyright (C) SIL International. MIT License. */ +import * as path from 'node:path'; +import * as fs from 'node:fs'; + import 'mocha'; -import * as path from 'path'; -import * as fs from 'fs'; import { fileURLToPath } from 'url'; import { assert } from 'chai'; -import { GeneratorMessages } from '../src/generator-messages.js'; + import { TestCompilerCallbacks, verifyCompilerMessagesObject } from '@keymanapp/developer-test-helpers'; import { CompilerErrorNamespace } from '@keymanapp/developer-utils'; + +import { GeneratorMessages } from '../src/generator-messages.js'; import { AbstractGenerator, GeneratorOptions } from '../src/abstract-generator.js'; +import { BasicGenerator } from '../src/basic-generator.js'; +import { KeymanKeyboardGenerator } from '../src/keyman-keyboard-generator.js'; +import { LdmlKeyboardGenerator } from '../src/ldml-keyboard-generator.js'; +import { LexicalModelGenerator } from '../src/lexical-model-generator.js'; + +import { options } from './shared-options.js'; describe('GeneratorMessages', function () { const callbacks = new TestCompilerCallbacks(); @@ -62,4 +71,49 @@ describe('GeneratorMessages', function () { assert.isTrue(callbacks.hasMessage(GeneratorMessages.ERROR_CannotWriteOutputFile), `messageId ERROR_CannotWriteOutputFile not generated, instead got: `+JSON.stringify(callbacks.messages,null,2)); }); + + it('should generate ERROR_InvalidTarget if an invalid target is passed in', async function () { + const testOptions: GeneratorOptions = { ...options, + // @ts-ignore ('invalid' could be passed by a user, even if would be a compile error here) + targets: ['invalid'] + }; + const bg = new BasicGenerator(); + const callbacks = new TestCompilerCallbacks(); + assert(await bg.init(callbacks, testOptions)); + assert.isFalse(bg.test_preGenerate()); + assert.isTrue(callbacks.hasMessage(GeneratorMessages.ERROR_InvalidTarget)); + }); + + it('should generate ERROR_InvalidKeymanKeyboardId if an invalid keyboard id is passed in', async function () { + const testOptions: GeneratorOptions = { ...options, + id: '???invalid', + }; + const kkg = new KeymanKeyboardGenerator(); + const callbacks = new TestCompilerCallbacks(); + assert.isTrue(await kkg.init(callbacks, testOptions)); + assert.isNull(await kkg.run()); + assert.isTrue(callbacks.hasMessage(GeneratorMessages.ERROR_InvalidKeymanKeyboardId)); + }); + + it('should generate ERROR_InvalidLdmlKeyboardId if an invalid keyboard id is passed in', async function () { + const testOptions: GeneratorOptions = { ...options, + id: '???invalid', + }; + const lkg = new LdmlKeyboardGenerator(); + const callbacks = new TestCompilerCallbacks(); + assert.isTrue(await lkg.init(callbacks, testOptions)); + assert.isNull(await lkg.run()); + assert.isTrue(callbacks.hasMessage(GeneratorMessages.ERROR_InvalidLdmlKeyboardId)); + }); + + it('should generate ERROR_InvalidLexicalModelId if an invalid lexical model id is passed in', async function () { + const testOptions: GeneratorOptions = { ...options, + id: 'example.???.invalid', + }; + const lmg = new LexicalModelGenerator(); + const callbacks = new TestCompilerCallbacks(); + assert.isTrue(await lmg.init(callbacks, testOptions)); + assert.isNull(await lmg.run()); + assert.isTrue(callbacks.hasMessage(GeneratorMessages.ERROR_InvalidLexicalModelId)); + }); }); diff --git a/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts b/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts index 74c06d5d12..3e1db45aed 100644 --- a/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts +++ b/developer/src/kmc-keyboard-info/src/keyboard-info-compiler.ts @@ -12,7 +12,7 @@ import { CompilerCallbacks, KeymanCompiler, CompilerOptions, KeymanCompilerResul import { KmpCompiler } from "@keymanapp/kmc-package"; import { SchemaValidators } from "@keymanapp/common-types"; -import { getFontFamily } from "./font-family.js"; +import { getFontFamily } from "@keymanapp/developer-utils"; const regionNames = new Intl.DisplayNames(['en'], { type: "region" }); const scriptNames = new Intl.DisplayNames(['en'], { type: "script" }); @@ -499,9 +499,6 @@ export class KeyboardInfoCompiler implements KeymanCompiler { keyboard_info.languages[language] = {}; } - const fontSource = [].concat(...kmpJsonData.keyboards.map(e => e.displayFont ? [e.displayFont] : []), ...kmpJsonData.keyboards.map(e => e.webDisplayFonts ?? [])); - const oskFontSource = [].concat(...kmpJsonData.keyboards.map(e => e.oskFont ? [e.oskFont] : []), ...kmpJsonData.keyboards.map(e => e.webOskFonts ?? [])); - let commonScript = null; for(const bcp47 of Object.keys(keyboard_info.languages)) { @@ -528,6 +525,20 @@ export class KeyboardInfoCompiler implements KeymanCompiler { // do it right now. // + // The code below: + // 1. Only includes fonts associated with keyboards which support the current bcp47 (filter) + // 2. Joins the displayFont and webDisplayFonts data, and removes duplicates (...new Set()) + + const supportedKeyboards = kmpJsonData.keyboards.filter(k => k.languages.find(lang => lang.id == bcp47)); + const fontSource = [...new Set([].concat( + ...supportedKeyboards.map(e => e.displayFont ? [e.displayFont] : []), + ...supportedKeyboards.map(e => e.webDisplayFonts ?? []) + ))]; + const oskFontSource = [...new Set([].concat( + ...supportedKeyboards.map(e => e.oskFont ? [e.oskFont] : []), + ...supportedKeyboards.map(e => e.webOskFonts ?? []) + ))]; + if(fontSource.length) { language.font = await this.fontSourceToKeyboardInfoFont(kpsFilename, kmpJsonData, fontSource); if(language.font == null) { diff --git a/developer/src/kmc-keyboard-info/test/font-family.tests.ts b/developer/src/kmc-keyboard-info/test/font-family.tests.ts index b27fad8d74..d926e75c65 100644 --- a/developer/src/kmc-keyboard-info/test/font-family.tests.ts +++ b/developer/src/kmc-keyboard-info/test/font-family.tests.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import { assert } from 'chai'; import 'mocha'; import { makePathToFixture } from './helpers/index.js'; -import { getFontFamily } from '../src/font-family.js'; +import { getFontFamily } from '@keymanapp/developer-utils'; const AFGHAN_TURKMEN_DISPLAY_FONT = makePathToFixture('afghan_turkmen', "Lateef-Regular.ttf"); const AFGHAN_TURKMEN_OSK_FONT = makePathToFixture('afghan_turkmen', "Lateef-Bold.ttf"); diff --git a/developer/src/kmc-keyboard-info/tsconfig.json b/developer/src/kmc-keyboard-info/tsconfig.json index d09b25c560..124c812ecf 100644 --- a/developer/src/kmc-keyboard-info/tsconfig.json +++ b/developer/src/kmc-keyboard-info/tsconfig.json @@ -13,7 +13,6 @@ }, "include": [ "src/**/*.ts", - "src/ttfmeta/lib/*.js", "src/imports/langtags.js", ], "references": [ diff --git a/developer/src/kmc-kmn/src/kmw-compiler/validate-layout-file.ts b/developer/src/kmc-kmn/src/kmw-compiler/validate-layout-file.ts index a5ee9da293..9bed88f5f8 100644 --- a/developer/src/kmc-kmn/src/kmw-compiler/validate-layout-file.ts +++ b/developer/src/kmc-kmn/src/kmw-compiler/validate-layout-file.ts @@ -46,6 +46,16 @@ function GetKeyIdUnicodeType(value: string): TKeyIdType { function KeyIdType(FId: string): TKeyIdType { // I4142 FId = FId.toUpperCase(); + + // Validate key id format: + // K_xxxx -- predefined virtual key - restricted character set + // T_xxxx -- custom 'touch' virtual key - touch key id + // U_ABCD_1234 -- Unicode key id (1+ chars) + // x00 -- "ISO" key identifier (not currently supported in touch layout files) + if(!/^((K_[A-Z0-9_?]+)|(T_\S+)|(U_[0-9A-F_]+))$/.test(FId)) { + // note: |[A-Z][0-9][0-9] -- ISO key identifiers not currently supported + return TKeyIdType.Key_Invalid; + } switch(FId.charAt(0)) { case 'T': return TKeyIdType.Key_Touch; diff --git a/developer/src/kmc-kmn/test/fixtures/kmw/error_touch_layout_invalid_identifier.keyman-touch-layout b/developer/src/kmc-kmn/test/fixtures/kmw/error_touch_layout_invalid_identifier.keyman-touch-layout new file mode 100644 index 0000000000..e08e33eedc --- /dev/null +++ b/developer/src/kmc-kmn/test/fixtures/kmw/error_touch_layout_invalid_identifier.keyman-touch-layout @@ -0,0 +1,550 @@ +{ + "tablet": { + "displayUnderlying": false, + "layer": [ + { + "id": "default", + "row": [ + { + "id": 1, + "key": [ + { + "id": "U_1E6B[_0307]", + "text": "១" + }, + { + "id": "K_2", + "text": "២" + }, + { + "id": "K_3", + "text": "៣" + }, + { + "id": "K_4", + "text": "៤" + }, + { + "id": "K_5", + "text": "៥" + }, + { + "id": "K_6", + "text": "៦" + }, + { + "id": "K_7", + "text": "៧" + }, + { + "id": "K_8", + "text": "៨" + }, + { + "id": "K_9", + "text": "៩" + }, + { + "id": "K_0", + "text": "០" + }, + { + "id": "K_HYPHEN", + "text": "ឥ" + }, + { + "id": "K_EQUAL", + "text": "ឲ" + }, + { + "id": "K_BKSP", + "text": "*BkSp*", + "width": "100", + "sp": "1" + } + ] + }, + { + "id": 2, + "key": [ + { + "id": "K_Q", + "text": "ឆ", + "pad": "75" + }, + { + "id": "K_W", + "text": "" + }, + { + "id": "K_E", + "text": "" + }, + { + "id": "K_R", + "text": "រ" + }, + { + "id": "K_T", + "text": "ត" + }, + { + "id": "K_Y", + "text": "យ" + }, + { + "id": "K_U", + "text": "" + }, + { + "id": "K_I", + "text": "" + }, + { + "id": "K_O", + "text": "" + }, + { + "id": "K_P", + "text": "ផ" + }, + { + "id": "K_LBRKT", + "text": "" + }, + { + "id": "K_RBRKT", + "text": "ឪ" + }, + { + "id": "T_new_138", + "text": "", + "width": "10", + "sp": "10" + } + ] + }, + { + "id": 3, + "key": [ + { + "id": "K_BKQUOTE", + "text": "«" + }, + { + "id": "K_A", + "text": "" + }, + { + "id": "K_S", + "text": "ស" + }, + { + "id": "K_D", + "text": "ដ" + }, + { + "id": "K_F", + "text": "ថ" + }, + { + "id": "K_G", + "text": "ង" + }, + { + "id": "K_H", + "text": "ហ" + }, + { + "id": "K_J", + "text": "" + }, + { + "id": "K_K", + "text": "ក" + }, + { + "id": "K_L", + "text": "ល" + }, + { + "id": "K_COLON", + "text": "" + }, + { + "id": "K_QUOTE", + "text": "" + }, + { + "id": "K_BKSLASH", + "text": "ឮ" + } + ] + }, + { + "id": 4, + "key": [ + { + "id": "K_SHIFT", + "text": "*Shift*", + "width": "160", + "sp": "1", + "nextlayer": "shift" + }, + { + "id": "K_oE2", + "text": "" + }, + { + "id": "K_Z", + "text": "ឋ" + }, + { + "id": "K_X", + "text": "ខ" + }, + { + "id": "K_C", + "text": "ច" + }, + { + "id": "K_V", + "text": "វ" + }, + { + "id": "K_B", + "text": "ប" + }, + { + "id": "K_N", + "text": "ន" + }, + { + "id": "K_M", + "text": "ម" + }, + { + "id": "K_COMMA", + "text": "" + }, + { + "id": "K_PERIOD", + "text": "។" + }, + { + "id": "K_SLASH", + "text": "" + }, + { + "id": "T_new_164", + "text": "", + "width": "10", + "sp": "10" + } + ] + }, + { + "id": 5, + "key": [ + { + "id": "K_LCONTROL", + "text": "*AltGr*", + "width": "160", + "sp": "1" + }, + { + "id": "K_LOPT", + "text": "*Menu*", + "width": "160", + "sp": "1" + }, + { + "id": "K_SPACE", + "text": "​", + "width": "930" + }, + { + "id": "K_ENTER", + "text": "*Enter*", + "width": "160", + "sp": "1" + } + ] + } + ] + }, + { + "id": "shift", + "row": [ + { + "id": 1, + "key": [ + { + "id": "K_1", + "text": "!" + }, + { + "id": "K_2", + "text": "ៗ" + }, + { + "id": "K_3", + "text": "\"" + }, + { + "id": "K_4", + "text": "៛" + }, + { + "id": "K_5", + "text": "%" + }, + { + "id": "K_6", + "text": "" + }, + { + "id": "K_7", + "text": "" + }, + { + "id": "K_8", + "text": "" + }, + { + "id": "K_9", + "text": "(" + }, + { + "id": "K_0", + "text": ")" + }, + { + "id": "K_HYPHEN", + "text": "" + }, + { + "id": "K_EQUAL", + "text": "=" + }, + { + "id": "K_BKSP", + "text": "*BkSp*", + "width": "100", + "sp": "1" + } + ] + }, + { + "id": 2, + "key": [ + { + "id": "K_Q", + "text": "ឈ", + "pad": "75" + }, + { + "id": "K_W", + "text": "" + }, + { + "id": "K_E", + "text": "" + }, + { + "id": "K_R", + "text": "ឬ" + }, + { + "id": "K_T", + "text": "ទ" + }, + { + "id": "K_Y", + "text": "" + }, + { + "id": "K_U", + "text": "" + }, + { + "id": "K_I", + "text": "" + }, + { + "id": "K_O", + "text": "" + }, + { + "id": "K_P", + "text": "ភ" + }, + { + "id": "K_LBRKT", + "text": "" + }, + { + "id": "K_RBRKT", + "text": "ឧ" + }, + { + "id": "T_new_364", + "text": "", + "width": "10", + "sp": "10" + } + ] + }, + { + "id": 3, + "key": [ + { + "id": "K_BKQUOTE", + "text": "»" + }, + { + "id": "K_A", + "text": "" + }, + { + "id": "K_S", + "text": "" + }, + { + "id": "K_D", + "text": "ឌ" + }, + { + "id": "K_F", + "text": "ធ" + }, + { + "id": "K_G", + "text": "អ" + }, + { + "id": "K_H", + "text": "ះ" + }, + { + "id": "K_J", + "text": "ញ" + }, + { + "id": "K_K", + "text": "គ" + }, + { + "id": "K_L", + "text": "ឡ" + }, + { + "id": "K_COLON", + "text": "" + }, + { + "id": "K_QUOTE", + "text": "" + }, + { + "id": "K_BKSLASH", + "text": "ឭ" + } + ] + }, + { + "id": 4, + "key": [ + { + "id": "K_SHIFT", + "text": "*Shift*", + "width": "160", + "sp": "2", + "nextlayer": "default" + }, + { + "id": "K_oE2", + "text": "" + }, + { + "id": "K_Z", + "text": "ឍ" + }, + { + "id": "K_X", + "text": "ឃ" + }, + { + "id": "K_C", + "text": "ជ" + }, + { + "id": "K_V", + "text": "" + }, + { + "id": "K_B", + "text": "ព" + }, + { + "id": "K_N", + "text": "ណ" + }, + { + "id": "K_M", + "text": "" + }, + { + "id": "K_COMMA", + "text": "" + }, + { + "id": "K_PERIOD", + "text": "៕" + }, + { + "id": "K_SLASH", + "text": "?" + }, + { + "id": "T_new_390", + "text": "", + "width": "10", + "sp": "10" + } + ] + }, + { + "id": 5, + "key": [ + { + "id": "K_LCONTROL", + "text": "*AltGr*", + "width": "160", + "sp": "1" + }, + { + "id": "K_LOPT", + "text": "*Menu*", + "width": "160", + "sp": "1" + }, + { + "id": "K_SPACE", + "text": "", + "width": "930" + }, + { + "id": "K_ENTER", + "text": "*Enter*", + "width": "160", + "sp": "1" + } + ] + } + ] + } + ], + "font": "Arial" + } +} \ No newline at end of file diff --git a/developer/src/kmc-kmn/test/fixtures/kmw/error_touch_layout_invalid_identifier.kmn b/developer/src/kmc-kmn/test/fixtures/kmw/error_touch_layout_invalid_identifier.kmn new file mode 100644 index 0000000000..9dfa805bb5 --- /dev/null +++ b/developer/src/kmc-kmn/test/fixtures/kmw/error_touch_layout_invalid_identifier.kmn @@ -0,0 +1,10 @@ +store(&VERSION) '15.0' +store(&NAME) "error_touch_layout_invalid_identifier" +store(©RIGHT) '© 2015-2024 SIL Global' +store(&TARGETS) 'any' +store(&LAYOUTFILE) 'error_touch_layout_invalid_identifier.keyman-touch-layout' +store(&KEYBOARDVERSION) '1.3' + +begin Unicode > use(main) + +group(main) using keys \ No newline at end of file diff --git a/developer/src/kmc-kmn/test/kmw/kmw-compiler.tests.ts b/developer/src/kmc-kmn/test/kmw/kmw-compiler.tests.ts index 37a58384b2..d90b33c312 100644 --- a/developer/src/kmc-kmn/test/kmw/kmw-compiler.tests.ts +++ b/developer/src/kmc-kmn/test/kmw/kmw-compiler.tests.ts @@ -36,7 +36,9 @@ describe('KeymanWeb Compiler', function() { }); this.afterEach(function() { - callbacks.printMessages(); + if(this.currentTest?.isFailed() || debug) { + callbacks.printMessages(); + } callbacks.clear(); }); @@ -203,6 +205,19 @@ describe('KeymanWeb Compiler', function() { assert.isTrue(callbacks.hasMessage(KmwCompilerMessages.HINT_TouchLayoutUsesUnsupportedGesturesDownlevel)); }); + it('should give error ERROR_TouchLayoutInvalidIdentifier if a virtual key is badly formatted e.g. U_1234[_5678]', async function() { + // #12870 + const filenames = generateTestFilenames('error_touch_layout_invalid_identifier'); + + let result = await kmnCompiler.run(filenames.source, null); + assert.isNull(result); + assert.isFalse(callbacks.hasMessage(KmnCompilerMessages.INFO_MinimumCoreEngineVersion)); + assert.isFalse(callbacks.hasMessage(KmwCompilerMessages.INFO_MinimumWebEngineVersion)); + assert.isTrue(callbacks.hasMessage(KmwCompilerMessages.ERROR_TouchLayoutInvalidIdentifier)); + assert.isTrue(callbacks.hasMessage(KmwCompilerMessages.ERROR_InvalidTouchLayoutFile)); + assert.lengthOf(callbacks.messages, 2); + }); + }); async function run_test_keyboard(kmnCompiler: KmnCompiler, id: string): diff --git a/developer/src/kmc-package/src/compiler/kmp-compiler.ts b/developer/src/kmc-package/src/compiler/kmp-compiler.ts index 607823c7dd..cd6cf6df36 100644 --- a/developer/src/kmc-package/src/compiler/kmp-compiler.ts +++ b/developer/src/kmc-package/src/compiler/kmp-compiler.ts @@ -18,6 +18,8 @@ import { PackageKeyboardTargetValidator } from './package-keyboard-target-valida import { PackageMetadataUpdater } from './package-metadata-updater.js'; import { markdownToHTML } from './markdown.js'; import { PackageValidation } from './package-validation.js'; +import { getFontFamilySync } from "@keymanapp/developer-utils"; +import { setKvkFontData } from './kvk-font-facename.js'; const KMP_JSON_FILENAME = 'kmp.json'; const KMP_INF_FILENAME = 'kmp.inf'; @@ -483,36 +485,36 @@ export class KmpCompiler implements KeymanCompiler { this.callbacks.reportMessage(PackageCompilerMessages.Warn_AbsolutePath({filename: filename})); } + let memberFileData = this.getMemberFileData(kpsFilename, filename); + if(!memberFileData) { + failed = true; + return; + } filename = this.callbacks.resolveFilename(kpsFilename, filename); const basename = this.callbacks.path.basename(filename); - if(!this.callbacks.fs.existsSync(filename)) { - this.callbacks.reportMessage(PackageCompilerMessages.Error_FileDoesNotExist({filename: filename})); - failed = true; - return; + if(KeymanFileTypes.filenameIs(filename, KeymanFileTypes.Binary.VisualKeyboard)) { + if(!this.isKvkFileBinary(memberFileData)) { + // warn the few users who are still doing this -- non-binary .kvk should not be included in package! + this.callbacks.reportMessage(PackageCompilerMessages.Warn_FileIsNotABinaryKvkFile({filename: filename})); + } else { + memberFileData = this.setKvkFontData(kpsFilename, data, filename, memberFileData); + } } - let memberFileData; - try { - memberFileData = this.callbacks.loadFile(filename); - } catch(e) { - this.callbacks.reportMessage(PackageCompilerMessages.Error_FileCouldNotBeRead({filename: filename, e: e})); - failed = true; - return; - } - - this.warnIfKvkFileIsNotBinary(filename, memberFileData); - zip.file(basename, memberFileData); - - // Remove path data from files before JSON save - value.name = basename; }); if(failed) { return null; } + data.files.forEach((value) => { + const basename = this.callbacks.path.basename(value.name); + // Remove path data from files before JSON save + value.name = basename; + }); + // TODO #9477: transform .md to .htm // Remove path data from file references in options @@ -557,26 +559,87 @@ export class KmpCompiler implements KeymanCompiler { return transcodeToCP1252(s); } + private getMemberFileData(kpsFilename: string, filename: string) { + filename = this.callbacks.resolveFilename(kpsFilename, filename); + + if(!this.callbacks.fs.existsSync(filename)) { + this.callbacks.reportMessage(PackageCompilerMessages.Error_FileDoesNotExist({filename: filename})); + return null; + } + + try { + return this.callbacks.loadFile(filename); + } catch(e) { + this.callbacks.reportMessage(PackageCompilerMessages.Error_FileCouldNotBeRead({filename: filename, e: e})); + return null; + } + } + + private setKvkFontData(kpsFilename: string, data: KmpJsonFile.KmpJsonFile, filename: string, kvk: Uint8Array) { + // find the appropriate font to set + const kvkId = this.callbacks.path.basename(filename.toLowerCase(), '.kvk'); + const kbd = data.keyboards.find(k => k.id == kvkId); + if(!kbd) { + // cannot find a matching keyboard + this.callbacks.reportMessage(PackageCompilerMessages.Warn_CannotFindMatchingKeyboardForVisualKeyboard({filename})); + return kvk; + } + const fontFilename = kbd.oskFont || kbd.displayFont; + if(!fontFilename) { + // no font data to set + return kvk; + } + + // Look up the full font filename + const fontFile = data.files.find(file => this.callbacks.path.basename(file.name.toLowerCase()) == fontFilename.toLowerCase()); + if(!fontFile) { + // font cannot be found + this.callbacks.reportMessage(PackageCompilerMessages.Warn_CannotFindFontForKeyboard({id: kbd.id, fontFilename})); + return kvk; + } + + // the font is a filename, included in the .kps + const fontData = this.getMemberFileData(kpsFilename, fontFile.name); + if(!fontData) { + // cannot find source font + this.callbacks.reportMessage(PackageCompilerMessages.Warn_CannotFindFontForKeyboard({id: kbd.id, fontFilename: fontFile.name})); + return kvk; + } + + const fontFacename = getFontFamilySync(fontData); + if(!fontFacename) { + // Font facename could not be extracted from ttf + this.callbacks.reportMessage(PackageCompilerMessages.Warn_CannotReadFont({fontFilename: fontFile.name})); + return kvk; + } + + const result = setKvkFontData(kvk, fontFacename); + if(!result) { + // KVK is invalid + this.callbacks.reportMessage(PackageCompilerMessages.Warn_VisualKeyboardFileIsInvalid({filename})); + return kvk; + } + + return result; + } + /** * Legacy .kmp compiler would transform xml-format .kvk files into a binary .kvk file; now - * we want that to remain the responsibility of the keyboard compiler, so we'll warn the - * few users who are still doing this + * we want that to remain the responsibility of the keyboard compiler. */ - private warnIfKvkFileIsNotBinary(filename: string, data: Uint8Array) { - if(!KeymanFileTypes.filenameIs(filename, KeymanFileTypes.Binary.VisualKeyboard)) { - return; - } - + private isKvkFileBinary(data: Uint8Array) { if(data.byteLength < 4) { // TODO: Not a valid .kvk file; should we be reporting this? - return; + return false; } + // Must start with 'KVKF' if(data[0] != KvkFile.KVK_HEADER_IDENTIFIER_BYTES[0] || data[1] != KvkFile.KVK_HEADER_IDENTIFIER_BYTES[1] || data[2] != KvkFile.KVK_HEADER_IDENTIFIER_BYTES[2] || data[3] != KvkFile.KVK_HEADER_IDENTIFIER_BYTES[3]) { - this.callbacks.reportMessage(PackageCompilerMessages.Warn_FileIsNotABinaryKvkFile({filename: filename})); + return false; } + return true; } } diff --git a/developer/src/kmc-package/src/compiler/kvk-font-facename.ts b/developer/src/kmc-package/src/compiler/kvk-font-facename.ts new file mode 100644 index 0000000000..b7c69da8e8 --- /dev/null +++ b/developer/src/kmc-package/src/compiler/kvk-font-facename.ts @@ -0,0 +1,16 @@ +import { KvkFileReader, KvkFileWriter } from "@keymanapp/common-types"; + +export function setKvkFontData(fileData: Uint8Array, fontFacename: string) { + const reader = new KvkFileReader(); + const kvk = reader.read(fileData); + if(!kvk) { + // kvk is invalid + return null; + } + + kvk.header.ansiFont.name = fontFacename; + kvk.header.unicodeFont.name = fontFacename; + + const writer = new KvkFileWriter(); + return writer.write(kvk); +} \ No newline at end of file diff --git a/developer/src/kmc-package/src/compiler/package-compiler-messages.ts b/developer/src/kmc-package/src/compiler/package-compiler-messages.ts index f3df62a897..6545b7d963 100644 --- a/developer/src/kmc-package/src/compiler/package-compiler-messages.ts +++ b/developer/src/kmc-package/src/compiler/package-compiler-messages.ts @@ -22,16 +22,21 @@ export class PackageCompilerMessages { static Warn_AbsolutePath = (o:{filename: string}) => m(this.WARN_AbsolutePath, `File ${def(o.filename)} has an absolute path, which is not portable.`); static ERROR_FileDoesNotExist = SevError | 0x0003; - static Error_FileDoesNotExist = (o:{filename: string}) => m(this.ERROR_FileDoesNotExist, `File ${def(o.filename)} does not exist.`); + static Error_FileDoesNotExist = (o:{filename: string}) => m( + this.ERROR_FileDoesNotExist, `File '${def(o.filename)}' does not exist.` + ); static ERROR_FileCouldNotBeRead = SevError | 0x0004; - static Error_FileCouldNotBeRead = (o:{filename: string; e: any}) => m(this.ERROR_FileCouldNotBeRead, - `File ${def(o.filename)} could not be read: ${(o.e ?? 'unknown error').toString()}.`); + static Error_FileCouldNotBeRead = (o:{filename: string; e: any}) => m( + this.ERROR_FileCouldNotBeRead, + `File '${def(o.filename)}' could not be read: ${(o.e ?? 'unknown error').toString()}.` + ); static WARN_FileIsNotABinaryKvkFile = SevWarn | 0x0005; static Warn_FileIsNotABinaryKvkFile = (o:{filename: string}) => m(this.WARN_FileIsNotABinaryKvkFile, - `File ${def(o.filename)} does not appear to be a valid binary .kvk file; this may be an old package that includes an xml-format .kvk file. `+ - `You must update the package to include the compiled .kvk file in the package.`); + `File '${def(o.filename)}' does not appear to be a valid binary .kvk file; this may be an old package that includes an xml-format .kvk file. `+ + `You must update the package to include the compiled .kvk file in the package.` + ); static ERROR_FollowKeyboardVersionNotAllowedForModelPackages = SevError | 0x0006; static Error_FollowKeyboardVersionNotAllowedForModelPackages = () => m(this.ERROR_FollowKeyboardVersionNotAllowedForModelPackages, @@ -43,15 +48,18 @@ export class PackageCompilerMessages { static ERROR_KeyboardContentFileNotFound = SevError | 0x0008; static Error_KeyboardContentFileNotFound = (o:{id:string}) => m(this.ERROR_KeyboardContentFileNotFound, - `Keyboard ${def(o.id)} was listed in but a corresponding .kmx file was not found in `); + `Keyboard '${def(o.id)}' was listed in but a corresponding .kmx file was not found in ` + ); static ERROR_KeyboardFileNotValid = SevError | 0x0009; static Error_KeyboardFileNotValid = (o:{filename:string, e:any}) => m(this.ERROR_KeyboardFileNotValid, - `Keyboard file ${def(o.filename)} is not a valid .kmx file: ${(o.e ?? 'unknown error').toString()}`); + `Keyboard file '${def(o.filename)}' is not a valid .kmx file: ${(o.e ?? 'unknown error').toString()}` + ); static INFO_KeyboardFileHasNoKeyboardVersion = SevInfo | 0x000A; static Info_KeyboardFileHasNoKeyboardVersion = (o:{filename:string}) => m(this.INFO_KeyboardFileHasNoKeyboardVersion, - `Keyboard file ${def(o.filename)} has no &KeyboardVersion store, using default '0.0'`); + `Keyboard file '${def(o.filename)}' has no &KeyboardVersion store, using default '0.0'` + ); static ERROR_PackageCannotContainBothModelsAndKeyboards = SevError | 0x000B; static Error_PackageCannotContainBothModelsAndKeyboards = () => m(this.ERROR_PackageCannotContainBothModelsAndKeyboards, @@ -63,19 +71,22 @@ export class PackageCompilerMessages { static WARN_PackageNameDoesNotFollowLexicalModelConventions = SevWarn | 0x000D; static Warn_PackageNameDoesNotFollowLexicalModelConventions = (o:{filename: string}) => m(this.WARN_PackageNameDoesNotFollowLexicalModelConventions, - `The package file ${def(o.filename)} does not follow the recommended model filename conventions. The name should be all lower case, `+ + `The package file '${def(o.filename)}' does not follow the recommended model filename conventions. The name should be all lower case, `+ `include only alphanumeric characters and underscore (_), not start with a digit, and should have the structure `+ - `...model.kps.`); + `...model.kps.` + ); static WARN_PackageNameDoesNotFollowKeyboardConventions = SevWarn | 0x000E; static Warn_PackageNameDoesNotFollowKeyboardConventions = (o:{filename: string}) => m(this.WARN_PackageNameDoesNotFollowKeyboardConventions, - `The package file ${def(o.filename)} does not follow the recommended keyboard filename conventions. The name should be all lower case, `+ - `include only alphanumeric characters and underscore (_), and not start with a digit.`); + `The package file '${def(o.filename)}' does not follow the recommended keyboard filename conventions. The name should be all lower case, `+ + `include only alphanumeric characters and underscore (_), and not start with a digit.` + ); static WARN_FileInPackageDoesNotFollowFilenameConventions = SevWarn | 0x000F; static Warn_FileInPackageDoesNotFollowFilenameConventions = (o:{filename: string}) => m(this.WARN_FileInPackageDoesNotFollowFilenameConventions, - `The file ${def(o.filename)} does not follow the recommended filename conventions. The extension should be all lower case, `+ - `and the filename should include only alphanumeric characters, -, _, + and .`); + `The file '${def(o.filename)}' does not follow the recommended filename conventions. The extension should be all lower case, `+ + `and the filename should include only alphanumeric characters, -, _, + and .` + ); static ERROR_PackageNameCannotBeBlank = SevError | 0x0010; static Error_PackageNameCannotBeBlank = () => m(this.ERROR_PackageNameCannotBeBlank, @@ -83,7 +94,8 @@ export class PackageCompilerMessages { static ERROR_KeyboardFileNotFound = SevError | 0x0011; static Error_KeyboardFileNotFound = (o:{filename:string}) => m(this.ERROR_KeyboardFileNotFound, - `Keyboard file ${def(o.filename)} was not found. Has it been compiled?`); + `Keyboard file '${def(o.filename)}' was not found. Has it been compiled?` + ); static WARN_KeyboardVersionsDoNotMatch = SevWarn | 0x0012; static Warn_KeyboardVersionsDoNotMatch = (o: {keyboard:string, version:string, firstKeyboard:string, firstVersion:string}) => m(this.WARN_KeyboardVersionsDoNotMatch, @@ -129,7 +141,8 @@ export class PackageCompilerMessages { static HINT_PackageContainsSourceFile = SevHint | 0x001D; static Hint_PackageContainsSourceFile = (o:{filename:string}) => m(this.HINT_PackageContainsSourceFile, - `The source file ${def(o.filename)} should not be included in the package; instead include the compiled result.`); + `The source file '${def(o.filename)}' should not be included in the package; instead include the compiled result.` + ); // 0x001E was ERROR_InvalidPackageFile, now CommonTypesMessages.Error_InvalidPackageFile @@ -146,5 +159,30 @@ export class PackageCompilerMessages { this.ERROR_PackageFileHasEmptyVersion, `Package version is not following keyboard version, but the package version field is blank.` ); + + static ERROR_RequiredParameterMissing = SevError | 0x0022; + static Error_RequiredParameterMissing = (o:{param: string}) => m( + this.ERROR_RequiredParameterMissing, `Source parameter '${def(o.param)}' is required.` + ); + + static WARN_CannotFindMatchingKeyboardForVisualKeyboard = SevWarn | 0x0023; + static Warn_CannotFindMatchingKeyboardForVisualKeyboard = (o:{filename: string}) => m( + this.WARN_CannotFindMatchingKeyboardForVisualKeyboard, `Cannot find matching keyboard for visual keyboard file '${def(o.filename)}'.` + ); + + static WARN_CannotFindFontForKeyboard = SevWarn | 0x0024; + static Warn_CannotFindFontForKeyboard = (o:{id: string, fontFilename: string}) => m( + this.WARN_CannotFindFontForKeyboard, `Cannot find font file '${def(o.fontFilename)}' for keyboard '${def(o.id)}'.` + ); + + static WARN_CannotReadFont = SevWarn | 0x0025; + static Warn_CannotReadFont = (o:{fontFilename: string}) => m( + this.WARN_CannotReadFont, `Cannot parse font file '${def(o.fontFilename)}' to get facename.` + ); + + static WARN_VisualKeyboardFileIsInvalid = SevWarn | 0x0026; + static Warn_VisualKeyboardFileIsInvalid = (o:{filename: string}) => m( + this.WARN_VisualKeyboardFileIsInvalid, `Visual keyboard file '${def(o.filename)}' is invalid.` + ); } diff --git a/developer/src/kmc-package/src/compiler/windows-package-installer-compiler.ts b/developer/src/kmc-package/src/compiler/windows-package-installer-compiler.ts index f964e71c87..6abbf8cb22 100644 --- a/developer/src/kmc-package/src/compiler/windows-package-installer-compiler.ts +++ b/developer/src/kmc-package/src/compiler/windows-package-installer-compiler.ts @@ -130,7 +130,15 @@ export class WindowsPackageInstallerCompiler implements KeymanCompiler { } // Check existence of required files - for(const filename of [sources.licenseFilename, sources.msiFilename, sources.setupExeFilename]) { + for(const [param,filename] of [ + ['licenseFilename',sources.licenseFilename], + ['msiFilename',sources.msiFilename], + ['setupExeFilename',sources.setupExeFilename] + ]) { + if(!filename) { + this.callbacks.reportMessage(PackageCompilerMessages.Error_RequiredParameterMissing({param})); + return null; + } if(!this.callbacks.fs.existsSync(filename)) { this.callbacks.reportMessage(PackageCompilerMessages.Error_FileDoesNotExist({filename})); return null; @@ -186,6 +194,11 @@ export class WindowsPackageInstallerCompiler implements KeymanCompiler { private async buildZip(kps: KpsFile.KpsFile, kpsFilename: string, sources: WindowsPackageInstallerSources): Promise { const kmpJson: KmpJsonFile.KmpJsonFile = this.kmpCompiler.transformKpsFileToKmpObject(kpsFilename, kps); + if(!kmpJson) { + // error will have been reported in transformKpsFileToKmpObject + return null; + } + if(!kmpJson.info?.name?.description) { this.callbacks.reportMessage(PackageCompilerMessages.Error_PackageNameCannotBeBlank()); return null; @@ -194,6 +207,10 @@ export class WindowsPackageInstallerCompiler implements KeymanCompiler { const kmpFilename = this.callbacks.path.basename(kpsFilename, KeymanFileTypes.Source.Package) + KeymanFileTypes.Binary.Package; const setupInfBuffer = this.buildSetupInf(sources, kmpJson, kmpFilename, kps); const kmpBuffer = await this.kmpCompiler.buildKmpFile(kpsFilename, kmpJson); + if(!kmpBuffer) { + // error will have been reported in buildKmpFile + return null; + } // Note that this does not technically generate a "valid" sfx according to // the zip spec, because the offsets in the .zip are relative to the start diff --git a/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kmx b/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kmx new file mode 100644 index 0000000000..bd6b832cab Binary files /dev/null and b/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kmx differ diff --git a/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kps b/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kps index de17212a84..1a02984beb 100644 --- a/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kps +++ b/developer/src/kmc-package/test/fixtures/binary_kvk_file/source/binary_kvk_file.kps @@ -27,7 +27,7 @@ .kvk - ../../invalid/basic.kmx + binary_kvk_file.kmx Keyboard Basic 0 .kmx @@ -36,7 +36,7 @@ Basic - basic + binary_kvk_file 1.0 Khmer diff --git a/developer/src/kmc/build.sh b/developer/src/kmc/build.sh index 684a9acbea..29016300a2 100755 --- a/developer/src/kmc/build.sh +++ b/developer/src/kmc/build.sh @@ -53,6 +53,13 @@ function do_build() { #------------------------------------------------------------------------------------------------------------------- +function do_test() { + builder_do_typescript_tests 50 + ./test/command-line-tests.sh test +} + +#------------------------------------------------------------------------------------------------------------------- + function do_api() { rm -rf ./build/messages mkdir -p ./build/messages @@ -99,7 +106,7 @@ function do_bundle() { builder_run_action clean rm -rf ./build/ ./tsconfig.tsbuildinfo builder_run_action configure verify_npm_setup builder_run_action build do_build -builder_run_action test builder_do_typescript_tests 50 +builder_run_action test do_test builder_run_action api do_api builder_run_action bundle do_bundle builder_run_action publish builder_publish_npm diff --git a/developer/src/kmc/src/commands/build.ts b/developer/src/kmc/src/commands/build.ts index b8d5496c5b..996e767a77 100644 --- a/developer/src/kmc/src/commands/build.ts +++ b/developer/src/kmc/src/commands/build.ts @@ -60,9 +60,9 @@ If no input file is supplied, kmc will build the current folder.`) buildCommand .command('windows-package-installer ') .description('Build an executable installer for Windows for a Keyman package') - .option('--msi ', 'Location of keymandesktop.msi') - .option('--exe ', 'Location of setup.exe') - .option('--license ', 'Location of license.txt') + .requiredOption('--msi ', 'Location of keymandesktop.msi') + .requiredOption('--exe ', 'Location of setup.exe') + .requiredOption('--license ', 'Location of license.txt') .option('--title-image [titleImageFilename]', 'Location of title image') .option('--app-name [applicationName]', 'Installer property: name of the application to be installed', 'Keyman') .option('--start-disabled', 'Installer property: do not enable keyboards after installation completes') diff --git a/developer/src/kmc/src/commands/generate.ts b/developer/src/kmc/src/commands/generate.ts index bac724d9d4..c9d8dde391 100644 --- a/developer/src/kmc/src/commands/generate.ts +++ b/developer/src/kmc/src/commands/generate.ts @@ -45,7 +45,7 @@ function declareGenerateLdmlKeyboard(command: Command) { .option('-o, --out-path ', 'Output path (may exist)') .option('-n, --name ', 'Keyboard descriptive name') .option('-c, --copyright ', 'Copyright holder') /* © yyyy */ - .option('-v, --version ', 'Keyboard version', '1.0') + .option('-v, --version ', 'Keyboard version', '1.0.0') .option('-L, --language-tag ', 'BCP-47 language tag', (value, previous) => previous.concat([value]), []) .option('-a, --author ', 'Name of keyboard author') @@ -59,12 +59,12 @@ function declareGenerateLexicalModel(command: Command) { modelSubCommand .description('Generate a wordlist lexical model project') .option('-o, --out-path ', 'Output path (may exist)') - .option('-n, --name ', 'Keyboard descriptive name') + .option('-n, --name ', 'Lexical model descriptive name') .option('-c, --copyright ', 'Copyright holder') /* © yyyy */ - .option('-v, --version ', 'Keyboard version', '1.0') + .option('-v, --version ', 'Lexical model version', '1.0') .option('-L, --language-tag ', 'BCP-47 language tag', (value, previous) => previous.concat([value]), []) - .option('-a, --author ', 'Name of keyboard author') + .option('-a, --author ', 'Name of lexical model author') .option('-d, --description ', 'Short description of the project, Markdown') .action(generateLexicalModel); diff --git a/developer/src/kmc/src/kmc.ts b/developer/src/kmc/src/kmc.ts index ade7200ce8..f4c28aa68c 100644 --- a/developer/src/kmc/src/kmc.ts +++ b/developer/src/kmc/src/kmc.ts @@ -19,15 +19,23 @@ await TestKeymanSentry.runTestIfCLRequested(kmcSentryOptions); if(KeymanSentry.isEnabled()) { KeymanSentry.init(kmcSentryOptions); } -try { - await run(); -} catch(e) { - KeymanSentry.captureException(e); -} -// Ensure any messages reported to Sentry have had time to be uploaded before we -// exit. In most cases, this will be a no-op so should not affect performance. -await exitProcess(0); +run().then(async () => { + // Ensure any messages reported to Sentry have had time to be uploaded before we + // exit. In most cases, this will be a no-op so should not affect performance. + await exitProcess(0); +}, (reason: any) => { + KeymanSentry.captureException(reason); + // in local environment, captureException will + // return so we want to re-throw; note that this is + // a little noisy because it comes from an async function + console.error('Aborting due to fatal exception. Local development environment detected, so printing trace.'); + const report: any = process.report?.getReport(reason); + console.log(report?.javascriptStack?.message); + console.log(report?.javascriptStack?.stack?.map((s: string) => ' '+s).join('\n')); + // This cannot be an async function because otherwise the stack trace gets captured + exitProcess(1); +}); async function run() { await loadOptions(); @@ -62,6 +70,5 @@ async function run() { declarePublish(program); */ - await program.parseAsync(process.argv) - .catch(reason => console.error(reason)); + await program.parseAsync(process.argv); } \ No newline at end of file diff --git a/developer/src/kmc/test/command-line-tests.sh b/developer/src/kmc/test/command-line-tests.sh new file mode 100755 index 0000000000..74447afd41 --- /dev/null +++ b/developer/src/kmc/test/command-line-tests.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# Command line tests for kmc +# +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../../../resources/build/builder.inc.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +# . "$KEYMAN_ROOT/resources/build/build-utils-ci.inc.sh" +# . "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" +# . "$KEYMAN_ROOT/developer/src/packages.inc.sh" + +builder_describe "Test Keyman Keyboard Compiler kmc command line e2e" \ + "test" \ + "--verbose,-v Verbose logging of output from kmc" \ + "--filter,-f=FILTER Run tests that match FILTER" \ + ":basic Basic sanity tests" \ + ":analyze Test 'kmc analyze' command" \ + ":build Test 'kmc build' command" \ + ":copy Test 'kmc copy' command'" \ + ":generate Test 'kmc generate' command" \ + ":message Test 'kmc message' command" + +builder_parse "$@" + +if [[ -z ${KMC+x} ]]; then + KMC="node .." +fi + +FAILED_TESTS=0 +PASSED_TESTS=0 +SKIPPED_TESTS=0 + +function run_kmc() { + local EXPECTED_EXIT=$1 + local EXPECTED_MATCH="$2" + shift 2 + local ACTUAL_EXIT=0 + local MATCH_RESULT=0 + local DATA + + if builder_has_option --filter; then + if [[ ! "$@" =~ "$FILTER" ]]; then + builder_echo blue "🟦 SKIP 'kmc $@'" + SKIPPED_TESTS=$((SKIPPED_TESTS + 1)) + return 0 + fi + fi + + DATA=$($KMC "$@" 2>&1) || ACTUAL_EXIT=$? + echo "$DATA" | grep -qF "$EXPECTED_MATCH" || MATCH_RESULT=$? + + if [[ $ACTUAL_EXIT != $EXPECTED_EXIT ]]; then + builder_echo red "❌ FAIL 'kmc $@': expected exit code $EXPECTED_EXIT != actual exit code $ACTUAL_EXIT" + builder_echo grey "Actual output from 'kmc $@':" + echo "$DATA" + FAILED_TESTS=$(($FAILED_TESTS + 1)) + elif [[ $MATCH_RESULT != 0 ]]; then + builder_echo red "❌ FAIL 'kmc $@': expected string '$EXPECTED_MATCH' not found in output" + builder_echo grey "Actual output from 'kmc $@':" + echo "$DATA" + FAILED_TESTS=$(($FAILED_TESTS + 1)) + else + builder_echo "${COLOR_GREEN}✔${COLOR_RESET} PASS 'kmc $@'" + if builder_has_option --verbose; then + builder_echo grey "Actual output from 'kmc $@':" + echo "$DATA" + fi + PASSED_TESTS=$(($PASSED_TESTS + 1)) + fi +} + +function do_test_basic() { + run_kmc 0 "Keyman Developer Command Line Interface" --help + run_kmc 0 "kmc build [options] [command]" build --help + run_kmc 1 "error: unknown option '--bad-option'" --bad-option +} + +function build_cleanup() { + rm -rf ./fixtures/command-line-tests/build/khmer_angkor/build/ +} + +function do_test_build() { + # project/file builds + build_cleanup + run_kmc 0 "." build ./fixtures/command-line-tests/build/khmer_angkor/khmer_angkor.kpj + + build_cleanup + run_kmc 0 "." build file ./fixtures/command-line-tests/build/khmer_angkor/khmer_angkor.kpj + + build_cleanup + run_kmc 0 "." build ./fixtures/command-line-tests/build/khmer_angkor/ + + # windows-package-installer tests + # TODO: windows-package-installer is currently silent but should emit a 'success' message + #build_cleanup + run_kmc 0 "" build windows-package-installer \ + ./fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kps \ + --msi ./fixtures/command-line-tests/build/windows-installer/keymandesktop.txt \ + --exe ./fixtures/command-line-tests/build/windows-installer/setup.txt \ + --license ./fixtures/command-line-tests/build/windows-installer/license.txt \ + --out-file ./fixtures/command-line-tests/build/khmer_angkor/build/khmer_angkor.exe + + #build_cleanup + run_kmc 1 "" build windows-package-installer \ + ./fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kps \ + --msi ./fixtures/command-line-tests/build/windows-installer/keymandesktop.txt \ + --exe ./fixtures/command-line-tests/build/windows-installer/setup.txt \ + --out-file ./fixtures/command-line-tests/build/khmer_angkor/build/khmer_angkor.exe +} + +function do_test_analyze() { + builder_heading "kmc analyze" +} + +function do_test_copy() { + builder_heading "kmc copy" +} + +function do_test_generate() { + builder_heading "kmc generate" +} + +function do_test_message() { + run_kmc 1 "Invalid parameter" message zap + run_kmc 0 "ERROR_FileDoesNotExist" message 4003 +} + +builder_run_action test:basic do_test_basic +builder_run_action test:analyze do_test_analyze +builder_run_action test:build do_test_build +builder_run_action test:copy do_test_copy +builder_run_action test:generate do_test_generate +builder_run_action test:message do_test_message + +if builder_has_action test; then + builder_echo grey "$PASSED_TESTS test(s) passed" + builder_echo grey "$SKIPPED_TESTS test(s) skipped" + if [[ $FAILED_TESTS != 0 ]]; then + builder_echo red "$FAILED_TESTS test(s) failed" + exit 1 + else + builder_echo "0 test(s) failed" + fi +fi diff --git a/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/.gitignore b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/.gitignore new file mode 100644 index 0000000000..d16386367f --- /dev/null +++ b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/khmer_angkor.kpj b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/khmer_angkor.kpj new file mode 100644 index 0000000000..2b5513fc52 --- /dev/null +++ b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/khmer_angkor.kpj @@ -0,0 +1,12 @@ + + + + $PROJECTPATH\build + $PROJECTPATH\source + True + True + False + keyboard + 2.0 + + diff --git a/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.ico b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.ico new file mode 100644 index 0000000000..42c90c398d Binary files /dev/null and b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.ico differ diff --git a/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.keyman-touch-layout b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.keyman-touch-layout new file mode 100644 index 0000000000..711f849008 --- /dev/null +++ b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.keyman-touch-layout @@ -0,0 +1,1950 @@ +{ + "tablet": { + "displayUnderlying": false, + "layer": [ + { + "id": "default", + "row": [ + { + "id": 1, + "key": [ + { + "id": "K_1", + "text": "១" + }, + { + "id": "K_2", + "text": "២" + }, + { + "id": "K_3", + "text": "៣" + }, + { + "id": "K_4", + "text": "៤" + }, + { + "id": "K_5", + "text": "៥" + }, + { + "id": "K_6", + "text": "៦" + }, + { + "id": "K_7", + "text": "៧" + }, + { + "id": "K_8", + "text": "៨" + }, + { + "id": "K_9", + "text": "៩" + }, + { + "id": "K_0", + "text": "០" + }, + { + "id": "K_HYPHEN", + "text": "ឥ" + }, + { + "id": "K_EQUAL", + "text": "ឲ" + }, + { + "id": "K_BKSP", + "text": "*BkSp*", + "width": "100", + "sp": "1" + } + ] + }, + { + "id": 2, + "key": [ + { + "id": "K_Q", + "text": "ឆ", + "pad": "75" + }, + { + "id": "K_W", + "text": "" + }, + { + "id": "K_E", + "text": "" + }, + { + "id": "K_R", + "text": "រ" + }, + { + "id": "K_T", + "text": "ត" + }, + { + "id": "K_Y", + "text": "យ" + }, + { + "id": "K_U", + "text": "" + }, + { + "id": "K_I", + "text": "" + }, + { + "id": "K_O", + "text": "" + }, + { + "id": "K_P", + "text": "ផ" + }, + { + "id": "K_LBRKT", + "text": "" + }, + { + "id": "K_RBRKT", + "text": "ឪ" + }, + { + "id": "T_new_138", + "text": "", + "width": "10", + "sp": "10" + } + ] + }, + { + "id": 3, + "key": [ + { + "id": "K_BKQUOTE", + "text": "«" + }, + { + "id": "K_A", + "text": "" + }, + { + "id": "K_S", + "text": "ស" + }, + { + "id": "K_D", + "text": "ដ" + }, + { + "id": "K_F", + "text": "ថ" + }, + { + "id": "K_G", + "text": "ង" + }, + { + "id": "K_H", + "text": "ហ" + }, + { + "id": "K_J", + "text": "" + }, + { + "id": "K_K", + "text": "ក" + }, + { + "id": "K_L", + "text": "ល" + }, + { + "id": "K_COLON", + "text": "" + }, + { + "id": "K_QUOTE", + "text": "" + }, + { + "id": "K_BKSLASH", + "text": "ឮ" + } + ] + }, + { + "id": 4, + "key": [ + { + "id": "K_SHIFT", + "text": "*Shift*", + "width": "160", + "sp": "1", + "nextlayer": "shift" + }, + { + "id": "K_oE2", + "text": "" + }, + { + "id": "K_Z", + "text": "ឋ" + }, + { + "id": "K_X", + "text": "ខ" + }, + { + "id": "K_C", + "text": "ច" + }, + { + "id": "K_V", + "text": "វ" + }, + { + "id": "K_B", + "text": "ប" + }, + { + "id": "K_N", + "text": "ន" + }, + { + "id": "K_M", + "text": "ម" + }, + { + "id": "K_COMMA", + "text": "" + }, + { + "id": "K_PERIOD", + "text": "។" + }, + { + "id": "K_SLASH", + "text": "" + }, + { + "id": "T_new_164", + "text": "", + "width": "10", + "sp": "10" + } + ] + }, + { + "id": 5, + "key": [ + { + "id": "K_LCONTROL", + "text": "*AltGr*", + "width": "160", + "sp": "1", + "nextlayer": "rightalt" + }, + { + "id": "K_LOPT", + "text": "*Menu*", + "width": "160", + "sp": "1" + }, + { + "id": "K_SPACE", + "text": "​", + "width": "930" + }, + { + "id": "K_ENTER", + "text": "*Enter*", + "width": "160", + "sp": "1" + } + ] + } + ] + }, + { + "id": "rightalt", + "row": [ + { + "id": 1, + "key": [ + { + "id": "K_1", + "text": "‌" + }, + { + "id": "K_2", + "text": "@" + }, + { + "id": "K_3", + "text": "" + }, + { + "id": "K_4", + "text": "$" + }, + { + "id": "K_5", + "text": "€" + }, + { + "id": "K_6", + "text": "៙" + }, + { + "id": "K_7", + "text": "៚" + }, + { + "id": "K_8", + "text": "*" + }, + { + "id": "K_9", + "text": "{" + }, + { + "id": "K_0", + "text": "}" + }, + { + "id": "K_HYPHEN", + "text": "≈" + }, + { + "id": "K_EQUAL", + "text": "" + }, + { + "id": "K_BKSP", + "text": "*BkSp*", + "width": "100", + "sp": "1" + } + ] + }, + { + "id": 2, + "key": [ + { + "id": "K_Q", + "text": "ៜ", + "pad": "75" + }, + { + "id": "K_W", + "text": "" + }, + { + "id": "K_E", + "text": "ឯ" + }, + { + "id": "K_R", + "text": "ឫ" + }, + { + "id": "K_T", + "text": "ឨ" + }, + { + "id": "K_Y", + "text": "[" + }, + { + "id": "K_U", + "text": "]" + }, + { + "id": "K_I", + "text": "ឦ" + }, + { + "id": "K_O", + "text": "ឱ" + }, + { + "id": "K_P", + "text": "ឰ" + }, + { + "id": "K_LBRKT", + "text": "ឩ" + }, + { + "id": "K_RBRKT", + "text": "ឳ" + }, + { + "id": "T_new_307", + "text": "", + "width": "10", + "sp": "10" + } + ] + }, + { + "id": 3, + "key": [ + { + "id": "K_BKQUOTE", + "text": "‍" + }, + { + "id": "K_A", + "text": "+" + }, + { + "id": "K_S", + "text": "-" + }, + { + "id": "K_D", + "text": "×" + }, + { + "id": "K_F", + "text": "÷" + }, + { + "id": "K_G", + "text": ":" + }, + { + "id": "K_H", + "text": "‘" + }, + { + "id": "K_J", + "text": "’" + }, + { + "id": "K_K", + "text": "ឝ" + }, + { + "id": "K_L", + "text": "៘" + }, + { + "id": "K_COLON", + "text": "៖" + }, + { + "id": "K_QUOTE", + "text": "ៈ" + }, + { + "id": "K_BKSLASH", + "text": "\\" + } + ] + }, + { + "id": 4, + "key": [ + { + "id": "K_SHIFT", + "text": "*Shift*", + "width": "160", + "sp": "1", + "nextlayer": "shift" + }, + { + "id": "K_oE2", + "text": "" + }, + { + "id": "K_Z", + "text": "<" + }, + { + "id": "K_X", + "text": ">" + }, + { + "id": "K_C", + "text": "#" + }, + { + "id": "K_V", + "text": "&" + }, + { + "id": "K_B", + "text": "ឞ" + }, + { + "id": "K_N", + "text": ";" + }, + { + "id": "K_M", + "text": "" + }, + { + "id": "K_COMMA", + "text": "," + }, + { + "id": "K_PERIOD", + "text": "." + }, + { + "id": "K_SLASH", + "text": "/" + }, + { + "id": "T_new_333", + "text": "", + "width": "10", + "sp": "10" + } + ] + }, + { + "id": 5, + "key": [ + { + "id": "K_LCONTROL", + "text": "*AltGr*", + "width": "160", + "sp": "2", + "nextlayer": "default" + }, + { + "id": "K_LOPT", + "text": "*Menu*", + "width": "160", + "sp": "1" + }, + { + "id": "K_SPACE", + "text": " ", + "width": "930" + }, + { + "id": "K_ENTER", + "text": "*Enter*", + "width": "160", + "sp": "1" + } + ] + } + ] + }, + { + "id": "shift", + "row": [ + { + "id": 1, + "key": [ + { + "id": "K_1", + "text": "!" + }, + { + "id": "K_2", + "text": "ៗ" + }, + { + "id": "K_3", + "text": "\"" + }, + { + "id": "K_4", + "text": "៛" + }, + { + "id": "K_5", + "text": "%" + }, + { + "id": "K_6", + "text": "" + }, + { + "id": "K_7", + "text": "" + }, + { + "id": "K_8", + "text": "" + }, + { + "id": "K_9", + "text": "(" + }, + { + "id": "K_0", + "text": ")" + }, + { + "id": "K_HYPHEN", + "text": "" + }, + { + "id": "K_EQUAL", + "text": "=" + }, + { + "id": "K_BKSP", + "text": "*BkSp*", + "width": "100", + "sp": "1" + } + ] + }, + { + "id": 2, + "key": [ + { + "id": "K_Q", + "text": "ឈ", + "pad": "75" + }, + { + "id": "K_W", + "text": "" + }, + { + "id": "K_E", + "text": "" + }, + { + "id": "K_R", + "text": "ឬ" + }, + { + "id": "K_T", + "text": "ទ" + }, + { + "id": "K_Y", + "text": "" + }, + { + "id": "K_U", + "text": "" + }, + { + "id": "K_I", + "text": "" + }, + { + "id": "K_O", + "text": "" + }, + { + "id": "K_P", + "text": "ភ" + }, + { + "id": "K_LBRKT", + "text": "" + }, + { + "id": "K_RBRKT", + "text": "ឧ" + }, + { + "id": "T_new_364", + "text": "", + "width": "10", + "sp": "10" + } + ] + }, + { + "id": 3, + "key": [ + { + "id": "K_BKQUOTE", + "text": "»" + }, + { + "id": "K_A", + "text": "" + }, + { + "id": "K_S", + "text": "" + }, + { + "id": "K_D", + "text": "ឌ" + }, + { + "id": "K_F", + "text": "ធ" + }, + { + "id": "K_G", + "text": "អ" + }, + { + "id": "K_H", + "text": "ះ" + }, + { + "id": "K_J", + "text": "ញ" + }, + { + "id": "K_K", + "text": "គ" + }, + { + "id": "K_L", + "text": "ឡ" + }, + { + "id": "K_COLON", + "text": "" + }, + { + "id": "K_QUOTE", + "text": "" + }, + { + "id": "K_BKSLASH", + "text": "ឭ" + } + ] + }, + { + "id": 4, + "key": [ + { + "id": "K_SHIFT", + "text": "*Shift*", + "width": "160", + "sp": "2", + "nextlayer": "default" + }, + { + "id": "K_oE2", + "text": "" + }, + { + "id": "K_Z", + "text": "ឍ" + }, + { + "id": "K_X", + "text": "ឃ" + }, + { + "id": "K_C", + "text": "ជ" + }, + { + "id": "K_V", + "text": "" + }, + { + "id": "K_B", + "text": "ព" + }, + { + "id": "K_N", + "text": "ណ" + }, + { + "id": "K_M", + "text": "" + }, + { + "id": "K_COMMA", + "text": "" + }, + { + "id": "K_PERIOD", + "text": "៕" + }, + { + "id": "K_SLASH", + "text": "?" + }, + { + "id": "T_new_390", + "text": "", + "width": "10", + "sp": "10" + } + ] + }, + { + "id": 5, + "key": [ + { + "id": "K_LCONTROL", + "text": "*AltGr*", + "width": "160", + "sp": "1", + "nextlayer": "rightalt" + }, + { + "id": "K_LOPT", + "text": "*Menu*", + "width": "160", + "sp": "1" + }, + { + "id": "K_SPACE", + "text": "", + "width": "930" + }, + { + "id": "K_ENTER", + "text": "*Enter*", + "width": "160", + "sp": "1" + } + ] + } + ] + } + ], + "font": "Khmer Busra Kbd", + "fontsize": "0.8em" + }, + "phone": { + "layer": [ + { + "id": "default", + "row": [ + { + "id": 1, + "key": [ + { + "id": "K_Q", + "text": "ឆ", + "pad": "", + "sk": [ + { + "text": "ឈ", + "id": "K_Q", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_1786" + }, + { + "text": "", + "id": "T_17D2_1788" + } + ] + }, + { + "id": "K_W", + "text": "", + "sk": [ + { + "text": "", + "id": "K_W", + "layer": "shift" + } + ] + }, + { + "id": "K_E", + "text": "", + "sk": [ + { + "text": "", + "id": "K_E", + "layer": "shift" + }, + { + "text": "", + "id": "K_S", + "layer": "shift" + }, + { + "text": "", + "id": "K_V", + "layer": "shift" + }, + { + "text": "ឯ", + "id": "U_17AF" + }, + { + "text": "ឰ", + "id": "U_17B0" + } + ] + }, + { + "id": "K_R", + "text": "រ", + "sk": [ + { + "text": "", + "id": "T_17D2_179A" + }, + { + "text": "ឫ", + "id": "U_17AB" + }, + { + "text": "ឬ", + "id": "U_17AC" + } + ] + }, + { + "id": "K_T", + "text": "ត", + "sk": [ + { + "text": "ទ", + "id": "K_T", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_178F" + }, + { + "text": "", + "id": "T_17D2_1791", + "layer": "default" + } + ] + }, + { + "id": "K_Y", + "text": "យ", + "sk": [ + { + "text": "", + "id": "T_17D2_1799" + } + ] + }, + { + "id": "K_U", + "text": "", + "sk": [ + { + "text": "", + "id": "K_U", + "layer": "shift" + }, + { + "text": "", + "id": "K_Y", + "layer": "shift" + }, + { + "text": "ឧ", + "id": "U_17A7" + }, + { + "text": "ឪ", + "id": "U_17AA", + "layer": "shift" + }, + { + "text": "ឩ", + "id": "U_17A9", + "layer": "shift" + }, + { + "text": "ឨ", + "id": "U_17A8" + } + ] + }, + { + "id": "K_I", + "text": "", + "sk": [ + { + "text": "", + "id": "K_I", + "layer": "shift" + }, + { + "text": "ឥ", + "id": "U_17A5" + }, + { + "text": "ឦ", + "id": "U_17A6", + "layer": "shift" + } + ] + }, + { + "id": "K_O", + "text": "", + "sk": [ + { + "text": "", + "id": "K_O", + "layer": "shift" + }, + { + "text": "", + "id": "K_LBRKT" + }, + { + "text": "", + "id": "K_LBRKT", + "layer": "shift" + }, + { + "text": "", + "id": "K_COLON", + "layer": "shift" + }, + { + "text": "ឱ", + "id": "U_17B1" + }, + { + "text": "ឲ", + "id": "U_17B2" + }, + { + "text": "ឳ", + "id": "U_17B3", + "layer": "shift" + } + ] + }, + { + "id": "K_P", + "text": "ផ", + "sk": [ + { + "text": "ភ", + "id": "K_P", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_1795" + }, + { + "text": "", + "id": "T_17D2_1797", + "layer": "default" + } + ] + } + ] + }, + { + "id": 2, + "key": [ + { + "id": "K_A", + "text": "", + "pad": "", + "width": "100", + "sk": [ + { + "text": "", + "id": "K_A", + "layer": "shift" + } + ] + }, + { + "id": "K_S", + "text": "ស", + "sk": [ + { + "text": "", + "id": "T_17D2_179F" + }, + { + "text": "ឝ", + "id": "U_179D" + }, + { + "text": "ឞ", + "id": "U_179E" + } + ] + }, + { + "id": "K_D", + "text": "ដ", + "sk": [ + { + "text": "ឌ", + "id": "K_D", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_178A" + }, + { + "text": "", + "id": "T_17D2_178C", + "layer": "default" + } + ] + }, + { + "id": "K_F", + "text": "ថ", + "sk": [ + { + "text": "ធ", + "id": "K_F", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_1790" + }, + { + "text": "", + "id": "T_17D2_1792", + "layer": "default" + } + ] + }, + { + "id": "K_G", + "text": "ង", + "sk": [ + { + "text": "អ", + "id": "K_G", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_1784" + }, + { + "text": "", + "id": "T_17D2_17A2", + "layer": "default" + } + ] + }, + { + "id": "K_H", + "text": "ហ", + "sk": [ + { + "text": "", + "id": "T_17D2_17A0" + }, + { + "text": "ះ", + "id": "K_H", + "layer": "shift" + }, + { + "text": "ៈ", + "id": "U_17C8" + } + ] + }, + { + "id": "K_J", + "text": "ញ", + "layer": "shift", + "sk": [ + { + "text": "", + "id": "T_17D2_1789" + } + ] + }, + { + "id": "K_K", + "text": "ក", + "sk": [ + { + "text": "គ", + "id": "K_K", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_1780" + }, + { + "text": "", + "id": "T_17D2_1782" + } + ] + }, + { + "id": "K_L", + "text": "ល", + "sk": [ + { + "text": "ឡ", + "id": "K_L", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_179B" + }, + { + "text": "ឭ", + "id": "U_17AD" + }, + { + "text": "ឮ", + "id": "U_17AE" + } + ] + }, + { + "id": "K_COLON", + "text": "" + } + ] + }, + { + "id": 3, + "key": [ + { + "id": "K_Z", + "text": "ឋ", + "pad": "", + "width": "", + "sk": [ + { + "text": "ឍ", + "id": "K_Z", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_178B" + }, + { + "text": "", + "id": "T_17D2_178D", + "layer": "default" + } + ] + }, + { + "id": "K_X", + "text": "ខ", + "sk": [ + { + "text": "ឃ", + "id": "K_X", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_1781" + }, + { + "text": "", + "id": "T_17D2_1783", + "layer": "default" + } + ] + }, + { + "id": "K_C", + "text": "ច", + "sk": [ + { + "text": "ជ", + "id": "K_C", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_1785" + }, + { + "text": "", + "id": "T_17D2_1787", + "layer": "default" + } + ] + }, + { + "id": "K_V", + "text": "វ", + "sk": [ + { + "text": "", + "id": "T_17D2_179C" + } + ] + }, + { + "id": "K_B", + "text": "ប", + "sk": [ + { + "text": "ព", + "id": "K_B", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_1794" + }, + { + "text": "", + "id": "T_17D2_1796", + "layer": "default" + } + ] + }, + { + "id": "K_N", + "text": "ន", + "sk": [ + { + "text": "ណ", + "id": "K_N", + "layer": "shift" + }, + { + "text": "", + "id": "T_17D2_1793" + }, + { + "text": "", + "id": "T_17D2_178E", + "layer": "default" + } + ] + }, + { + "id": "K_M", + "text": "ម", + "sk": [ + { + "text": "", + "id": "T_17D2_1798" + }, + { + "text": "", + "id": "K_M", + "layer": "shift" + } + ] + }, + { + "id": "K_COMMA", + "text": "", + "sk": [ + { + "text": "", + "id": "K_COMMA", + "layer": "shift" + }, + { + "text": "", + "id": "K_6", + "layer": "shift" + }, + { + "text": "", + "id": "K_7", + "layer": "shift" + }, + { + "text": "", + "id": "K_8", + "layer": "shift" + }, + { + "text": "", + "id": "K_HYPHEN", + "layer": "shift" + }, + { + "text": "", + "id": "U_17D1", + "layer": "shift" + }, + { + "text": "", + "id": "U_17DD", + "layer": "shift" + }, + { + "text": "", + "id": "U_17CE", + "layer": "shift" + } + ] + }, + { + "id": "K_QUOTE", + "text": "", + "width": "100", + "sk": [ + { + "text": "", + "id": "K_QUOTE", + "layer": "shift" + }, + { + "text": "", + "id": "K_SLASH" + } + ] + }, + { + "id": "K_BKSP", + "text": "*BkSp*", + "width": "100", + "sp": "1" + } + ] + }, + { + "id": 4, + "key": [ + { + "id": "K_NUMLOCK", + "text": "១២៣", + "width": "140", + "sp": "1", + "nextlayer": "numeric" + }, + { + "id": "K_LOPT", + "text": "*Menu*", + "width": "120", + "sp": "1" + }, + { + "id": "K_SPACE", + "text": "​", + "width": "555", + "sp": "0", + "sk": [ + { + "text": " ", + "id": "U_0020", + "layer": "default" + } + ] + }, + { + "id": "K_PERIOD", + "text": "។", + "width": "120", + "sk": [ + { + "text": "៕", + "id": "K_PERIOD", + "layer": "shift" + }, + { + "text": "!", + "id": "U_0021" + }, + { + "text": "?", + "id": "U_003F" + } + ] + }, + { + "id": "K_ENTER", + "text": "*Enter*", + "width": "140", + "sp": "1" + } + ] + } + ] + }, + { + "id": "numeric", + "row": [ + { + "id": 1, + "key": [ + { + "id": "K_1", + "text": "១", + "pad": "", + "sk": [ + { + "text": "1", + "id": "U_0031" + } + ] + }, + { + "id": "K_2", + "text": "២", + "sk": [ + { + "text": "2", + "id": "U_0032" + } + ] + }, + { + "id": "K_3", + "text": "៣", + "sk": [ + { + "text": "3", + "id": "U_0033" + } + ] + }, + { + "id": "K_4", + "text": "៤", + "sk": [ + { + "text": "4", + "id": "U_0034" + } + ] + }, + { + "id": "K_5", + "text": "៥", + "sk": [ + { + "text": "5", + "id": "U_0035" + } + ] + }, + { + "id": "K_6", + "text": "៦", + "sk": [ + { + "text": "6", + "id": "U_0036" + } + ] + }, + { + "id": "K_7", + "text": "៧", + "sk": [ + { + "text": "7", + "id": "U_0037" + } + ] + }, + { + "id": "K_8", + "text": "៨", + "sk": [ + { + "text": "8", + "id": "U_0038" + } + ] + }, + { + "id": "K_9", + "text": "៩", + "sk": [ + { + "text": "9", + "id": "U_0039" + } + ] + }, + { + "id": "K_0", + "text": "០", + "sk": [ + { + "text": "0", + "id": "U_0030" + }, + { + "text": "", + "id": "U_17D3" + } + ] + } + ] + }, + { + "id": 2, + "key": [ + { + "id": "U_0040", + "text": "@", + "pad": "", + "sk": [ + { + "text": "©", + "id": "U_00A9" + }, + { + "text": "®", + "id": "U_00AE" + } + ] + }, + { + "id": "U_0023", + "text": "#", + "sk": [ + { + "text": "№", + "id": "U_2116" + }, + { + "text": "~", + "id": "U_007E" + } + ] + }, + { + "id": "U_17DB", + "text": "៛", + "sk": [ + { + "text": "$", + "id": "U_0024" + }, + { + "text": "฿", + "id": "U_0E3F" + }, + { + "text": "¢", + "id": "U_00A2" + }, + { + "text": "£", + "id": "U_00A3" + }, + { + "text": "¥", + "id": "U_00A5" + } + ] + }, + { + "id": "U_0026", + "text": "&" + }, + { + "id": "U_0025", + "text": "%", + "sk": [ + { + "text": "‰", + "id": "U_2030" + }, + { + "text": "‱", + "id": "U_2031" + } + ] + }, + { + "id": "U_002B", + "text": "+", + "sk": [ + { + "text": "-", + "id": "U_002D" + }, + { + "text": "×", + "id": "U_00D7" + }, + { + "text": "÷", + "id": "U_00F7" + }, + { + "text": "±", + "id": "U_00B1" + } + ] + }, + { + "id": "U_003D", + "text": "=", + "sk": [ + { + "text": "_", + "id": "U_005F" + }, + { + "text": "≠", + "id": "U_2260" + } + ] + }, + { + "id": "U_002A", + "text": "*", + "sk": [ + { + "text": "^", + "id": "U_005E" + } + ] + }, + { + "id": "U_003F", + "text": "?", + "sk": [ + { + "text": "¿", + "id": "U_00BF" + } + ] + }, + { + "id": "U_0021", + "text": "!", + "sk": [ + { + "text": "¡", + "id": "U_00A1" + } + ] + } + ] + }, + { + "id": 3, + "key": [ + { + "id": "U_2018", + "text": "‘", + "sk": [ + { + "text": "’", + "id": "U_2019" + } + ] + }, + { + "id": "U_201C", + "text": "“", + "sk": [ + { + "text": "”", + "id": "U_201D" + } + ] + }, + { + "id": "U_00AB", + "text": "«", + "pad": "", + "sk": [ + { + "text": "»", + "id": "U_00BB" + } + ] + }, + { + "id": "U_002F", + "text": "/", + "sk": [ + { + "text": "\\", + "id": "U_005C" + }, + { + "text": "|", + "id": "U_007C" + }, + { + "text": "¦", + "id": "U_00A6" + } + ] + }, + { + "id": "U_0028", + "text": "(", + "sk": [ + { + "text": ")", + "id": "U_0029" + }, + { + "text": "[", + "id": "U_005B" + }, + { + "text": "]", + "id": "U_005D" + }, + { + "text": "{", + "id": "U_007B" + }, + { + "text": "}", + "id": "U_007D" + } + ] + }, + { + "id": "U_17D9", + "text": "៙", + "sk": [ + { + "text": "៚", + "id": "U_17DA" + }, + { + "text": "ៜ", + "id": "U_17DC" + }, + { + "text": "§", + "id": "U_00A7" + }, + { + "text": "Ø", + "id": "U_00D8" + } + ] + }, + { + "id": "U_17D7", + "text": "ៗ" + }, + { + "id": "U_003C", + "text": "<", + "sk": [ + { + "text": "≤", + "id": "U_2264" + }, + { + "text": ">", + "id": "U_003E" + }, + { + "text": "≥", + "id": "U_2265" + } + ] + }, + { + "id": "U_17D6", + "text": "៖", + "sk": [ + { + "text": ":", + "id": "U_003A" + }, + { + "text": ";", + "id": "U_003B" + }, + { + "text": "…", + "id": "U_2026" + } + ] + }, + { + "id": "K_BKSP", + "text": "*BkSp*", + "width": "", + "sp": "1" + } + ] + }, + { + "id": 4, + "key": [ + { + "id": "K_LCONTROL", + "text": "១២៣", + "width": "140", + "sp": "2", + "nextlayer": "default" + }, + { + "id": "K_LOPT", + "text": "*Menu*", + "width": "120", + "sp": "1" + }, + { + "id": "K_SPACE", + "text": "​", + "width": "555", + "sp": "0", + "layer": "shift", + "sk": [] + }, + { + "id": "K_PERIOD", + "text": "។", + "width": "120", + "sk": [ + { + "text": "៕", + "id": "K_PERIOD", + "layer": "shift" + }, + { + "text": "!", + "id": "U_0021" + }, + { + "text": "?", + "id": "U_003F" + } + ] + }, + { + "id": "K_ENTER", + "text": "*Enter*", + "width": "140", + "sp": "1" + } + ] + } + ] + } + ], + "displayUnderlying": false, + "font": "Khmer Busra Kbd", + "fontsize": "0.8em" + } +} \ No newline at end of file diff --git a/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kmn b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kmn new file mode 100644 index 0000000000..b7919afca3 --- /dev/null +++ b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kmn @@ -0,0 +1,642 @@ +store(&VERSION) '10.0' +store(&NAME) "Khmer Angkor" +store(©RIGHT) '© 2015-2022 SIL International' +store(&MESSAGE) "More than just a Khmer Unicode keyboard." +store(&TARGETS) 'any' +store(&LAYOUTFILE) 'khmer_angkor.keyman-touch-layout' +store(&KEYBOARDVERSION) '1.3' +store(&BITMAP) 'khmer_angkor.ico' +store(&VISUALKEYBOARD) 'khmer_angkor.kvks' + +begin Unicode > use(main) + + + +c ==============================================STORES============================================== + + + +c 33 consonants and two deprecated consonants + +store(c_key) [K_K] [K_X] [SHIFT K_K] [SHIFT K_X] [K_G] \ + [K_C] [K_Q] [SHIFT K_C] [SHIFT K_Q] [SHIFT K_J] \ + [K_D] [K_Z] [SHIFT K_D] [SHIFT K_Z] [SHIFT K_N] \ + [K_T] [K_F] [SHIFT K_T] [SHIFT K_F] [K_N] \ + [K_B] [K_P] [SHIFT K_B] [SHIFT K_P] [K_M] \ + [K_Y] [K_R] [K_L] [K_V] [K_S] [K_H] [SHIFT K_L] [SHIFT K_G] \ + [RALT K_K] [RALT K_B] +store(c_out) U+1780 U+1781 U+1782 U+1783 U+1784 \ + U+1785 U+1786 U+1787 U+1788 U+1789 \ + U+178A U+178B U+178C U+178D U+178E \ + U+178F U+1790 U+1791 U+1792 U+1793 \ + U+1794 U+1795 U+1796 U+1797 U+1798 \ + U+1799 U+179A U+179B U+179C U+179F U+17A0 U+17A1 U+17A2 \ + U+179D U+179E c deprecated, but they are used in minority languages + +c All genuine dependent vowels + +store(v_gen_key) [K_A] [K_I] [SHIFT K_I] [K_W] [SHIFT K_W] [K_U] [SHIFT K_U] [SHIFT K_Y] \ + [K_COLON] [SHIFT K_LBRKT] [K_LBRKT] [K_E] [SHIFT K_E] [SHIFT K_S] [K_O] [SHIFT K_O] +store(v_gen) U+17B6 U+17B7 U+17B8 U+17B9 U+17BA U+17BB U+17BC U+17BD \ + U+17BE U+17BF U+17C0 U+17C1 U+17C2 U+17C3 U+17C4 U+17C5 + +c All pseudo dependent vowels + +store(v_pseudo_key) [SHIFT K_M] [SHIFT K_H] [RALT K_QUOTE] +store(v_pseudo) U+17C6 U+17C7 U+17C8 + +c Both genuine and pseudo vowels + +store(v_key) outs(v_gen_key) outs(v_pseudo_key) +store(v_out) outs(v_gen) outs(v_pseudo) +store(v_any) outs(v_out) + +c These seven vowels can be concatenated with Reahmuk (U+17C7) to make េះ ោះ ុះ ិះ ីះ ឹះ and ែះ. + +store(v_combo_R) U+17C1 U+17C4 U+17BB U+17B7 U+17B8 U+17B9 U+17C2 + +c these two vowels can be concatenated with Nikahit (U+17C6) to make ាំ and ុំ. + +store(v_combo_N) U+17B6 U+17BB +store(v_combo) outs(v_combo_R) outs(v_combo_N) + +c Independent vowels + +store(ind_v_key) [K_HYPHEN] [RALT K_I] [SHIFT K_RBRKT] [RALT K_T] [RALT K_LBRKT] [K_RBRKT] [RALT K_R] [SHIFT K_R] \ + [SHIFT K_BKSLASH] [K_BKSLASH] [RALT K_E] [RALT K_P] [RALT K_O] [K_EQUAL] [RALT K_RBRKT] +store(ind_v_out) U+17A5 U+17A6 U+17A7 U+17A8 U+17A9 U+17AA U+17AB U+17AC \ + U+17AD U+17AE U+17AF U+17B0 U+17B1 U+17B2 U+17B3 + +c Diacritics + +store(diacritic_key) [K_QUOTE] [SHIFT K_7] [SHIFT K_HYPHEN] [SHIFT K_8] [SHIFT K_6] [RALT K_QUOTE] \ + [RALT K_EQUAL] [RALT K_3] [RALT K_W] [RALT K_Q] [K_J] +store(diacritic_out) U+17CB U+17D0 U+17CC U+17CF U+17CD U+17C8 \ + U+17CE U+17D1 U+17DD U+17DC U+17D2 + +c Consonant shifter--Muusikatoan(U+17C9) and Triisap(U+17CA) + +store(c_shifter_key) [SHIFT K_QUOTE] [K_SLASH] +store(c_shifter) U+17C9 U+17CA + +c punctuations + +store(punct_key) [K_PERIOD] [SHIFT K_PERIOD] [RALT K_COLON] [SHIFT K_2] [RALT K_L] [RALT K_6] [RALT K_7] [RALT K_M] +store(punct_out) U+17D4 U+17D5 U+17D6 U+17D7 U+17D8 U+17D9 U+17DA U+17D3 + +store(latin_punct_key) [K_BKQUOTE] [SHIFT K_BKQUOTE] [SHIFT K_9] [SHIFT K_0] [SHIFT K_1] [SHIFT K_3] \ + [SHIFT K_5] [SHIFT K_EQUAL] [SHIFT K_SLASH] [RALT K_9] [RALT K_0] [RALT K_BKSLASH] \ + [RALT K_2] [RALT K_8] [RALT K_COMMA] [RALT K_D] [RALT K_PERIOD] [RALT K_SLASH] \ + [RALT K_Y] [RALT K_U] [RALT K_BKQUOTE] [RALT K_1] [RALT K_A] [RALT K_S] \ + [RALT K_F] [RALT K_G] [RALT K_HYPHEN] [RALT K_H] [RALT K_J] [RALT K_N] \ + [RALT K_Z] [RALT K_X] [RALT K_C] [RALT K_V] +store(latin_punct_out) U+00AB U+00BB U+0028 U+0029 U+0021 U+0022 \ + U+0025 U+003D U+003F U+007B U+007D U+005C \ + U+0040 U+002A U+002C U+00D7 U+002E U+002F \ + U+005B U+005D U+200D U+200C U+002B U+002D \ + U+00F7 U+003A U+2248 U+2018 U+2019 U+003B \ + U+003C U+003E U+0023 U+0026 +c white space, ZWSP, ZWJ +store(spaces_key) [K_SPACE] [SHIFT K_SPACE] [RALT K_SPACE] +store(spaces_out) U+200B U+0020 U+00A0 + +c Currency: Riel, Dollar and Euro + +store(currency_key) [SHIFT K_4] [RALT K_4] [RALT K_5] +store(currency_out) U+17DB U+0024 U+20AC + +c Khmer digits 0 through 9 + +store(digit_key) [K_0] [K_1] [K_2] [K_3] [K_4] [K_5] [K_6] [K_7] [K_8] [K_9] +store(digit_out) U+17E0 U+17E1 U+17E2 U+17E3 U+17E4 U+17E5 U+17E6 U+17E7 U+17E8 U+17E9 + +c Khmer Lek Attak (a.k.a. divination lore) +store(lek_attak_key) [SHIFT RALT K_0] [SHIFT RALT K_1] [SHIFT RALT K_2] [SHIFT RALT K_3] [SHIFT RALT K_4] \ + [SHIFT RALT K_5] [SHIFT RALT K_6] [SHIFT RALT K_7] [SHIFT RALT K_8] [SHIFT RALT K_9] +store(lek_attak_out) U+17F0 U+17F1 U+17F2 U+17F3 U+17F4 \ + U+17F5 U+17F6 U+17F7 U+17F8 U+17F9 + +c Lunar date format (Khmer style) + +store(lunar_date_key) [SHIFT RALT K_A] [SHIFT RALT K_B] [SHIFT RALT K_C] [SHIFT RALT K_D] [SHIFT RALT K_E] \ + [SHIFT RALT K_F] [SHIFT RALT K_G] [SHIFT RALT K_H] [SHIFT RALT K_I] [SHIFT RALT K_J] \ + [SHIFT RALT K_K] [SHIFT RALT K_L] [SHIFT RALT K_M] [SHIFT RALT K_N] [SHIFT RALT K_O] \ + [SHIFT RALT K_P] [SHIFT RALT K_Q] [SHIFT RALT K_R] [SHIFT RALT K_S] [SHIFT RALT K_T] \ + [SHIFT RALT K_U] [SHIFT RALT K_V] [SHIFT RALT K_W] [SHIFT RALT K_X] [SHIFT RALT K_Y] \ + [SHIFT RALT K_Z] [SHIFT RALT K_COLON] [SHIFT RALT K_COMMA] [SHIFT RALT K_PERIOD] [SHIFT RALT K_LBRKT] \ + [SHIFT RALT K_RBRKT] [SHIFT RALT K_QUOTE] +store(lunar_date_out) U+19EC U+19FB U+19F9 U+19EE U+19E2 \ + U+19EF U+19F0 U+19F1 U+19E7 U+19F2 \ + U+19F3 U+19F4 U+19FD U+19FC U+19E8 \ + U+19E9 U+19E0 U+19E3 U+19ED U+19E4 \ + U+19E6 U+19FA U+19E1 U+19F8 U+19E5 \ + U+19F7 U+19F5 U+19FE U+19FF U+19EA \ + U+19EB U+19F6 + +c Subscripts​ for mobile/table layouts + +store(input_subcons) \ + [T_17D2_1780] [T_17D2_1781] [T_17D2_1782] [T_17D2_1783] [T_17D2_1784] [T_17D2_1785] [T_17D2_1786] [T_17D2_1787] \ + [T_17D2_1788] [T_17D2_1789] [T_17D2_178A] [T_17D2_178B] [T_17D2_178C] [T_17D2_178D] [T_17D2_178E] [T_17D2_178F] \ + [T_17D2_1790] [T_17D2_1791] [T_17D2_1792] [T_17D2_1793] [T_17D2_1794] [T_17D2_1795] [T_17D2_1796] [T_17D2_1797] \ + [T_17D2_1798] [T_17D2_1799] [T_17D2_179A] [T_17D2_179B] [T_17D2_179C] [T_17D2_179D] [T_17D2_179E] [T_17D2_179F] \ + [T_17D2_17A0] [T_17D2_17A1] [T_17D2_17A2] + +store(subcons) \ + U+1780 U+1781 U+1782 U+1783 U+1784 U+1785 U+1786 U+1787 \ + U+1788 U+1789 U+178A U+178B U+178C U+178D U+178E U+178F \ + U+1790 U+1791 U+1792 U+1793 U+1794 U+1795 U+1796 U+1797 \ + U+1798 U+1799 U+179A U+179B U+179C U+179D U+179E U+179F \ + U+17A0 U+17A1 U+17A2 + +c Arabic digits 0 through 9 + +store(arabic_digit_key) [U_0030] [U_0031] [U_0032] [U_0033] [U_0034] [U_0035] [U_0036] [U_0037] [U_0038] [U_0039] +store(arabic_digit_out) U+0030 U+0031 U+0032 U+0033 U+0034 U+0035 U+0036 U+0037 U+0038 U+0039 + +c These stores are for consonant (cluster) and consonant shifter configuration + +store(v_above) U+17B7 U+17B8 U+17B9 U+17BA \ + U+17BE U+17D0 c These vowels are rendered above the base consonant. +store(shiftable_c_1st) U+179F U+17A0 U+17A2 c These consonants shift to the 2nd series when adding U+17CA to them. +store(shiftable_BA) U+1794 c BA is unique because it can be used with both U+17CA (shift to 2nd) and and U+179C (shift to PA, still in 1st series). +store(shiftable_c_2nd) U+1784 U+1789 U+1798 U+1799 \ + U+179A U+179C U+1793 U+179B c These consonants shift to the 1st series when adding U+17C9 to them. +store(shiftable_c_2nd_with_BA) outs(shiftable_c_2nd) outs(shiftable_BA) +store(c_2nd_combo_LO) U+1799 U+1798 U+1784 U+1794 \ + U+179C c These can be combined with U+179B to make a 2nd series consonant cluster. +store(c_2nd_combo_MO) U+1799 U+179B U+1784 U+179A c These can be combined with U+1798 to make a 2nd series consonant cluster. +store(c_1st_combo_LO) U+1794 U+17A0 U+17A2 c they can be combined with U+179B to make a 1st series consonant cluster. +store(c_1st_combo_MO) U+17A0 U+179F U+17A2 c they can be combined with U+1798 to make a 1st series consonant cluster. +store(c_combo_SA) U+1794 U+1799 U+179B U+1798 \ + U+1793 U+1789 U+1784 U+179A \ + U+179C U+17A2 c they can be combined with U+179F to make a 1st series consonant cluster. +store(c_combo_QA) U+1786 U+1788 U+1794 U+1795 \ + U+178F U+1791 c they can be combined with U+17A2 to make a 1st series consonant cluster. +store(c_combo_HA) U+179C U+17A3 c they can be combined with U+17A0 to make a 1st series consonant cluster. + + + +c ==============================================BASIC RULES & CONSTRAINTS/ROTATIONS============================================== + + + +group(main) using keys + +c Basic rules with RALT key + + + any(c_key) > index(c_out,1) + + any(v_key) > index(v_out,1) + + any(ind_v_key) > index(ind_v_out,1) + + any(diacritic_key) > index(diacritic_out,1) + + any(c_shifter_key) > index(c_shifter,1) + + any(punct_key) > index(punct_out,1) + + any(latin_punct_key) > index(latin_punct_out,1) + + any(currency_key) > index(currency_out,1) + + any(digit_key) > index(digit_out,1) + + any(lek_attak_key) > index(lek_attak_out,1) + + any(lunar_date_key) > index(lunar_date_out,1) + + any(spaces_key) > index(spaces_out,1) + +c two-unicode-code-point vowels + ++ [SHIFT K_A] > U+17B6 U+17C6 ++ [SHIFT K_V] > U+17C1 U+17C7 ++ [SHIFT K_COLON] > U+17C4 U+17C7 ++ [K_COMMA] > U+17BB U+17C6 ++ [SHIFT K_COMMA] > U+17BB U+17C7 + +c Constraints + +any(v_gen) + [K_QUOTE] > context beep c no Bantoc after any genuine vowel +any(v_pseudo) + [K_QUOTE] > context beep c no Bantoc after any pseudo vowel +any(c_shifter) + [K_QUOTE] > context beep c no Bantoc after Triisap/Muusikatoan +U+17D2 any(c_out) + [K_QUOTE] > context beep c no Bantoc on a subscript +any(c_shifter) + any(c_shifter_key) > context beep c no two Triisap/Muusikatoan + +c Rotation + +U+17C7 + [SHIFT K_H] > U+17C8 c U+17C7 is changed to U+17C8 when pressing [SHIFT K_H]. +U+17C8 + [SHIFT K_H] > U+17C7 c U+17C8 is changed to U+17C7 when K_H is pressed. +c any(v_gen) U+17C7 + [SHIFT K_H] > context beep c Don't rotate between U+17C7 and U+17C8. + +c Press Space twice to get a White Space + +U+200B + [K_SPACE] > U+0020 + +c One backspace deletes a two-part vowels (i.e. psuedoVowel + U+17C7) + +any(v_combo_N) U+17C6 + [K_BKSP] > nul +any(v_combo_R) U+17C7 + [K_BKSP] > nul + +c When pressing [K_U] after ាំ and ុំ, the U+17BB is converted to a respective consonant shifter placing before them (ាំ and ុំ). + +any(shiftable_c_1st) U+17B6 U+17C6 + [K_U] > context(1) U+17CA context(2) context(3) c fix ស ាំ ុ > ស៊ាំ +any(shiftable_c_2nd_with_BA) U+17B6 U+17C6 + [K_U] > context(1) U+17C9 context(2) context(3) c fix ម ាំ ុ > ម៉ាំ + +c Consonant shifter constraints (beep when the consonant shifter is used in inappropriate environment) + +any(shiftable_c_1st) + [SHIFT K_QUOTE] > context U+17C9 beep +any(shiftable_c_2nd_with_BA) + [K_SLASH] > context U+17CA beep + +c 1st series consonant clusters + +any(c_combo_QA) U+17D2 U+17A2 + [SHIFT K_QUOTE] > context U+17C9 beep +U+179B U+17D2 any(c_1st_combo_LO) + [SHIFT K_QUOTE] > context U+17C9 beep +U+1798 U+17D2 any(c_1st_combo_MO) + [SHIFT K_QUOTE] > context U+17C9 beep +U+179F U+17D2 any(c_combo_SA) + [SHIFT K_QUOTE] > context U+17C9 beep +any(c_combo_HA) U+17D2 U+17A0 + [SHIFT K_QUOTE] > context U+17C9 beep +U+17A2 U+17D2 U+1784 + [SHIFT K_QUOTE] > context U+17C9 beep +U+17A2 U+17D2 U+179C + [SHIFT K_QUOTE] > context U+17C9 beep + +c 2nd series consonant clusters + +U+179B U+17D2 any(c_2nd_combo_LO) + [K_SLASH] > context U+17CA beep +U+1798 U+17D2 any(c_2nd_combo_MO) + [K_SLASH] > context U+17CA beep + + +c ==============================================MOBILE/TABLET TOUCH LAYOUT============================================== + + + ++ any(input_subcons) > U+17D2 index(subcons, 1) + +c delete a subscript (a subscript sign + a consonant) upon a backspace + +platform('touch') U+17D2 any(c_out) + [K_BKSP] > nul c working OK + + + [K_NPSTAR] > U+002A + + [SHIFT K_NPSTAR] > U+002A + + [K_NPPLUS] > U+002B + + [SHIFT K_NPPLUS] > U+002B + + [K_NPMINUS] > U+002D + + [SHIFT K_NPMINUS] > U+002D + + [K_NPDOT] > U+002E + + [SHIFT K_NPDOT] > U+002E + + [K_NPSLASH] > U+002F + + [SHIFT K_NPSLASH] > U+002F + + + +c ==============================================NORMALIZATION============================================== + + + +match > use(normalise) + +group(normalise) + +c Illegitimate vowel combinations should be transformed to the legitimate ones (Case #6 and #7). + +U+17C1 U+17B6 > U+17C4 c when type េ and ា, tranform them to ោ +U+17B6 U+17C1 > U+17C4 c when type ា and េ, tranform them to ោ +U+17C1 U+17B8 > U+17BE c when type េ and ី , tranform them to ើ +U+17B8 U+17C1 > U+17BE c when type ី and េ, tranform them to ើ + +c Illegitimate vowel combinations should be transformed to the legitimate ones (Case #5). + +U+17C6 U+17BB > U+17BB U+17C6 c when type ំ and ុ , tranform them to ុំ +U+17C6 U+17B6 > U+17B6 U+17C6 c when type ំ and ា, tranform them to ាំ + + +c Rotate vowels + +any(v_gen) any(v_gen) > context(2) c the last vowel stroke replaces the previous one. + +c Rotation of two-part vowels + +any(v_gen) any(v_pseudo) any(v_gen) > context(3) c a two-part vowel rotates to a genuine vowel. +any(v_gen) any(v_pseudo) any(v_pseudo) > context(3) c a two-part vowel rotates to a pseudo vowel. +any(v_gen) any(v_gen) any(v_pseudo) > context(2) context(3) c a genuine vowel rotates to a two-part vowel. +any(v_pseudo) any(v_gen) any(v_pseudo) > context(2) context(3) c a pseudo vowel roates to a two-part vowel. +any(v_gen) any(v_pseudo) any(v_gen) any(v_pseudo) > context(3) context(4) c a two-part vowel rotates to another two-part vowel. +any(v_pseudo) any(v_pseudo) > context(2) c a pseudo vowel rotates to another pseudo vowel. +c any(v_pseudo) any(v_gen) > context(2) c a pseudo vowel roates to a genuine vowel. + +c Delete extraneous coeng markers + +U+17D2 U+17D2 > context(1) c no two coengs are allowed. +U+17D2 any(v_any) > context(2) c The coeng is deleted when a vowel is put after it. +U+17D2 any(v_gen) any(v_pseudo) > context(2) context(3) c The coeng is deleted when a pseudo vowel is put after it. + +c Case #1: when a subscript is typed after a vowel, the subscript should be automatically moved to before the vowel. + +any(v_any) U+17D2 any(subcons) > context(2) context(3) context(1) c a vowel has to come after a subscript. +any(v_combo_N) U+17C6 U+17D2 any(subcons) > context(3) context(4) context(1) context(2) c these two-part vowels have to come after a subscript. +any(v_combo_R) U+17C7 U+17D2 any(subcons) > context(3) context(4) context(1) context(2) c these two-part vowels have to come after a subscript. + +c a subscript placed after a shifter and a vowel (a genuine/pseudo vowel or a two-part vowel) has to move to before them. + +any(c_shifter) any(v_any) U+17D2 any(subcons) > context(3) context(4) context(1) context(2) c after a genuine/pseudo vowel +any(c_shifter) any(v_combo_N) U+17C6 U+17D2 any(subcons) > context(4) context(5) context(1) context(2) context(3) c after a two-part vowel +any(c_shifter) any(v_combo_R) U+17C7 U+17D2 any(subcons) > context(4) context(5) context(1) context(2) context(3) c after a two-part vowel + +c Case #2: when [D2+9A] is typed before a subscript, it should be moved to after it. + +U+17D2 U+178A U+17D2 U+179A > context(1) U+178F context(3) context(4) c change subscript ្ដ to ្ត after ្រ +U+17D2 U+179A U+17D2 U+178A > context(3) U+178F context(1) context(2) c ្ដ placed after ្រ has to be changed to ្ត and placed before ្រ + +U+17D2 U+178A any(v_any) U+17D2 U+179A > context(1) U+178F context(4) context(5) context(3) c fixed 6e +U+17D2 U+178A any(v_combo_N) U+17C6 U+17D2 U+179A > context(1) U+178F context(5) context(6) context(3) context(4) +U+17D2 U+178A any(v_combo_R) U+17C7 U+17D2 U+179A > context(1) U+178F context(5) context(6) context(3) context(4) + +U+17D2 U+179A any(v_any) U+17D2 U+178A > context(1) U+178F context(4) context(2) context(3) c fixed 6g +U+17D2 U+179A any(v_combo_N) U+17C6 U+17D2 U+178A > context(1) U+178F context(5) context(2) context(3) context(4) +U+17D2 U+179A any(v_combo_R) U+17C7 U+17D2 U+178A > context(1) U+178F context(5) context(2) context(3) context(4) + +U+17D2 U+179A any(c_shifter) U+17D2 any(subcons) > context(4) context(5) context(1) context(2) context(3) c when a shifter intervenes, move it to the end. +U+17D2 U+179A U+17D2 any(subcons) > context(3) context(4) context(1) context(2) c move any subscript placed after [D2+DA] to before them +U+17D2 U+179A any(v_any) U+17D2 any(subcons) > context(4) context(5) context(1) context(2) context(3) c when a vowel intervenes, move it to the end +U+17D2 U+179A any(v_combo_N) U+17C6 U+17D2 any(subcons) > context(5) context(6) context(1) context(2) context(3) context(4) c when a two-part vowel intervenes, move it to the end too. +U+17D2 U+179A any(v_combo_R) U+17C7 U+17D2 any(subcons) > context(5) context(6) context(1) context(2) context(3) context(4) c (same as stated right above) + +c when a subscript is placed after a consonant shifter, they should be reversed. (Case #3) + +any(c_shifter) U+17D2 any(subcons) > context(2) context(3) context(1) + +c Reordering - Triisap and Muusikatoan store before a vowel and a vowelcomboR and pseudo vowel. + +any(v_any) any(c_shifter) > context(2) context(1) c place either a enguine or pseudo vowel after a consonant shifter +any(v_gen) any(v_pseudo) any(c_shifter) > context(3) context(1) context(2) c place a two-part vowel after a consonant shifter(i.e. មោះ៉ ) +any(c_shifter) any(v_any) any(c_shifter) > context(3) context(2) c when either a genuine or pseudo vowel is in between two consonant shifters, replace the one on the left with the one on the right. +any(c_shifter) any(v_gen) any(v_pseudo) any(c_shifter) > context(2) context(3) context(4) c when a two-part vowel is in between two consonants shifter, replace the one on the left with the one the right. + +c +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +c + when [BB] is place either before or after an above vowel (ិ ី ឹ ឺ ើ and ាំ) and a Samyok Sannya (Case #4) + +c + the [BB] should transform to a respective consonant shifter placed before the vowel. + +c + the consonant shifter is a Trisap if the base consonant is ស ហ អ + +c + the consonant shifter is a Muusikatoan if the base consonant is ង ញ ម យ រ វ ប and ន ល + +c +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +c consonant clusters --1st > 2nd series (with U+17BB) + +any(c_combo_QA) U+17D2 U+17A2 U+17BB any(v_above) > context(1) context(2) context(3) U+17CA context(5) +any(c_combo_QA) U+17D2 U+17A2 U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +any(c_combo_QA) U+17D2 U+17A2 any(v_above) U+17BB > context(1) context(2) context(3) U+17CA context(4) +any(c_combo_QA) U+17D2 U+17A2 U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(6) context(7) + +U+179B U+17D2 any(c_1st_combo_LO) U+17BB any(v_above) > context(1) context(2) context(3) U+17CA context(5) +U+179B U+17D2 any(c_1st_combo_LO) U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +U+179B U+17D2 any(c_1st_combo_LO) any(v_above) U+17BB > context(1) context(2) context(3) U+17CA context(4) +U+179B U+17D2 any(c_1st_combo_LO) U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(6) context(7) + +U+1798 U+17D2 any(c_1st_combo_MO) U+17BB any(v_above) > context(1) context(2) context(3) U+17CA context(5) +U+1798 U+17D2 any(c_1st_combo_MO) U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +U+1798 U+17D2 any(c_1st_combo_MO) any(v_above) U+17BB > context(1) context(2) context(3) U+17CA context(4) +U+1798 U+17D2 any(c_1st_combo_MO) U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(6) context(7) + +U+179F U+17D2 U+1794 U+17BB any(v_above) > context(1) context(2) context(3) U+17C9 context(5) c prevent ស្បុិ from transforming to ស្ប៊ិ​​​, but produce ស្ប៉ិ instead because ប never have Triisap shifted down. +U+179F U+17D2 U+1794 U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17C9 context(5) context(6) +U+179F U+17D2 U+1794 any(v_above) U+17BB > context(1) context(2) context(3) U+17C9 context(4) +U+179F U+17D2 U+1794 U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17C9 context(6) context(7) + +U+179F U+17D2 any(c_combo_SA) U+17BB any(v_above) > context(1) context(2) context(3) U+17CA context(5) +U+179F U+17D2 any(c_combo_SA) U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +U+179F U+17D2 any(c_combo_SA) any(v_above) U+17BB > context(1) context(2) context(3) U+17CA context(4) +U+179F U+17D2 any(c_combo_SA) U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(6) context(7) + +any(c_combo_HA) U+17D2 U+17A0 U+17BB any(v_above) > context(1) context(2) context(3) U+17CA context(5) +any(c_combo_HA) U+17D2 U+17A0 U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +any(c_combo_HA) U+17D2 U+17A0 any(v_above) U+17BB > context(1) context(2) context(3) U+17CA context(4) +any(c_combo_HA) U+17D2 U+17A0 U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(6) context(7) + +U+17A2 U+17D2 U+1784 U+17BB any(v_above) > context(1) context(2) context(3) U+17CA context(5) +U+17A2 U+17D2 U+1784 U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +U+17A2 U+17D2 U+1784 any(v_above) U+17BB > context(1) context(2) context(3) U+17CA context(4) +U+17A2 U+17D2 U+1784 U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(6) context(7) + +U+17A2 U+17D2 U+179C U+17BB any(v_above) > context(1) context(2) context(3) U+17CA context(5) +U+17A2 U+17D2 U+179C U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +U+17A2 U+17D2 U+179C any(v_above) U+17BB > context(1) context(2) context(3) U+17CA context(4) +U+17A2 U+17D2 U+179C U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(6) context(7) + +U+17A0 U+17D2 U+1794 U+17BB any(v_above) > context(1) context(2) context(3) U+17C9 context(5) c change U+17BB preceded by HA and coeng BA to U+17C9 before the above vowel +U+17A0 U+17D2 U+1794 U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17C9 context(3) context(6) c change U+17BB preceded by HA and coeng BA to U+17C9 before ាំ +U+17A0 U+17D2 U+1794 any(v_above) U+17BB > context(1) context(2) context(3) U+17C9 context(4) c change U+17BB preceded by HA and coeng BA to U+17C9 before the above vowel +U+17A0 U+17D2 U+1794 U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17C9 context(6) context(7) c change U+17BB preceded by HA and coeng BA to U+17C9 before U+17B6 U+17C6 + + +U+17A0 U+17D2 any(shiftable_c_2nd_with_BA) U+17BB any(v_above) > context(1) context(2) context(3) U+17CA context(5) c change U+17BB preceded by HA and coeng shiftable_c_2nd to U+17CA before the above vowel +U+17A0 U+17D2 any(shiftable_c_2nd_with_BA) U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(3) context(6) c change U+17BB preceded by HA and coeng shiftable_c_2nd to U+17CA before ាំ +U+17A0 U+17D2 any(shiftable_c_2nd_with_BA) any(v_above) U+17BB > context(1) context(2) context(3) U+17CA context(4) c change U+17BB preceded by HA and coeng shiftable_c_2nd to U+17CA before the above vowel +U+17A0 U+17D2 any(shiftable_c_2nd_with_BA) U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(6) context(7) c change U+17BB preceded by HA and coeng shiftable_c_2nd to U+17CA before U+17B6 U+17C6 + +c consonant clusters --2nd > 1st series (with U+17BB) + +U+179B U+17D2 any(c_2nd_combo_LO) U+17BB any(v_above) > context(1) context(2) context(3) U+17C9 context(5) +U+179B U+17D2 any(c_2nd_combo_LO) U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17C9 context(5) context(6) +U+179B U+17D2 any(c_2nd_combo_LO) any(v_above) U+17BB > context(1) context(2) context(3) U+17C9 context(4) +U+179B U+17D2 any(c_2nd_combo_LO) U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17C9 context(6) context(7) + +U+1798 U+17D2 any(c_2nd_combo_MO) U+17BB any(v_above) > context(1) context(2) context(3) U+17C9 context(5) +U+1798 U+17D2 any(c_2nd_combo_MO) U+17BB U+17B6 U+17C6 > context(1) context(2) context(3) U+17C9 context(5) context(6) +U+1798 U+17D2 any(c_2nd_combo_MO) any(v_above) U+17BB > context(1) context(2) context(3) U+17C9 context(4) +U+1798 U+17D2 any(c_2nd_combo_MO) U+17BB U+17C6 U+17B6 U+17C6 > context(1) context(2) context(3) U+17C9 context(6) context(7) + +c single consonants --1st > 2nd series (with U+17BB) + +any(shiftable_c_1st) U+17BB any(v_above) > context(1) U+17CA context(3) c change U+17BB to U+17CA before the above vowel +any(shiftable_c_1st) U+17BB U+17B6 U+17C6 > context(1) U+17CA context(3) context(4) c change U+17BB to U+17CA before ាំ +any(shiftable_c_1st) any(v_above) U+17BB > context(1) U+17CA context(2) c change U+17BB to U+17CA and place it before the above vowel +any(shiftable_c_1st) U+17BB U+17C6 U+17B6 U+17C6 > context(1) U+17CA context(4) context(5) c change U+17BB U+17C6 to U+17CA + +c single consonants --2nd > 1st series (with U+17BB) + +any(shiftable_c_2nd_with_BA) U+17BB any(v_above) > context(1) U+17C9 context(3) c change U+17BB to U+17C9 before the above vowel +any(shiftable_c_2nd_with_BA) U+17BB U+17B6 U+17C6 > context(1) U+17C9 context(3) context(4) c change U+17BB to U+17C9 before ាំ +any(shiftable_c_2nd_with_BA) any(v_above) U+17BB > context(1) U+17C9 context(2) c change U+17BB to U+17C9 before the above vowel +any(shiftable_c_2nd_with_BA) U+17BB U+17C6 U+17B6 U+17C6 > context(1) U+17C9 context(4) context(5) c change U+17BB U+17C6 to U+17C9 + +c prevent incorrect use of consonant shifter with 2nd series consonant clusters (without U+17BB) + +U+179B U+17D2 any(c_2nd_combo_LO) U+17CA any(v_above) > context(1) context(2) context(3) U+17C9 context(5) +U+1798 U+17D2 any(c_2nd_combo_MO) U+17CA any(v_above) > context(1) context(2) context(3) U+17C9 context(5) +U+179B U+17D2 any(c_2nd_combo_LO) U+17CA any(v_gen) any(v_pseudo) > context(1) context(2) context(3) U+17C9 context(5) context(6) +U+1798 U+17D2 any(c_2nd_combo_MO) U+17CA any(v_gen) any(v_pseudo) > context(1) context(2) context(3) U+17C9 context(5) context(6) + +c single consonants --2nd > 1st series (without U+17BB) + +any(shiftable_c_2nd) U+17CA any(v_above) > context(1) U+17C9 context(3) c change U+17CA to U+17C9 before the above vowel +any(shiftable_c_2nd) U+17CA any(v_gen) any(v_pseudo) > context(1) U+17C9 context(3) context(4) c change U+17CA to U+17C9 before ាំ + +U+17D2 any(shiftable_c_2nd) U+17CA any(v_above) > context c stop ស្រ៊ី from becoming ស្រ៉ី +U+17D2 any(shiftable_c_2nd) U+17CA any(v_gen) any(v_pseudo) > context + +c SPECIAL CASE -- the following clusters may accept either of the two consonant shifters. + +U+1794 U+17D2 U+1799 any(c_shifter) > context +U+179F U+17D2 U+1794 any(c_shifter) > context +U+1786 U+17D2 U+1794 any(c_shifter) > context +U+1794 U+17D2 U+1799 any(c_shifter) > context +U+179F U+17D2 U+1794 any(c_shifter) > context +U+1786 U+17D2 U+1794 any(c_shifter) > context + +c single --1st > 2nd series (without U+17BB) + +any(shiftable_c_1st) U+17C9 any(v_above) > context(1) U+17CA context(3) c change U+17C9 to U+17CA before the above vowel +any(shiftable_c_1st) U+17C9 any(v_gen) any(v_pseudo) > context(1) U+17CA context(3) context(4) c change U+17C9 to U+17CA before ាំ + +c consonant clusters --1st > 2nd series (without U+17BB) + +any(c_combo_QA) U+17D2 U+17A2 U+17C9 any(v_above) > context(1) context(2) context(3) U+17CA context(5) +U+179B U+17D2 any(c_1st_combo_LO) U+17C9 any(v_above) > context(1) context(2) context(3) U+17CA context(5) +U+1798 U+17D2 any(c_1st_combo_MO) U+17C9 any(v_above) > context(1) context(2) context(3) U+17CA context(5) +U+179F U+17D2 U+1794 U+17C9 any(v_above) > context(1) context(2) context(3) U+17C9 context(5) c prevent ស្ប៉ី from transforming to ស្ប៊ី +U+179F U+17D2 any(c_combo_SA) U+17C9 any(v_above) > context(1) context(2) context(3) U+17CA context(5) +any(c_combo_HA) U+17D2 U+17A0 U+17C9 any(v_above) > context(1) context(2) context(3) U+17CA context(5) +U+17A2 U+17D2 U+1784 U+17C9 any(v_above) > context(1) context(2) context(3) U+17CA context(5) +U+17A2 U+17D2 U+179C U+17C9 any(v_above) > context(1) context(2) context(3) U+17CA context(5) +any(c_combo_QA) U+17D2 U+17A2 U+17C9 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +U+179B U+17D2 any(c_1st_combo_LO) U+17C9 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +U+1798 U+17D2 any(c_1st_combo_MO) U+17C9 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +U+179F U+17D2 U+1794 U+17C9 U+17B6 U+17C6 > context(1) context(2) context(3) U+17C9 context(5) context(6) c prevent ស្ប៉ាំ from transforming to ស្ប៊ាំ +U+179F U+17D2 any(c_combo_SA) U+17C9 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +any(c_combo_HA) U+17D2 U+17A0 U+17C9 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +U+17A2 U+17D2 U+1784 U+17C9 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) +U+17A2 U+17D2 U+179C U+17C9 U+17B6 U+17C6 > context(1) context(2) context(3) U+17CA context(5) context(6) + +c U+17B6 U+17BB and U+17C6 in that order preceeded by any first series consonant (cluster) are transformed to U+17CA U+17B6 and U+17C6 + +any(shiftable_c_1st) U+17B6 U+17BB U+17C6 > context(1) U+17CA context(2) context(4) +any(shiftable_c_1st) U+17BB U+17C6 U+17B6 > context(1) U+17CA context(4) context(3) + +any(c_combo_QA) U+17D2 U+17A2 U+17B6 U+17BB U+17C6 > context(1) context(2) context(3) U+17CA context(4) context(6) +any(c_combo_QA) U+17D2 U+17A2 U+17BB U+17C6 U+17B6 > context(1) context(2) context(3) U+17CA context(6) context(5) + +U+179B U+17D2 any(c_1st_combo_LO) U+17B6 U+17BB U+17C6 > context(1) context(2) context(3) U+17CA context(4) context(6) +U+179B U+17D2 any(c_1st_combo_LO) U+17BB U+17C6 U+17B6 > context(1) context(2) context(3) U+17CA context(6) context(5) + +U+1798 U+17D2 any(c_1st_combo_MO) U+17B6 U+17BB U+17C6 > context(1) context(2) context(3) U+17CA context(4) context(6) +U+1798 U+17D2 any(c_1st_combo_MO) U+17BB U+17C6 U+17B6 > context(1) context(2) context(3) U+17CA context(6) context(5) + +U+179F U+17D2 any(c_combo_SA) U+17B6 U+17BB U+17C6 > context(1) context(2) context(3) U+17CA context(4) context(6) +U+179F U+17D2 any(c_combo_SA) U+17BB U+17C6 U+17B6 > context(1) context(2) context(3) U+17CA context(6) context(5) + +any(c_combo_HA) U+17D2 U+17A0 U+17B6 U+17BB U+17C6 > context(1) context(2) context(3) U+17CA context(4) context(6) +any(c_combo_HA) U+17D2 U+17A0 U+17BB U+17C6 U+17B6 > context(1) context(2) context(3) U+17CA context(6) context(5) + +U+17A2 U+17D2 U+1784 U+17B6 U+17BB U+17C6 > context(1) context(2) context(3) U+17CA context(4) context(6) +U+17A2 U+17D2 U+1784 U+17BB U+17C6 U+17B6 > context(1) context(2) context(3) U+17CA context(6) context(5) + +U+17A2 U+17D2 U+179C U+17B6 U+17BB U+17C6 > context(1) context(2) context(3) U+17CA context(4) context(6) +U+17A2 U+17D2 U+179C U+17BB U+17C6 U+17B6 > context(1) context(2) context(3) U+17CA context(4) context(6) + +c U+17B6 U+17BB and U+17C6 in that order preceeded by any second series consonant (cluster) are transformed to U+17C9 U+17B6 and U+17C6 + +any(shiftable_c_2nd_with_BA) U+17B6 U+17BB U+17C6 > context(1) U+17C9 context(2) context(4) +any(shiftable_c_2nd_with_BA) U+17BB U+17C6 U+17B6 > context(1) U+17C9 context(4) context(3) + +U+179B U+17D2 any(c_2nd_combo_LO) U+17B6 U+17BB U+17C6 > context(1) context(2) context(3) U+17C9 context(4) context(6) +U+179B U+17D2 any(c_2nd_combo_LO) U+17BB U+17C6 U+17B6 > context(1) context(2) context(3) U+17C9 context(6) context(5) + +U+1798 U+17D2 any(c_2nd_combo_MO) U+17B6 U+17BB U+17C6 > context(1) context(2) context(3) U+17C9 context(4) context(6) +U+1798 U+17D2 any(c_2nd_combo_MO) U+17BB U+17C6 U+17B6 > context(1) context(2) context(3) U+17C9 context(6) context(5) + +c េុី/ុេី/៉េី > ៊ើ + +any(shiftable_c_1st) U+17C1 U+17BB U+17B8 > context(1) U+17CA U+17BE c cancel out by vowel rotation +any(shiftable_c_1st) U+17BB U+17C1 U+17B8 > context(1) U+17CA U+17BE c cancel out by vowel rotation +any(shiftable_c_1st) U+17C9 U+17C1 U+17B8 > context(1) U+17CA U+17BE + +any(c_combo_QA) U+17D2 U+17A2 U+17C1 U+17BB U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +any(c_combo_QA) U+17D2 U+17A2 U+17BB U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +any(c_combo_QA) U+17D2 U+17A2 U+17C9 U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE + +U+179B U+17D2 any(c_1st_combo_LO) U+17C1 U+17BB U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +U+179B U+17D2 any(c_1st_combo_LO) U+17BB U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +U+179B U+17D2 any(c_1st_combo_LO) U+17C9 U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE + +U+1798 U+17D2 any(c_1st_combo_MO) U+17C1 U+17BB U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +U+1798 U+17D2 any(c_1st_combo_MO) U+17BB U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +U+1798 U+17D2 any(c_1st_combo_MO) U+17C9 U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE + +U+179F U+17D2 any(c_combo_SA) U+17C1 U+17BB U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +U+179F U+17D2 any(c_combo_SA) U+17BB U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +U+179F U+17D2 any(c_combo_SA) U+17C9 U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE + +any(c_combo_HA) U+17D2 U+17A0 U+17C1 U+17BB U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +any(c_combo_HA) U+17D2 U+17A0 U+17BB U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +any(c_combo_HA) U+17D2 U+17A0 U+17C9 U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE + +U+17A2 U+17D2 U+1784 U+17C1 U+17BB U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +U+17A2 U+17D2 U+1784 U+17BB U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +U+17A2 U+17D2 U+1784 U+17C9 U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE + +U+17A2 U+17D2 U+179C U+17C1 U+17BB U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +U+17A2 U+17D2 U+179C U+17BB U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE c cancel out by vowel rotation +U+17A2 U+17D2 U+179C U+17C9 U+17C1 U+17B8 > context(1) context(2) context(3) U+17CA U+17BE + +c េុី/ុេី/៊េី > ៉ើ + +any(shiftable_c_2nd) U+17C1 U+17BB U+17B8 > context(1) U+17C9 U+17BE c cancel out by vowel rotation +any(shiftable_c_2nd) U+17BB U+17C1 U+17B8 > context(1) U+17C9 U+17BE c cancel out by vowel rotation +any(shiftable_c_2nd) U+17CA U+17C1 U+17B8 > context(1) U+17C9 U+17BE + +U+179B U+17D2 any(c_2nd_combo_LO) U+17C1 U+17BB U+17B8 > context(1) context(2) context(3) U+17C9 U+17BE c cancel out by vowel rotation +U+179B U+17D2 any(c_2nd_combo_LO) U+17BB U+17C1 U+17B8 > context(1) context(2) context(3) U+17C9 U+17BE c cancel out by vowel rotation +U+179B U+17D2 any(c_2nd_combo_LO) U+17CA U+17C1 U+17B8 > context(1) context(2) context(3) U+17C9 U+17BE + +U+1798 U+17D2 any(c_2nd_combo_MO) U+17C1 U+17BB U+17B8 > context(1) context(2) context(3) U+17C9 U+17BE c cancel out by vowel rotation +U+1798 U+17D2 any(c_2nd_combo_MO) U+17BB U+17C1 U+17B8 > context(1) context(2) context(3) U+17C9 U+17BE c cancel out by vowel rotation +U+1798 U+17D2 any(c_2nd_combo_MO) U+17CA U+17C1 U+17B8 > context(1) context(2) context(3) U+17C9 U+17BE + +c place Robat after a base before any vowel + +any(c_out) any(v_gen) U+17CC > context(1) context(3) context(2) + +c Use appropriate identical subscript in two separate contexts. (Case #8) + +U+178E U+17D2 U+178F > context(1) context(2) U+178A +U+1793 U+17D2 U+178A > context(1) context(2) U+178F + +c BONUSES​(note that these combinations are invalid and they can't be found in the CN dictionary 1967 nor RH 1997, but users think they are) + +U+1791 U+17D2 U+1794 > U+17A1 c ទ្ប transforms to ឡ + +U+1794 U+17D2 U+1789 > U+17AB c ប្ញ transforms to ឫ +U+17AB U+17BB > U+17AC c ឫុ transforms to ឬ + +U+17AD U+17B6 > U+1789 c ឭា transforms to ញ +U+17AE U+17B6 > U+1789 c ឮា transforms to ញ + +U+1796 U+17D2 U+1789 > U+17AD c ព្ញ transforms to ឭ +U+17AD U+17BB > U+17AE c ឭុ transforms to ឮ + +U+1796 U+17D2 U+178B > U+17B0 c ព្ឋ transforms to ឰ + +U+17A7 U+17B7 > U+17B1 c ឧិ transforms to ឱ +U+17A7 U+17CC > U+17B1 c ឧ៌ transforms to ឱ +U+17A7 U+17CD > U+17B1 c ឧ៍ transforms to ឱ + +c U+17D4 U+179B U+17D4 > U+17D8 c ។ល។ transforms to U+17D8 + +U+178A U+17D2 U+1792 > U+178A U+17D2 U+178B c ដ្ធ > ដ្ឋ +U+1791 U+17D2 U+178B > U+1791 U+17D2 U+1792 c ទ្ឋ > ទ្ធ + +c additional rules + +U+1796 U+1793 U+17D2 U+178B > context(1) context(2) context(3) U+1792 c ពន្ឋ > ពន្ធ +U+1796 U+17D0 U+1793 U+17D2 U+178B > context(1) context(2) context(3) context(4) U+1792 c ព័ន្ឋ > ព័ន្ធ + + +U+17AA U+17D2 U+1799 > U+17B1 context(2) context(3) c ឪ្យ > ឱ្យ +U+17B3 U+17D2 U+1799 > U+17B1 context(2) context(3) c ឳ្យ > ឱ្យ + +U+1789 U+17D2 U+179C > U+1796 context(2) context(3) U+17B6 c ញ្វ (as in សញ្វវុធ) > ព្វា as in សញ្វវុធ + +U+17D2 U+1799 U+17C1 U+17BA > U+17BF c េ្យឺ > ឿ +U+17D2 U+1799 U+17C1 U+17B9 > U+17BF c េ្យឹ > ឿ +U+17D2 U+1799 U+17C1 U+17B8 > U+17BF c េ្យី > ឿ + +U+17D2 U+1799 U+17D2 any(c_out) U+17C1 U+17BA > context(3) context(4) U+17BF c គ្រេ្យឺង > គ្រឿង +U+17D2 U+1799 U+17D2 any(c_out) U+17C1 U+17B9 > context(3) context(4) U+17BF c គ្រេ្យឹង > គ្រឿង +U+17D2 U+1799 U+17D2 any(c_out) U+17C1 U+17B8 > context(3) context(4) U+17BF c គ្រេ្យីង > គ្រឿង + +c In everyday practice, both 'regular space' and 'no-break space' are not used before punctuation, i.e. ។ ! etc. +c An annoying issue of having line broken up at the end of the line dropping the punctuation (។, ! and also ៗ) to the next line. +c It may be helpful to include a rule where a 'Zero Width No-Break Space' should be output together with those punct. +c ref: https://www.unicode.org/L2/L2020/20008-core-text.pdf +c ref: https://www.unicode.org/versions/Unicode12.0.0/ch23.pdf#G12985 +c ref: https://www.compart.com/en/unicode/U+FEFF + +c EOF diff --git a/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kps b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kps new file mode 100644 index 0000000000..65407f787e --- /dev/null +++ b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kps @@ -0,0 +1,58 @@ + + + + 15.0.266.0 + 7.0 + + + + readme.htm + splash.gif + + + + + + + + + + Khmer Angkor + © 2015-2022 SIL International + Makara Sok + + https://keyman.com/keyboards/khmer_angkor + Khmer Angkor + + + + ..\build\khmer_angkor.js + File khmer_angkor.js + 0 + .js + + + ..\build\khmer_angkor.kvk + File khmer_angkor.kvk + 0 + .kvk + + + ..\build\khmer_angkor.kmx + Keyboard Khmer Angkor + 0 + .kmx + + + + + Khmer Angkor + khmer_angkor + 1.3 + + Central Khmer (Khmer, Cambodia) + + + + + diff --git a/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kvks b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kvks new file mode 100644 index 0000000000..e9c38a464d --- /dev/null +++ b/developer/src/kmc/test/fixtures/command-line-tests/build/khmer_angkor/source/khmer_angkor.kvks @@ -0,0 +1,206 @@ + + +
+ 10.0 + khmer_angkor + + + +
+ + + ឞ + ឝ + ៈ + ឳ + ឨ + ឩ + ឰ + ឫ + ឦ + ឱ + ឯ + + + ៜ + + ៖ + ៙ + ៚ + + ៘ + ‌ + ‍ + ] + [ + / + . + ‘ + + + & + ’ + * + @ + \ + } + { + - + ÷ + : + , + ≈ + ; + < + # + > + × + $ + € +   + + + ៸ + ៰ + ៱ + ៲ + ៳ + ៴ + ៵ + ៶ + ៷ + ៹ + ᧿ + ᧾ + ᧪ + ᧫ + ᧶ + ᧵ + ᧬ + ᧷ + ᧥ + ᧸ + ᧡ + ᧻ + ᧹ + ᧮ + ᧢ + ᧯ + ᧰ + ᧦ + ᧱ + ᧤ + ᧭ + ᧣ + ᧧ + ᧠ + ᧺ + ᧲ + ᧳ + ᧴ + ᧩ + ᧨ + ᧼ + ᧽ + + + ​ + + + ឥ + ។ + + ០ + ១ + ២ + ៣ + ៤ + ៥ + ៦ + ៧ + ៨ + ៩ + + ឲ + + ឮ + ឪ + « + + ប + ច + ដ + + ថ + ង + ហ + + + ក + ល + ម + ន + + ផ + ឆ + រ + ស + ត + + វ + + ខ + យ + ឋ + + + + ! + + " + ៛ + % + + ( + ) + + = + + ៕ + ? + ៗ + + ព + ជ + ឌ + + ធ + អ + ះ + + ញ + គ + ឡ + + ណ + + ភ + ឈ + ឬ + + ទ + + + + ឃ + + ឍ + + + + ឭ + ឧ + » + + + +
diff --git a/developer/src/kmc/test/fixtures/command-line-tests/build/windows-installer/keymandesktop.txt b/developer/src/kmc/test/fixtures/command-line-tests/build/windows-installer/keymandesktop.txt new file mode 100644 index 0000000000..96d012d7d1 --- /dev/null +++ b/developer/src/kmc/test/fixtures/command-line-tests/build/windows-installer/keymandesktop.txt @@ -0,0 +1,3 @@ +This placeholder file represents keymandesktop.msi, the Windows Installer package for Keyman for Windows. + +(Not including the real file to reduce file size in repo, as it is not necessary in order to complete the unit test) \ No newline at end of file diff --git a/developer/src/kmc/test/fixtures/command-line-tests/build/windows-installer/license.txt b/developer/src/kmc/test/fixtures/command-line-tests/build/windows-installer/license.txt new file mode 100644 index 0000000000..cb2aa8919f --- /dev/null +++ b/developer/src/kmc/test/fixtures/command-line-tests/build/windows-installer/license.txt @@ -0,0 +1,3 @@ +This placeholder file represents license.txt. + +(Not including the real file to reduce file size in repo, as it is not necessary in order to complete the unit test) \ No newline at end of file diff --git a/developer/src/kmc/test/fixtures/command-line-tests/build/windows-installer/setup.txt b/developer/src/kmc/test/fixtures/command-line-tests/build/windows-installer/setup.txt new file mode 100644 index 0000000000..1a72b86ef0 --- /dev/null +++ b/developer/src/kmc/test/fixtures/command-line-tests/build/windows-installer/setup.txt @@ -0,0 +1,3 @@ +This placeholder file represents setup-redist.exe, the sfx loader. + +(Not including the real file to reduce file size in repo, as it is not necessary in order to complete the unit test) \ No newline at end of file diff --git a/developer/src/kmcmplib/tests/meson.build b/developer/src/kmcmplib/tests/meson.build index da2ac61a37..33536e936b 100644 --- a/developer/src/kmcmplib/tests/meson.build +++ b/developer/src/kmcmplib/tests/meson.build @@ -162,7 +162,7 @@ gtestcompilertest = executable('gtest-compiler-tests', 'gtest-compiler.tests.cpp include_directories: inc, name_suffix: name_suffix, link_args: links + tests_links, - objects: lib.extract_all_objects(), + objects: lib.extract_all_objects(recursive: false), dependencies: [ icuuc_dep, gtest_dep, gmock_dep ], ) @@ -173,7 +173,7 @@ gtest_km_u16_test = executable('gtest-km_u16-tests', 'gtest-km_u16.tests.cpp', include_directories: inc, name_suffix: name_suffix, link_args: links + tests_links, - objects: lib.extract_all_objects(), + objects: lib.extract_all_objects(recursive: false), dependencies: [ icuuc_dep, gtest_dep, gmock_dep ], ) diff --git a/developer/src/kmcmplib/wasm.build.linux.in b/developer/src/kmcmplib/wasm.build.linux.in index 27cb7ba68f..90a31bf75b 100644 --- a/developer/src/kmcmplib/wasm.build.linux.in +++ b/developer/src/kmcmplib/wasm.build.linux.in @@ -2,3 +2,4 @@ c = ['$EMSCRIPTEN_BASE/emcc'] cpp = ['$EMSCRIPTEN_BASE/em++'] ar = ['$EMSCRIPTEN_BASE/emar'] +strip = ['$EMSCRIPTEN_BASE/emstrip'] diff --git a/developer/src/kmcmplib/wasm.build.mac.in b/developer/src/kmcmplib/wasm.build.mac.in index 27cb7ba68f..90a31bf75b 100644 --- a/developer/src/kmcmplib/wasm.build.mac.in +++ b/developer/src/kmcmplib/wasm.build.mac.in @@ -2,3 +2,4 @@ c = ['$EMSCRIPTEN_BASE/emcc'] cpp = ['$EMSCRIPTEN_BASE/em++'] ar = ['$EMSCRIPTEN_BASE/emar'] +strip = ['$EMSCRIPTEN_BASE/emstrip'] diff --git a/developer/src/kmcmplib/wasm.build.win.in b/developer/src/kmcmplib/wasm.build.win.in index 68c21fb00f..2c9ed6b612 100644 --- a/developer/src/kmcmplib/wasm.build.win.in +++ b/developer/src/kmcmplib/wasm.build.win.in @@ -2,3 +2,4 @@ c = ['python.exe', '$EMSCRIPTEN_BASE/emcc.py'] cpp = ['python.exe', '$EMSCRIPTEN_BASE/em++.py'] ar = ['python.exe', '$EMSCRIPTEN_BASE/emar.py'] +strip = ['python.exe', '$EMSCRIPTEN_BASE/emstrip.py'] diff --git a/developer/src/kmcmplib/wasm.defs.build b/developer/src/kmcmplib/wasm.defs.build index 4a598a1e1c..9e0859b49b 100644 --- a/developer/src/kmcmplib/wasm.defs.build +++ b/developer/src/kmcmplib/wasm.defs.build @@ -1,7 +1,8 @@ [binaries] -c = ['emcc.py'] -cpp = ['em++.py'] -ar = ['emar.py'] +c = ['emcc'] +cpp = ['em++'] +ar = ['emar'] +strip = ['emstrip'] exe_wrapper = 'node' [properties] diff --git a/developer/src/server/build.sh b/developer/src/server/build.sh index c73ed853f7..e0e82e3617 100755 --- a/developer/src/server/build.sh +++ b/developer/src/server/build.sh @@ -113,7 +113,17 @@ function installer_server() { # monorepo context cat package.json | "$JQ" \ '. | del(.devDependencies."@keymanapp/resources-gosh") | del(.devDependencies."@keymanapp/keyman-version") | del(.dependencies."@keymanapp/developer-utils")' \ + > "$PRODBUILDTEMP/package0.json" + + # #12950: Merge dependencies from @keymanapp/developer-utils and Server; this + # is a bit messy but will mean we don't lose the needed dependencies. + "$JQ" -s '.[0] + { dependencies: (.[1].dependencies * .[0].dependencies) }' "$PRODBUILDTEMP/package0.json" \ + "$KEYMAN_ROOT/developer/src/common/web/utils/package.json" > "$PRODBUILDTEMP/package1.json" + cat $PRODBUILDTEMP/package1.json | "$JQ" \ + '. | del(.dependencies."@keymanapp/common-types")' \ > "$PRODBUILDTEMP/package.json" + rm "$PRODBUILDTEMP/package0.json" + rm "$PRODBUILDTEMP/package1.json" pushd "$PRODBUILDTEMP" npm install --omit=dev --omit=optional diff --git a/developer/src/tike/child/UfrmKeymanWizard.pas b/developer/src/tike/child/UfrmKeymanWizard.pas index 59e0f8a341..afb1e715ed 100644 --- a/developer/src/tike/child/UfrmKeymanWizard.pas +++ b/developer/src/tike/child/UfrmKeymanWizard.pas @@ -369,6 +369,8 @@ type FLayoutSetup: Boolean; FCheckboxGridHelper: TCheckboxGridHelper; + FDisplayMapFont: TFont; + function GetCurrentRule: TKeyboardParser_LayoutRule; procedure EnableControls; @@ -475,6 +477,7 @@ type procedure OrderDetailsPanels; function ValidateFileHasNoUnicodeCharacters(sw: WideString): Boolean; procedure UpdateQRCode; + procedure SetDisplayMapFont(const Value: TFont); protected function GetHelpTopic: string; override; @@ -495,6 +498,8 @@ type function ShouldRememberFocus(Control: TWinControl): Boolean; override; // I4679 + property DisplayMapFont: TFont read FDisplayMapFont write SetDisplayMapFont; + public procedure StartDebugging(FStartTest: Boolean = False); procedure StopDebugging; @@ -630,6 +635,8 @@ procedure TfrmKeymanWizard.FormCreate(Sender: TObject); begin FLoading := True; + FDisplayMapFont := TFont.Create; + //SetWindowTheme(pages.Handle, 'Explorer', ''); cmdAddToProject.Caption := '&Add to Project'; @@ -681,6 +688,7 @@ begin FreeAndNil(FKeyboardParser); // I4557 FreeAndNil(FDebugForm); FreeAndNil(FCheckboxGridHelper); + FreeAndNil(FDisplayMapFont); UninitSystemKeyboard; end; @@ -1825,6 +1833,15 @@ begin Delete(Result.Size, Length(Result.Size)-1, 2); end; end; + kfontDisplayMap: + begin + Result.Enabled := FKeyboardParser.GetSystemStoreValue(ssDisplayMap) <> ''; + if Result.Enabled then + begin + Result.Name := DisplayMapFont.Name; + Result.Size := IntToStr(TFontUtils.FontSizeInPoints(DisplayMapFont.Name, DisplayMapFont.Size)); // I4872 + end; + end; end; end; @@ -2535,6 +2552,8 @@ begin FFont.Free; end; end; + + SetFontFromString(DisplayMapFont, ProjectFile.IDEState['DisplayMapFont']); end; procedure TfrmKeymanWizard.SaveSettings(SaveProject: Boolean); @@ -2548,6 +2567,7 @@ begin else ProjectFile.IDEState['DebugDefaultFont'] := '0'; // I4702 ProjectFile.IDEState['DebugFont'] := FontAsString(FDebugForm.memo.Font); // I4702 + ProjectFile.IDEState['DisplayMapFont'] := FontAsString(DisplayMapFont); inherited; end; @@ -2781,6 +2801,13 @@ begin TUtilExecute.URL(lbDebugHosts.Items[lbDebugHosts.ItemIndex]); end; +procedure TfrmKeymanWizard.SetDisplayMapFont(const Value: TFont); +begin + FDisplayMapFont.Assign(Value); + if ProjectFile <> nil then + ProjectFile.IDEState['DisplayMapFont'] := FontAsString(DisplayMapFont); +end; + procedure TfrmKeymanWizard.SetFontInfo(Index: TKeyboardFont; const Value: TKeyboardFontInfo); // I4057 var f: TFont; @@ -2814,6 +2841,11 @@ begin CharFont.Name := Value.Name; CharFont.Size := StrToIntDef(Value.Size, 12); end; + kfontDisplayMap: + begin + DisplayMapFont.Name := Value.Name; + DisplayMapFont.Size := StrToIntDef(Value.Size, 12); + end; kfontOSK: begin if FKeyboardParser.Features.ContainsKey(kfOSK) then // I4258 diff --git a/developer/src/tike/dialogs/UfrmKeyboardFonts.dfm b/developer/src/tike/dialogs/UfrmKeyboardFonts.dfm index bd8ba4de5b..b874db58a8 100644 --- a/developer/src/tike/dialogs/UfrmKeyboardFonts.dfm +++ b/developer/src/tike/dialogs/UfrmKeyboardFonts.dfm @@ -2,11 +2,11 @@ inherited frmKeyboardFonts: TfrmKeyboardFonts BorderIcons = [biSystemMenu] BorderStyle = bsDialog Caption = 'Keyboard Fonts' - ClientHeight = 291 + ClientHeight = 322 ClientWidth = 512 Position = poScreenCenter ExplicitWidth = 518 - ExplicitHeight = 320 + ExplicitHeight = 351 PixelsPerInch = 96 TextHeight = 13 object lblDevEnv: TLabel @@ -147,6 +147,20 @@ inherited frmKeyboardFonts: TfrmKeyboardFonts Height = 13 Caption = 'em' end + object lblDisplayMap: TLabel + Left = 32 + Top = 248 + Width = 109 + Height = 13 + Caption = '&displayMap target font' + end + object Label4: TLabel + Left = 463 + Top = 248 + Width = 14 + Height = 13 + Caption = 'em' + end object fcbCode: TscFontComboBox Left = 176 Top = 41 @@ -260,23 +274,23 @@ inherited frmKeyboardFonts: TfrmKeyboardFonts end object cmdOK: TButton Left = 162 - Top = 248 + Top = 280 Width = 91 Height = 25 Caption = 'OK' Default = True - TabOrder = 12 + TabOrder = 14 OnClick = cmdOKClick end object cmdCancel: TButton Left = 259 - Top = 248 + Top = 280 Width = 90 Height = 25 Cancel = True Caption = 'Cancel' ModalResult = 2 - TabOrder = 13 + TabOrder = 15 end object fcbTouchLayoutPhone: TscFontComboBox Left = 176 @@ -347,4 +361,34 @@ inherited frmKeyboardFonts: TfrmKeyboardFonts ShowPreviewFontName = False ShowPreviewInList = False end + object editDisplayMapSize: TEdit + Left = 391 + Top = 245 + Width = 66 + Height = 21 + TabOrder = 13 + end + object fcbDisplayMap: TscFontComboBox + Left = 176 + Top = 245 + Width = 209 + Height = 24 + DropDownCount = 12 + Font.Charset = DEFAULT_CHARSET + Font.Color = clWindowText + Font.Height = -13 + Font.Name = 'Tahoma' + Font.Style = [] + ItemHeight = 18 + ParentFont = False + TabOrder = 12 + MoveUsedToTop = False + ColorUsed = clWindow + PreviewWidth = 240 + PreviewHeight = 60 + FontTypes = [ftTrueTypeAnsi, ftTrueTypeSymbol] + ShowPreview = False + ShowPreviewFontName = False + ShowPreviewInList = False + end end diff --git a/developer/src/tike/dialogs/UfrmKeyboardFonts.pas b/developer/src/tike/dialogs/UfrmKeyboardFonts.pas index 8c5bc103c2..7f5d4546bd 100644 --- a/developer/src/tike/dialogs/UfrmKeyboardFonts.pas +++ b/developer/src/tike/dialogs/UfrmKeyboardFonts.pas @@ -66,6 +66,10 @@ type fcbTouchLayoutPhone: TscFontComboBox; fcbTouchLayoutTablet: TscFontComboBox; fcbTouchLayoutDesktop: TscFontComboBox; + lblDisplayMap: TLabel; + editDisplayMapSize: TEdit; + fcbDisplayMap: TscFontComboBox; + Label4: TLabel; procedure FormCreate(Sender: TObject); procedure cmdOKClick(Sender: TObject); private @@ -120,6 +124,10 @@ begin FControls[kfontTouchLayoutDesktop].CaptionLabel := lblTouchLayoutDesktopFont; FControls[kfontTouchLayoutDesktop].NameCombo := fcbTouchLayoutDesktop; FControls[kfontTouchLayoutDesktop].SizeEdit := editTouchLayoutDesktopSize; + + FControls[kfontDisplayMap].CaptionLabel := lblDisplayMap; + FControls[kfontDisplayMap].NameCombo := fcbDisplayMap; + FControls[kfontDisplayMap].SizeEdit := editDisplayMapSize; end; function TfrmKeyboardFonts.GetFontInfo(Index: TKeyboardFont): TKeyboardFontInfo; diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.kmnProjectFileUI.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.kmnProjectFileUI.pas index 863b737dc0..10e9dcdd3c 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.kmnProjectFileUI.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.kmnProjectFileUI.pas @@ -200,7 +200,7 @@ end; function TkmnProjectFileUI.TestKeymanWeb(FSilent: Boolean): Boolean; // I4409 var - FCompiledName: string; + FCompiledName, OSKFont: string; editor: TfrmTikeEditor; wizard: TfrmKeymanWizard; i: TKeyboardFont; @@ -250,7 +250,7 @@ begin // We register all fonts that are used by the layout, // but just once for each reference! - for i := kfontChar to kfontTouchLayoutDesktop do + for i := kfontChar to kfontDisplayMap do begin Found := False; for j := kfontChar to TKeyboardFont(Ord(i)-1) do @@ -265,13 +265,18 @@ begin if TServerUI.VerifyServerRunning then begin + if Wizard.FontInfo[kfontDisplayMap].Enabled then + begin + OSKFont := Wizard.FontInfo[kfontDisplayMap].Name + end + else + OSKFont := Wizard.FontInfo[kfontOSK].Name; + TServerDebugAPI.RegisterKeyboard( FCompiledName, ProjectFile.FileVersion, - // We only need to specify the char + osk fonts here - // as the others are referenced in the touch layout definition directly Wizard.FontInfo[kfontChar].Name, - Wizard.FontInfo[kfontOSK].Name + OSKFont ); wizard.NotifyStartedWebDebug; // I4021 diff --git a/docs/build/linux-ubuntu.md b/docs/build/linux-ubuntu.md index 37b2aafa1a..0f531acb59 100644 --- a/docs/build/linux-ubuntu.md +++ b/docs/build/linux-ubuntu.md @@ -238,83 +238,25 @@ Android projects. `JAVA_HOME_11` is mostly used by CI. ## Docker Builder The Docker builder allows you to perform a build from anywhere Docker is supported. - -To build the docker image: - -```shell -cd linux -docker pull ubuntu:latest # (to make sure you have an up-to-date image) -docker build . -t keymanapp/keyman-linux-builder:latest -``` - -Once the image is built, it may be used to build parts of Keyman. - -**Note** that it's not yet possible to run tests in the Docker container. - -- core - - ```shell - # build 'Keyman Core' in docker - # keep linux build artifacts separate - mkdir -p $(git rev-parse --show-toplevel)/core/build/linux - docker run -it --rm -v $(git rev-parse --show-toplevel):/home/build/build \ - -v $(git rev-parse --show-toplevel)/core/build/linux:/home/build/build/core/build \ - keymanapp/keyman-linux-builder:latest \ - core/build.sh --debug - ``` - -- linux - - ```shell - # build 'Keyman for Linux' installation in docker - docker run -it --rm -v $(git rev-parse --show-toplevel):/home/build/build \ - --entrypoint /bin/bash keymanapp/keyman-linux-builder:latest \ - -c 'DESTDIR=/home/build /usr/bin/bashwrapper linux/build.sh --debug build install' - ``` - -- Keyman Web - - ```shell - # build 'Keyman Web' in docker - docker run --privileged -it --rm \ - -v $(git rev-parse --show-toplevel):/home/build/build \ - keymanapp/keyman-linux-builder:latest \ - web/build.sh --debug - ``` - -- Keyman for Android - - ```shell - # build 'Keyman for Android' in docker - docker run -it --rm -v $(git rev-parse --show-toplevel):/home/build/build \ - keymanapp/keyman-linux-builder:latest \ - android/build.sh --debug - ``` - -### Customizing the builder - -You can use Docker [build args](https://docs.docker.com/build/guide/build-args/) to customize the image build. As an example, the following will build an image explicitly with Ubuntu 23.04 and Node.js 20. Check the [Dockerfile](../../linux/Dockerfile) for `ARG` entries. - -```shell -cd linux -docker pull ubuntu:23.04 # (to make sure you have an up-to-date image) -docker build . -t keymanapp/keyman-linux-builder:u23.04-node20 --build-arg OS_VERSION=23.04 --build-arg NODE_MAJOR=20 -```` +See [this README.md](../../resources/docker-images/README.md) for details. ### Using the builder with VSCode [Dev Containers](https://code.visualstudio.com/docs/devcontainers/tutorial) -1. Save the following as `.devcontainer/devcontainer.json`, updating the `image` to match the Docker image built above. +1. Save the following as `.devcontainer/devcontainer.json`, updating the `image` + to match the Docker image built above. -```json -// file: .devcontainer/devcontainer.json -{ - "name": "Keyman Ubuntu 23.04", - "image": "keymanapp/keyman-linux-builder:u23.04-node18" -} -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu -``` + ```json + // file: .devcontainer/devcontainer.json + { + "name": "Keyman Ubuntu 23.04", + "image": "keymanapp/keyman-linux-builder:u23.04-node18" + } + // For format details, see https://aka.ms/devcontainer.json. For config options, + // see the README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu + ``` -2. in VSCode, use the "Dev Containers: Open Folder In Container…" option and choose the Keyman directory. +2. in VSCode, use the "Dev Containers: Open Folder In Container…" option and + choose the Keyman directory. -3. You will be given a window which is running VSCode inside this builder image, regardless of your host OS. +3. You will be given a window which is running VSCode inside this builder + image, regardless of your host OS. diff --git a/docs/file-formats/kmx-file-format.md b/docs/file-formats/kmx-file-format.md index 67288bc123..e13575ae4a 100644 --- a/docs/file-formats/kmx-file-format.md +++ b/docs/file-formats/kmx-file-format.md @@ -18,11 +18,14 @@ source files. zeroed. Note that `COMP_KEYBOARD_KMXPLUS` must follow immediately after `COMP_KEYBOARD` with no empty space (that is at byte offset 64). -A .kmx file is essentially a 32-bit memory mapped file. In a 32-bit process, the +A .kmx file is essentially a 32-bit memory mapped file, however there is +no guarantee that all sections start at a 32-bit boundary. +In a 32-bit process on architectures that use flexible alignment, the file can be loaded into a memory buffer, and (assuming the file is valid), all offset values in the file can be rewritten into pointers by adding the base of -the memory buffer to them. (In a 64-bit process, a fixup is needed to make space -for 64-bit pointers.) +the memory buffer to them. (In a 64-bit process or on architectures that +use strict alignment (like emscripten), a fixup is needed to make space +for aligned pointers.) ## `COMP_KEYBOARD`: KMX header diff --git a/docs/minimum-versions.md b/docs/minimum-versions.md index d30b1e858a..174f4b518e 100644 --- a/docs/minimum-versions.md +++ b/docs/minimum-versions.md @@ -67,6 +67,7 @@ https://help.keyman.com/developer/engine/android/latest-version/ | KEYMAN_MIN_VERSION_NPM | 10.5.1 | | KEYMAN_MIN_VERSION_VISUAL_STUDIO | 2019 | | KEYMAN_VERSION_CLDR | 45 | +| KEYMAN_VERSION_GRADLE | 7.6.4 | | KEYMAN_VERSION_ICU | 73.1 | | KEYMAN_VERSION_ISO639_3 | 2024-05-22 | | KEYMAN_VERSION_JAVA | 11 | diff --git a/ios/docs/help/context/gestures.md b/ios/docs/help/context/gestures.md new file mode 100644 index 0000000000..251de0f1ce --- /dev/null +++ b/ios/docs/help/context/gestures.md @@ -0,0 +1,15 @@ +--- +title: Gesture Hints and Tips +--- + +## Using Gestures on the Keyboard Keys +There are now several gestures available to interact with some keys on the keyboard. Keys which have gestures available will normally show them as a hint in the top right of the key: + +![](../ios_images/gesture-hint.png) + +* **Long press**: Press and hold a key, and after a moment a submenu will appear. Slide the finger to the submenu to select a key. Release the finger, and the highlighted key from the submenu will be output. + +* **Flick**: Press and hold a key, and then slide the finger in various directions on a key to reach alternate outputs. The key will animate to show the expected output when the finger gets released. A common flick is to slide down to reach numerals on the top row of the keyboard. + + An up-flick on keys that support longpresses but do not have a defined up-flick will skip the wait and immediately display their longpress submenu. +* **Multitap**: Some keys can be pressed repeatedly and rapidly to reach alternate outputs. Tapping rapidly twice on Shift will activate Caps Lock on many keyboards. \ No newline at end of file diff --git a/ios/docs/help/context/index.md b/ios/docs/help/context/index.md index 99e41dd65c..90cfbfd5f6 100644 --- a/ios/docs/help/context/index.md +++ b/ios/docs/help/context/index.md @@ -2,6 +2,8 @@ title: Keyman for iPhone and iPad Help --- +[Keyboard gestures](gestures) + | | Keyboard Keys
(Keyboard Present) | |---|---| | ![](../ios_images/globe.png) | Install or select another language/keyboard | diff --git a/ios/docs/help/index.md b/ios/docs/help/index.md index c71c703e72..994bbbda9e 100644 --- a/ios/docs/help/index.md +++ b/ios/docs/help/index.md @@ -18,6 +18,7 @@ title: Keyman for iPhone and iPad 18.0 Help * [Using the Banner on the Keyboard](basic/using-the-banner) * [Switching Between Keyboards](basic/switching-between-keyboards) * [Using a Keyboard](basic/keyboard-usage) +* [Keyboard Gestures](context/gestures) * [Using the Menu](context/) * [Using the Settings Screen](basic/config/) * [Language Settings](basic/language-settings) diff --git a/ios/docs/help/ios_images/gesture-hint.png b/ios/docs/help/ios_images/gesture-hint.png new file mode 100644 index 0000000000..155ba67418 Binary files /dev/null and b/ios/docs/help/ios_images/gesture-hint.png differ diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index d1c711368f..045506b9af 100644 --- a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj +++ b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj @@ -25,8 +25,10 @@ 1645D5952036C6FF0076C51B /* KeymanPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1645D5942036C6FF0076C51B /* KeymanPackage.swift */; }; 1645D5972036C9F80076C51B /* KMPKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1645D5962036C9F80076C51B /* KMPKeyboard.swift */; }; 165EB3A12098993900040A69 /* KeyboardError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165EB3A02098993900040A69 /* KeyboardError.swift */; }; + 29084CAB2CD48B5D004070E7 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 29084CAA2CD48B5D004070E7 /* Images.xcassets */; }; 296EF2C72AFA26C700E3E384 /* ZIPFoundation.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 296EF2C62AFA26C700E3E384 /* ZIPFoundation.xcframework */; }; 29B30C232B564F9900C342A4 /* KeymanEngineLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29B30C222B564F9900C342A4 /* KeymanEngineLogger.swift */; }; + 29E202BD2CCB7541008B4740 /* KeyboardHeightViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E202BC2CCB7541008B4740 /* KeyboardHeightViewController.swift */; }; 377D10DE26846B8900467431 /* SpacebarTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377D10DD26846B8900467431 /* SpacebarTextViewController.swift */; }; 6CD5DFAA150F6DC8007A5DDE /* icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 6CD5DFA8150F6DC8007A5DDE /* icon.png */; }; 6CD5DFAB150F6DC8007A5DDE /* icon@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 6CD5DFA9150F6DC8007A5DDE /* icon@2x.png */; }; @@ -298,6 +300,7 @@ 1645D5942036C6FF0076C51B /* KeymanPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeymanPackage.swift; sourceTree = ""; }; 1645D5962036C9F80076C51B /* KMPKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPKeyboard.swift; sourceTree = ""; }; 165EB3A02098993900040A69 /* KeyboardError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardError.swift; sourceTree = ""; }; + 29084CAA2CD48B5D004070E7 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 293EA3DB2705955300545EED /* ha */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ha; path = ha.lproj/ResourceInfoView.strings; sourceTree = ""; }; 293EA3DC2705964200545EED /* ha */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ha; path = ha.lproj/Localizable.strings; sourceTree = ""; }; 293EA3DD270596B700545EED /* ha */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ha; path = ha.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -333,6 +336,7 @@ 29C1E17128001F7600759EDE /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/ResourceInfoView.strings"; sourceTree = ""; }; 29C1E17228001F8800759EDE /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; 29C1E17328001FA200759EDE /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "pt-PT"; path = "pt-PT.lproj/Localizable.stringsdict"; sourceTree = ""; }; + 29E202BC2CCB7541008B4740 /* KeyboardHeightViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardHeightViewController.swift; sourceTree = ""; }; 377D10DD26846B8900467431 /* SpacebarTextViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacebarTextViewController.swift; sourceTree = ""; }; 6C0A140E151EA930007FA4AD /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; 6C0A140F151EA930007FA4AD /* Keyman.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; lineEnding = 0; path = Keyman.xcconfig; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.xcconfig; }; @@ -705,6 +709,7 @@ 9A60763C22892485003BCFBA /* Settings.storyboard */, 9A60764322893A4E003BCFBA /* SettingsViewController.swift */, 377D10DD26846B8900467431 /* SpacebarTextViewController.swift */, + 29E202BC2CCB7541008B4740 /* KeyboardHeightViewController.swift */, ); name = Settings; sourceTree = ""; @@ -760,6 +765,7 @@ CEA1486F2407808F00C6ECD2 /* Localizable.strings */, CE96E42D24D1229A005B8E5A /* Localizable.stringsdict */, CE7A26D023CEE5790005955C /* Keyboard Colors.xcassets */, + 29084CAA2CD48B5D004070E7 /* Images.xcassets */, C06D372E1F81F4E100F61AE0 /* Info.plist */, F273AB9615641D9300A47CEE /* Classes */, ); @@ -1260,6 +1266,7 @@ buildActionMask = 2147483647; files = ( C06D37601F82095200F61AE0 /* Keyman.bundle in Resources */, + 29084CAB2CD48B5D004070E7 /* Images.xcassets in Resources */, CEA1486C2407808F00C6ECD2 /* Localizable.strings in Resources */, 9ADC459F22E1895D004C78C6 /* LanguageLMDetailViewController.xib in Resources */, CEA14870240780E100C6ECD2 /* ResourceInfoView.xib in Resources */, @@ -1447,6 +1454,7 @@ CE67D961228A6F190029F2B5 /* KeyboardCommandStructs.swift in Sources */, CE87751E24C68DA500B1475A /* KeyboardSearchViewController.swift in Sources */, CE8B0BBF248764ED0045EB2E /* KMPResource.swift in Sources */, + 29E202BD2CCB7541008B4740 /* KeyboardHeightViewController.swift in Sources */, 9AD4F53C229F85AC007992D3 /* LanguageSettingsViewController.swift in Sources */, CE969BE8251AD8B500376D6A /* PackageWebViewController.swift in Sources */, CE7A26DB23CEEF640005955C /* Colors.swift in Sources */, diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift b/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift index 3b4ed5a1d6..01d2862838 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Constants.swift @@ -41,6 +41,8 @@ public enum Key { static let synchronizeSWLexicalModel = "KeymanSynchronizeSWLexicalModel" static let migrationLevel = "KeymanEngineMigrationLevel" + static let portraitKeyboardHeight = "PortraitKeyboardHeight" + static let landscapeKeyboardHeight = "LandscapeKeyboardHeight" // JSON keys for language REST calls static let options = "options" @@ -93,6 +95,9 @@ public enum Defaults { public static let lexicalModel: InstallableLexicalModel = { return lexicalModelPackage.findResource(withID: lexicalModelID)! }() + + // default for ancient/unrecognized devices + static let unknownDeviceKeyboardHeight: CGFloat = 216.0 } public enum Resources { diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Extension/UserDefaults+Types.swift b/ios/engine/KMEI/KeymanEngine/Classes/Extension/UserDefaults+Types.swift index 5cfed55ea2..8991660dae 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Extension/UserDefaults+Types.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Extension/UserDefaults+Types.swift @@ -331,7 +331,27 @@ public extension UserDefaults { set(prefs, forKey: Key.userCorrectSettings) } } - + + var portraitKeyboardHeight: Double { + get { + return double(forKey: Key.portraitKeyboardHeight) + } + + set(height) { + set(height, forKey: Key.portraitKeyboardHeight) + } + } + + var landscapeKeyboardHeight: Double { + get { + return double(forKey: Key.landscapeKeyboardHeight) + } + + set(height) { + set(height, forKey: Key.landscapeKeyboardHeight) + } + } + func predictSettingForLanguage(languageID: String) -> Bool { if let dict = predictionEnablements { return dict[languageID] ?? true diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift index 003a4e15a8..16acd10f9e 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/InputViewController.swift @@ -72,6 +72,7 @@ private class CustomInputView: UIInputView, UIInputViewAudioFeedback { } func setConstraints() { + os_log("CustomInputView setConstraints", log: KeymanEngineLogger.ui, type: .info) let innerView = keymanWeb.view! let guide = self.safeAreaLayoutGuide @@ -88,18 +89,48 @@ private class CustomInputView: UIInputView, UIInputViewAudioFeedback { kbdWidthConstraint.priority = .defaultHigh kbdWidthConstraint.isActive = true - let bannerHeight = InputViewController.topBarHeight + self.buildKeyboardHeightConstraints(bannerHeight: InputViewController.topBarHeight) + } + /** + * Due to new custom keyboard height as chosen by the user. + * The value for the new keyboard height originates from KeyboardHeightViewController. + */ + func keyboardHeightChanged() { + os_log("CustomInputView keyboardHeightChanged", log: KeymanEngineLogger.ui, type: .info) + + // deactivate constraints for both orientations (though one should already be inactive) + landscapeConstraint?.isActive = false + portraitConstraint?.isActive = false + + // rebuild both portrait and landscape constraints + self.buildKeyboardHeightConstraints(bannerHeight: InputViewController.topBarHeight) + + // activate constraints for the current orientation + if InputViewController.isPortrait { + portraitConstraint?.isActive = true + } else { + landscapeConstraint?.isActive = true + } + + self.setNeedsLayout() + } + + private func buildKeyboardHeightConstraints(bannerHeight: CGFloat) { + os_log("CustomInputView buildKeyboardHeightConstraints", log: KeymanEngineLogger.ui, type: .info) + let innerView = keymanWeb.view! + // Cannot be met by the in-app keyboard, but helps to 'force' height for the system keyboard. - let portraitHeight = innerView.heightAnchor.constraint(equalToConstant: bannerHeight + keymanWeb.constraintTargetHeight(isPortrait: true)) - portraitHeight.identifier = "Height constraint for portrait mode" - portraitHeight.priority = .defaultHigh - let landscapeHeight = innerView.heightAnchor.constraint(equalToConstant: bannerHeight + keymanWeb.constraintTargetHeight(isPortrait: false)) - landscapeHeight.identifier = "Height constraint for landscape mode" - landscapeHeight.priority = .defaultHigh - - portraitConstraint = portraitHeight - landscapeConstraint = landscapeHeight + let portraitHeightConstraint = innerView.heightAnchor.constraint(equalToConstant: bannerHeight + keymanWeb.readKeyboardHeight(isPortrait: true)!) + portraitHeightConstraint.identifier = "Height constraint for portrait mode" + portraitHeightConstraint.priority = .defaultHigh + + let landscapeHeightConstraint = innerView.heightAnchor.constraint(equalToConstant: bannerHeight + keymanWeb.readKeyboardHeight(isPortrait: false)!) + landscapeHeightConstraint.identifier = "Height constraint for landscape mode" + landscapeHeightConstraint.priority = .defaultHigh + + portraitConstraint = portraitHeightConstraint + landscapeConstraint = landscapeHeightConstraint // .isActive will be set according to the current portrait/landscape perspective. } @@ -153,7 +184,7 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate { } var expandedHeight: CGFloat { - return keymanWeb.keyboardHeight + InputViewController.topBarHeight + return keymanWeb.keyboardSize.height + InputViewController.topBarHeight } public convenience init() { @@ -504,7 +535,7 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate { } public var kmwHeight: CGFloat { - return keymanWeb.keyboardHeight + return keymanWeb.keyboardSize.height } func clearModel() { @@ -512,13 +543,24 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate { } private func setInnerConstraints() { - let iv = self.inputView as! CustomInputView - iv.setConstraints() + let customInputView = self.inputView as! CustomInputView + customInputView.setConstraints() self.updateViewConstraints() fixLayout() } + /** + * Due to new custom keyboard height as chosen by the user. + * The value for the new keyboard height originates from KeyboardHeightViewController. + */ + func keyboardHeightChanged() { + os_log("InputViewController keyboardHeightChanged", log: KeymanEngineLogger.ui, type: .debug) + if let customInputView = self.inputView as? CustomInputView { + customInputView.keyboardHeightChanged() + } + } + func fixLayout() { view.setNeedsLayout() view.layoutIfNeeded() diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeyboardScaleMap.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeyboardScaleMap.swift index c18fb8c7ad..0335a1035c 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeyboardScaleMap.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeyboardScaleMap.swift @@ -217,6 +217,8 @@ class KeyboardScaleMap { screenSize: CGSize = UIScreen.main.bounds.size, asPhone: Bool? = nil) -> KeyboardSize? { if let scaling = shared.scalings[KeyboardScaleMap.hashKey(for: device)] { + let kbHeight = (forPortrait ? scaling.portrait : scaling.landscape).keyboardHeight + os_log("KeyboardScaleMap getDeviceDefaultKeyboardScale keyboard height: %f", log:KeymanEngineLogger.ui, type: .debug, kbHeight) return forPortrait ? scaling.portrait : scaling.landscape } @@ -240,6 +242,9 @@ class KeyboardScaleMap { // keyboard dimensions. It's not a perfect rule, but should suffice for a stop-gap solution. let scaling = shared.scalings[KeyboardScaleMap.hashKey(for: mappedDevice)]! + let kbHeight = (forPortrait ? scaling.portrait : scaling.landscape).keyboardHeight + os_log("KeyboardScaleMap getDeviceDefaultKeyboardScale keyboard height for missing device: %f", log:KeymanEngineLogger.ui, type: .debug, kbHeight) + return forPortrait ? scaling.portrait : scaling.landscape } } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 50a22e6fe4..c101ebd832 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -102,6 +102,7 @@ class KeymanWebViewController: UIViewController { // after it has been replaced by KMW's OSK resizing operation.) keyboardSize = view.bounds.size + os_log("KeymanWebViewController viewWillLayoutSubviews to keyboardSize %{public}s", log:KeymanEngineLogger.ui, type: .debug, NSCoder.string(for:keyboardSize)) } open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -695,36 +696,100 @@ extension KeymanWebViewController: KeymanWebDelegate { // MARK: - Manage views extension KeymanWebViewController { // MARK: - Sizing - public var keyboardHeight: CGFloat { - return keyboardSize.height - } @objc func menuKeyHeld(_ keymanWeb: KeymanWebViewController) { self.delegate?.menuKeyHeld(self) } - func constraintTargetHeight(isPortrait: Bool) -> CGFloat { - return KeyboardScaleMap.getDeviceDefaultKeyboardScale(forPortrait: isPortrait)?.keyboardHeight ?? 216 // default for ancient devices - } + func determineDefaultKeyboardHeight(isPortrait: Bool) -> CGFloat { + os_log("determineDefaultKeyboardHeight", log:KeymanEngineLogger.ui, type: .info) - var keyboardWidth: CGFloat { - return keyboardSize.width + let keyboardHeight = KeyboardScaleMap.getDeviceDefaultKeyboardScale(forPortrait: isPortrait)?.keyboardHeight ?? Defaults.unknownDeviceKeyboardHeight + + return keyboardHeight; } func initKeyboardSize() { - var width: CGFloat - var height: CGFloat + let width: CGFloat width = UIScreen.main.bounds.width - - if Util.isSystemKeyboard { - height = constraintTargetHeight(isPortrait: InputViewController.isPortrait) + var height: CGFloat + + // get orientation differently if system or in-app keyboard + let portrait = Util.isSystemKeyboard ? InputViewController.isPortrait : UIDevice.current.orientation.isPortrait + + /** + * If keyboard height is saved in UserDefaults, then use it + */ + if let savedHeight = self.readKeyboardHeight(isPortrait: portrait) { + height = savedHeight } else { - height = constraintTargetHeight(isPortrait: UIDevice.current.orientation.isPortrait) + /** + * Otherwise, get default keyboard height for this orientation and write to UserDefaults + */ + height = self.determineDefaultKeyboardHeight(isPortrait: portrait) + self.writeKeyboardHeightIfDoesNotExist(isPortrait: portrait, height: height) + + /** + * If we need to write out the keyboard height for one orientation, then we + * expect that the other must be written also. + * Write it out now, but only if a value for keyboard height does not already exist. + */ + height = self.determineDefaultKeyboardHeight(isPortrait: !portrait) + self.writeKeyboardHeightIfDoesNotExist(isPortrait: !portrait, height: height) } + + /** + * no need to check for Util.isSystemKeyboard because this is shared storage + * the UserDefaults are readable and writeable by both system and in-app + */ - keyboardSize = CGSize(width: width, height: height) + self.keyboardSize = CGSize(width: width, height: height) + os_log("KeymanWebViewController initKeyboardSize %{public}s", log:KeymanEngineLogger.ui, type: .default, NSCoder.string(for:keyboardSize)) } + /** + * reads and returns keyboard height if it is found in UserDefaults, otherwise returns nil + */ + func readKeyboardHeight(isPortrait: Bool) -> CGFloat? { + var height: CGFloat? = nil + + if (isPortrait) { + if (Storage.active.userDefaults.object(forKey: Key.portraitKeyboardHeight) != nil) { + height = Storage.active.userDefaults.portraitKeyboardHeight + } + } else { // landscape + if (Storage.active.userDefaults.object(forKey: Key.landscapeKeyboardHeight) != nil) { + height = Storage.active.userDefaults.landscapeKeyboardHeight + } + } + + let message = "readKeyboardHeight, for isPortrait \(isPortrait) value \(String(describing: height))" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .info, message) + + return height; + } + + /** + * Write out the keyboard height to the UserDefaults but only if it does not exist there yet. + * If it exists, then we assume it was configured by the user and do not want to + * overwrite that value with a default value derived for this device. + */ + func writeKeyboardHeightIfDoesNotExist(isPortrait: Bool, height: CGFloat) { + let writeMessage = "writeKeyboardHeightIfDoesNotExist, isPortrait: \(isPortrait) height: \(height)" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .info, writeMessage) + if (isPortrait) { + if (Storage.active.userDefaults.object(forKey: Key.portraitKeyboardHeight) == nil) { + Storage.active.userDefaults.portraitKeyboardHeight = height + os_log("portrait keyboardHeight default value written", log:KeymanEngineLogger.ui, type: .info, writeMessage) + } + } else { + if (Storage.active.userDefaults.object(forKey: Key.landscapeKeyboardHeight) == nil) { + Storage.active.userDefaults.landscapeKeyboardHeight = height + os_log("landscape keyboardHeight default value written", log:KeymanEngineLogger.ui, type: .info, writeMessage) + } + } + } + var keyboardSize: CGSize { get { if kbSize.equalTo(CGSize.zero) { @@ -750,15 +815,6 @@ extension KeymanWebViewController { } } - @objc func resizeDelay() { - // + 1000 to work around iOS bug with resizing on landscape orientation. Technically we only - // need this for landscape but it doesn't hurt to do it with both. 1000 is a big number that - // should hopefully work on all devices. - let kbWidth = keyboardWidth - let kbHeight = keyboardHeight - view.frame = CGRect(x: 0.0, y: 0.0, width: kbWidth, height: kbHeight + 1000) - } - // Keyman interaction func resizeKeyboard() { fixLayout() @@ -768,6 +824,7 @@ extension KeymanWebViewController { // the first time. setOskWidth(Int(kbSize.width)) setOskHeight(Int(kbSize.height)) + os_log("KeymanWebViewController resizeKeyboard to kbSize %{public}s", log:KeymanEngineLogger.ui, type: .debug, NSCoder.string(for:kbSize)) } func resetKeyboardState() { diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardHeightViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardHeightViewController.swift new file mode 100644 index 0000000000..4326dc52d4 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardHeightViewController.swift @@ -0,0 +1,440 @@ +/* + * Keyman is copyright (C) SIL International. MIT License. + * + * Created by Shawn Schantz on 2024-10-25. + * + * View controller for adjusting the height of the keyboard + */ + +import Foundation +import UIKit +import os.log + +class KeyboardHeightViewController: UIViewController { + let contentView: UIView = UIView() + let defaultButton = UIButton(type: .roundedRect) + let label: UILabel = UILabel() + let keyboardImage: UIImageView = UIImageView() + let keyboardResizer: UIImageView = UIImageView() + var keyboardConstraintsArray: [NSLayoutConstraint] = [] + var resizerConstraintsArray: [NSLayoutConstraint] = [] + var isPortrait = true + var keyboardHeight = 0.0 + var defaultPortraitHeight = 0.0 + var defaultLandscapeHeight = 0.0 + var minKeyboardHeight = 10.0 + var maxKeyboardHeight = 10.0 + + public init() { + super.init(nibName: nil, bundle: nil) + _ = view + } + + override func viewDidLoad() { + super.viewDidLoad() + + os_log("KeyboardHeightViewController viewDidLoad", log:KeymanEngineLogger.ui, type: .info) + + self.determineOrientation() + self.determineDefaultKeyboardHeights() + self.applyKeyboardHeight() + + title = NSLocalizedString("adjust-keyboard-height-title", bundle: engineBundle, comment: "") + navigationItem.setHidesBackButton(false, animated: true) + navigationItem.leftBarButtonItem?.isEnabled = true + + navigationController?.toolbar?.barTintColor = UIColor.orange + + self.configureContentView() + self.configureDefaultHeightButton() + self.configureLabel() + self.configureKeyboardImage() + self.updateKeyboardImage() + self.configureKeyboardConstraints() + self.configureKeyboardResizer() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.calculateKeyboardHeightLimits() + } + + private func determineOrientation() { + self.isPortrait = UIScreen.main.bounds.height > UIScreen.main.bounds.width + let message = "determineOrientation, isPortrait: \(self.isPortrait)" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .debug, message) + } + + private func determineDefaultKeyboardHeights() { + // if no KeyboardScaleMap found for device, then default to Defaults.defaultUnknownKeyboardHeight + let portraitKeyboardScale = KeyboardScaleMap.getDeviceDefaultKeyboardScale(forPortrait: true) + self.defaultPortraitHeight = Double(portraitKeyboardScale?.keyboardHeight ?? Defaults.unknownDeviceKeyboardHeight) + + let landscapeKeyboardScale = KeyboardScaleMap.getDeviceDefaultKeyboardScale(forPortrait: false) + self.defaultLandscapeHeight = Double(landscapeKeyboardScale?.keyboardHeight ?? Defaults.unknownDeviceKeyboardHeight) + } + + /** + * Read the previously set keyboard heights from UserDefaults. + * Running the first time, these values would be defaults set in KeymanWebviewController.initKeyboardSize() + * These values should be set prior to now; if not, log an error and use default values + */ + private func applyKeyboardHeight() { + if (self.isPortrait) { + if (Storage.active.userDefaults.object(forKey: Key.portraitKeyboardHeight) != nil) { + self.keyboardHeight = Storage.active.userDefaults.portraitKeyboardHeight + let message = "applyKeyboardHeight, from UserDefaults loaded portrait value \(self.keyboardHeight)" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .debug, message) + } else { + self.keyboardHeight = self.defaultPortraitHeight + let message = "applyKeyboardHeight, portraitHeight not found in UserDefaults, using default value \(self.keyboardHeight)" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .error, message) + } + } else { + if (Storage.active.userDefaults.object(forKey: Key.portraitKeyboardHeight) != nil) { + self.keyboardHeight = Storage.active.userDefaults.landscapeKeyboardHeight + let message = "applyKeyboardHeight, from UserDefaults loaded landscape value \(self.keyboardHeight)" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .debug, message) + } else { + self.keyboardHeight = self.defaultLandscapeHeight + let message = "applyKeyboardHeight, landscapeHeight not found in UserDefaults, using default value \(self.keyboardHeight)" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .error, message) + } + } + } + + + /** + * Set some reasonable limits on how small or large a keyboard can be set to during resizing. + */ + private func calculateKeyboardHeightLimits() { + // minimum height + self.minKeyboardHeight = 100.0 + + // set maxKeyboardHeight to 100 pts smaller than the contentView height + self.maxKeyboardHeight = contentView.frame.height - 100.0 + + let message = "minKeyboardHeight: \(minKeyboardHeight) maxKeyboardHeight: \(maxKeyboardHeight)" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .debug, message) + } + + /** + * Given the vertical attemptedTranslation in points due to the user dragging the keyboard resizer view, + * return the amount that the user is allowed to grow or shrink the keyboard without exceeding its size limits. + */ + private func applyKeyboardSizeLimits(attemptedTranslation: Double) -> Double { + guard attemptedTranslation != 0 else { + return 0 + } + + // Are we trying to reduce or enlarge the keyboard? + // An increase in keyboardResizer .y translation moves it down and reduces the keyboard height. + let reducingKeyboardHeight = attemptedTranslation > 0 + + // by default, unless we exceed keyboard height limitations, we will approve all of the translation + var approvedTranslation = attemptedTranslation + + // Calculate new proposed height by subtracting the translation. + let proposedKeyboardHeight = self.keyboardHeight - attemptedTranslation + + // calculations vary depending on reducing or enlarging the keyboard + if (reducingKeyboardHeight) { + // if we are reducing too much, then calculate how much translation is allowed + if (proposedKeyboardHeight < self.minKeyboardHeight) { + // if we are already at the minimum size, return 0 + if(self.keyboardHeight <= self.minKeyboardHeight) { + approvedTranslation = 0; + } else { + // how much space to translate the resizer downward, a positive number + let availableToReduce = self.keyboardHeight - self.minKeyboardHeight + approvedTranslation = availableToReduce + } + } + } else { // enlarging the keyboard height + if (proposedKeyboardHeight > self.maxKeyboardHeight) { + // if we are already at the maximum size, return 0 + if(self.keyboardHeight >= self.maxKeyboardHeight) { + approvedTranslation = 0; + } else { + // how much space to translate the resizer upward, a negative number + let availableToEnlarge = self.keyboardHeight - self.maxKeyboardHeight + approvedTranslation = availableToEnlarge + } + } + } + + // round to nearest whole number + approvedTranslation = approvedTranslation.rounded(.toNearestOrAwayFromZero) + let messageBegan = "approvedTranslation: \(approvedTranslation)" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .debug, messageBegan) + return approvedTranslation + } + + /** + * Called when the keyboardResizer view is being moved to adjust the keyboard height. + */ + @objc func handleDrag(_ dragRecognizer: UIPanGestureRecognizer) { + switch dragRecognizer.state { + case .began: + os_log("keyboardResizer handleDrag began", log:KeymanEngineLogger.ui, type: .info) + + case .changed: + self.provideResizeFeedback(drag: dragRecognizer) + + case .ended: + // reset transforms before actual keyboard height change + self.keyboardResizer.transform = .identity + self.keyboardImage.transform = .identity + + let verticalTranslation = dragRecognizer.translation(in: self.contentView).y + let approvedTranslation = self.applyKeyboardSizeLimits(attemptedTranslation: verticalTranslation) + if (approvedTranslation != 0) { + let newKeyboardHeight = keyboardHeight - approvedTranslation; + self.writeNewKeyboardHeight(newHeight: newKeyboardHeight) + } else { + os_log("handleDrag .ended with no resizing", log:KeymanEngineLogger.ui, type: .info) + } + default: break + } + } + + /** + * Stretch the keyboard image and move the keyboardResizier object as it is being resized. + */ + private func provideResizeFeedback(drag: UIPanGestureRecognizer) { + // how much did we drag vertically + let verticalTranslation = drag.translation(in: self.contentView).y + + // enforce keyboard size limits on translation + let approvedTranslation = self.applyKeyboardSizeLimits(attemptedTranslation: verticalTranslation) + + if (approvedTranslation != 0) { + let keyboardTranslation = CGAffineTransform(translationX: 0, y: approvedTranslation) + self.keyboardResizer.transform = keyboardTranslation + + let newKeyboardHeight = self.keyboardHeight - approvedTranslation; + let yScaleFactor = newKeyboardHeight/self.keyboardHeight + + let keyboardScale = CGAffineTransform(scaleX: 1, y: yScaleFactor) + + // concatenate both the translate and the scale for the keyboard image + let keyboardHeightTranslation = CGAffineTransform(translationX: 0, y: approvedTranslation/2) + let keyboardImageTransform = keyboardScale.concatenating(keyboardHeightTranslation) + self.keyboardImage.transform = keyboardImageTransform + } + } + + /** + * Write the new height of the keyboard to to UserDefaults. + */ + private func writeNewKeyboardHeight (newHeight: Double) { + let message = "writeNewKeyboardHeight, newHeight :\(newHeight) isPortrait: \(isPortrait)" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .info, message) + + keyboardHeight = newHeight + self.updateKeyboardConstraints() + + if (isPortrait) { + Storage.active.userDefaults.portraitKeyboardHeight = newHeight + } else { + Storage.active.userDefaults.landscapeKeyboardHeight = newHeight + } + } + + private func configureKeyboardConstraints() { + // create constraints for each orientation and apply when device is rotated + self.keyboardConstraintsArray = [ + keyboardImage.leftAnchor.constraint(equalTo: contentView.leftAnchor), + keyboardImage.rightAnchor.constraint(equalTo: contentView.rightAnchor), + keyboardImage.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -keyboardHeight), + keyboardImage.heightAnchor.constraint(equalToConstant: keyboardHeight) + ] + NSLayoutConstraint.activate(self.keyboardConstraintsArray) + } + + /** + * recalculate constraints for new portrait keyboard height + */ + private func updateKeyboardConstraints() { + if (!self.keyboardConstraintsArray.isEmpty) { + NSLayoutConstraint.deactivate(self.keyboardConstraintsArray) + } + self.keyboardConstraintsArray = [ + keyboardImage.leftAnchor.constraint(equalTo: contentView.leftAnchor), + keyboardImage.rightAnchor.constraint(equalTo: contentView.rightAnchor), + keyboardImage.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -self.keyboardHeight), + keyboardImage.heightAnchor.constraint(equalToConstant: self.keyboardHeight) + ] + NSLayoutConstraint.activate(self.keyboardConstraintsArray) + + self.updateKeyboardResizerConstraints() + } + + private func configureContentView() { + contentView.isUserInteractionEnabled = true + contentView.backgroundColor = .white + view.addSubview(contentView) + + contentView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + contentView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), + contentView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), + contentView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } + + func configureDefaultHeightButton() { + defaultButton.titleLabel?.font = .systemFont(ofSize: 20, weight: .bold) + let buttonTitle = NSLocalizedString("button-label-reset-default-keyboard-height", bundle: engineBundle, comment: "") + defaultButton.setTitle(buttonTitle, for: .normal) + defaultButton.sizeToFit() + defaultButton.layer.cornerRadius = 8.0 + defaultButton.addTarget(self, action: #selector(self.restoreDefaultKeyboardHeight), for: .touchUpInside) + contentView.addSubview(defaultButton) + + defaultButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + defaultButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10.0), + defaultButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10.0) + ]) + } + + @objc private func restoreDefaultKeyboardHeight() { + os_log("restored to default", log:KeymanEngineLogger.ui, type: .info) + let defaultHeight = self.isPortrait ? self.defaultPortraitHeight : self.defaultLandscapeHeight + + UIView.animate(withDuration: 1.1, delay: 0.1, usingSpringWithDamping: 0.35, initialSpringVelocity: 4.5, options: .curveEaseOut, animations: { + self.writeNewKeyboardHeight(newHeight: defaultHeight) + self.view.layoutIfNeeded() + }, completion: nil) + } + + private func configureLabel() { + label.backgroundColor = .white + label.textColor = .black + label.textAlignment = NSTextAlignment.left + label.font = UIFont.systemFont(ofSize: 16.0) + self.setLabelTextForOrientation() + label.numberOfLines = 0 + contentView.addSubview(label) + + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: self.defaultButton.bottomAnchor, constant: 5.0), + label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10.0), + label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant:-25.0), + label.heightAnchor.constraint(greaterThanOrEqualToConstant: 50.0) + ]) + } + + private func setLabelTextForOrientation() { + let dragText = NSLocalizedString("keyboard-drag-instructions", bundle: engineBundle, comment: "") + var rotateText: String? = nil; + + if (self.isPortrait) { + rotateText = NSLocalizedString("portrait-keyboard-rotate-instructions", bundle: engineBundle, comment: "") + } else { + rotateText = NSLocalizedString("landscape-keyboard-rotate-instructions", bundle: engineBundle, comment: "") + } + + label.text = "\(dragText)\n\(rotateText!)" + } + + private func configureKeyboardImage() { + keyboardImage.isUserInteractionEnabled = true + keyboardImage.contentMode = UIView.ContentMode.scaleToFill + keyboardImage.backgroundColor=UIColor.cyan + contentView.addSubview(keyboardImage) + + keyboardImage.translatesAutoresizingMaskIntoConstraints = false + } + + private func configureKeyboardResizer() { + var arrowSymbolImage: UIImage? = nil + keyboardResizer.isUserInteractionEnabled = true + + // if using iOS earlier than 13.0, use bitmap image of arrow and non-dynamic gray + if #available(iOSApplicationExtension 13.0, *) { + arrowSymbolImage = UIImage(systemName: "arrow.up.and.down.square.fill") + keyboardResizer.backgroundColor = UIColor.systemGray3 // adapts for light/dark mode + } else { + arrowSymbolImage = UIImage(named:"arrow.up.and.down", in:engineBundle, compatibleWith:nil) + keyboardResizer.backgroundColor = UIColor.systemGray + } + keyboardResizer.image = arrowSymbolImage + keyboardResizer.layer.cornerRadius = 5 + contentView.addSubview(keyboardResizer) + keyboardResizer.translatesAutoresizingMaskIntoConstraints = false + + self.updateKeyboardResizerConstraints() + + let drag = UIPanGestureRecognizer(target: self, action: #selector(handleDrag)) + drag.maximumNumberOfTouches = 1 + drag.minimumNumberOfTouches = 1 + keyboardResizer.addGestureRecognizer(drag) + } + + private func updateKeyboardResizerConstraints() { + // first, clear existing constraints + if (!resizerConstraintsArray.isEmpty) { + NSLayoutConstraint.deactivate(resizerConstraintsArray) + } + + // add new constraints using the current value of the keyboard height + resizerConstraintsArray = [ + keyboardResizer.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + keyboardResizer.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -keyboardHeight-30), + keyboardResizer.heightAnchor.constraint(equalToConstant: 60), + keyboardResizer.widthAnchor.constraint(equalToConstant: 60) + ] + NSLayoutConstraint.activate(resizerConstraintsArray) + } + + private func updateKeyboardImage() { + var kbImage: UIImage? = nil + if (self.isPortrait) { + kbImage = UIImage(named:"keyboard.compact", in:engineBundle, compatibleWith:nil) + } else { + kbImage = UIImage(named:"keyboard.regular", in:engineBundle, compatibleWith:nil) + } + keyboardImage.image = kbImage + + let kbImageMessage = "updateKeyboardImage, kbImage: \(String(describing: kbImage))" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .debug, kbImageMessage) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + let rotateMessage = "viewWillTransition to size \(size) with current UIScreen.main.bounds.size size = \(UIScreen.main.bounds.size)" + os_log("%{public}s", log:KeymanEngineLogger.ui, type: .info, rotateMessage) + super.viewWillTransition(to: size, with: coordinator) + + /** + determine which orientation the device had rotated to and adjust content as necessary + */ + self.determineOrientation() + + coordinator.animate(alongsideTransition: { _ in + self.applyKeyboardHeight() + self.calculateKeyboardHeightLimits() + self.setLabelTextForOrientation() + self.updateKeyboardImage() + self.updateKeyboardConstraints() + }, completion: nil) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if (traitCollection.userInterfaceStyle == .dark) { + os_log("keyboardHeight traitCollectionDidChange to .dark", log:KeymanEngineLogger.ui, type: .debug) + } else { + os_log("keyboardHeight traitCollectionDidChange to .light", log:KeymanEngineLogger.ui, type: .debug) + } + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift index d1d134b249..4ab0da660b 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift @@ -926,6 +926,11 @@ public class Manager: NSObject, UIGestureRecognizerDelegate { inputViewController.setContextState(text: text, range: range) } + func keyboardHeightChanged() { + os_log("Manager keyboardHeightChanged", log:KeymanEngineLogger.settings, type: .default) + self.inputViewController.keyboardHeightChanged() + } + var vibrationSupportLevel: VibrationSupport { let device = Device.current diff --git a/ios/engine/KMEI/KeymanEngine/Classes/SettingsViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/SettingsViewController.swift index 543453bb0c..3104cba43f 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/SettingsViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/SettingsViewController.swift @@ -12,6 +12,8 @@ import os.log open class SettingsViewController: UITableViewController { private var itemsArray = [[String: String]]() private var userLanguages: [String: Language] = [:] + private var previousPortraitKeyboardHeight: Double = 0.0 + private var previousLandscapeKeyboardHeight: Double = 0.0 override open func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -32,9 +34,16 @@ open class SettingsViewController: UITableViewController { } @objc func doneClicked(_ sender: Any) { - // While the called method might should be renamed, it does the job well enough. // This resets KMW so that any new and/or updated resources can be properly loaded. + os_log("SettingsViewController doneClicked", log:KeymanEngineLogger.settings, type: .info) Manager.shared.dismissKeyboardPicker(self) + + let newPortraitHeight = Storage.active.userDefaults.portraitKeyboardHeight + let newLandscapeHeight = Storage.active.userDefaults.landscapeKeyboardHeight + + if ((previousPortraitKeyboardHeight != newPortraitHeight) || (previousLandscapeKeyboardHeight != newLandscapeHeight)) { + Manager.shared.keyboardHeightChanged() + } } open func launchSettings(launchingVC: UIViewController, sender: Any?) -> Void { @@ -53,7 +62,8 @@ open class SettingsViewController: UITableViewController { public init(/*storage: Storage*/) { // self.storage = storage super.init(nibName: nil, bundle: nil) - + os_log("init settings", log:KeymanEngineLogger.settings, type: .default) + itemsArray = [[String: String]]() itemsArray.append([ "title": NSLocalizedString("menu-installed-languages-title", bundle: engineBundle, comment: ""), @@ -79,6 +89,12 @@ open class SettingsViewController: UITableViewController { "reuseid": "spacebartext" ]) + itemsArray.append([ + "title": NSLocalizedString("menu-settings-adjust-keyboard-height", bundle: engineBundle, comment: ""), + "subtitle": "", + "reuseid": "adjustkeyboardheight" + ]) + if let _ = URL(string: UIApplication.openSettingsURLString) { itemsArray.append([ "title": NSLocalizedString("menu-settings-system-keyboard-menu", bundle: engineBundle, comment: ""), @@ -176,7 +192,7 @@ open class SettingsViewController: UITableViewController { enableReportingSwitch.rightAnchor.constraint(equalTo: cell.layoutMarginsGuide.rightAnchor).isActive = true enableReportingSwitch.centerYAnchor.constraint(equalTo: cell.layoutMarginsGuide.centerYAnchor).isActive = true - case "systemkeyboardsettings", "installfile", "forcederror", "spacebartext": + case "systemkeyboardsettings", "installfile", "forcederror", "spacebartext", "adjustkeyboardheight": break default: let message = "unknown cellIdentifier(\"\(cellIdentifier ?? "EMPTY")\")" @@ -233,6 +249,10 @@ open class SettingsViewController: UITableViewController { cell.detailTextLabel?.text = NSLocalizedString("menu-settings-spacebar-hint-"+Manager.shared.spacebarText.rawValue, bundle: engineBundle, comment: "") cell.detailTextLabel?.isEnabled = true break + case "adjustkeyboardheight": + cell.accessoryType = .disclosureIndicator + cell.detailTextLabel?.isEnabled = false + break case "showbanner", "showgetstarted": cell.detailTextLabel?.isEnabled = false default: @@ -280,6 +300,8 @@ open class SettingsViewController: UITableViewController { SentryManager.forceError() case "spacebartext": showSpacebarText() + case "adjustkeyboardheight": + showAdjustKeyboardHeight() default: break } @@ -429,4 +451,21 @@ open class SettingsViewController: UITableViewController { } } + func showAdjustKeyboardHeight() { + let vc = KeyboardHeightViewController() + if let nc = navigationController { + self.previousPortraitKeyboardHeight = Storage.active.userDefaults.portraitKeyboardHeight + self.previousLandscapeKeyboardHeight = Storage.active.userDefaults.landscapeKeyboardHeight + + let message = "selected Adjust Keyboard Height, previousPortraitKeyboardHeight: \(previousPortraitKeyboardHeight), previousLandscapeKeyboardHeight: \(previousLandscapeKeyboardHeight)" + os_log("%{public}s", log:KeymanEngineLogger.settings, type: .default, message) + + nc.pushViewController(vc, animated: true) + setIsDoneButtonEnabled(nc, true) + } else { + let message = ("No navigation controller for showing keyboard height view") + os_log("%{public}s", log:KeymanEngineLogger.settings, type: .error, message) + SentryManager.capture(message) + } + } } diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/Contents.json b/ios/engine/KMEI/KeymanEngine/Images.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/arrow.up.and.down.imageset/Contents.json b/ios/engine/KMEI/KeymanEngine/Images.xcassets/arrow.up.and.down.imageset/Contents.json new file mode 100644 index 0000000000..1dc6b4c767 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Images.xcassets/arrow.up.and.down.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "arrow.up.and.down.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/arrow.up.and.down.imageset/arrow.up.and.down.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/arrow.up.and.down.imageset/arrow.up.and.down.png new file mode 100644 index 0000000000..3b903f6fac Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/arrow.up.and.down.imageset/arrow.up.and.down.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/Contents.json b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/Contents.json new file mode 100644 index 0000000000..ad0b164d5d --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "keyboard.compact.light@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "keyboard.compact.dark@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "keyboard.compact.light@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "keyboard.compact.dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "keyboard.compact.light@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "keyboard.compact.dark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.dark@1x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.dark@1x.png new file mode 100644 index 0000000000..ecf0a7b564 Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.dark@1x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.dark@2x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.dark@2x.png new file mode 100644 index 0000000000..f74d9222e4 Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.dark@2x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.dark@3x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.dark@3x.png new file mode 100644 index 0000000000..e0779e9a25 Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.dark@3x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.light@1x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.light@1x.png new file mode 100644 index 0000000000..76acd4598c Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.light@1x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.light@2x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.light@2x.png new file mode 100644 index 0000000000..390c853ddb Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.light@2x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.light@3x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.light@3x.png new file mode 100644 index 0000000000..2d97178aeb Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.compact.imageset/keyboard.compact.light@3x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/Contents.json b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/Contents.json new file mode 100644 index 0000000000..d5ce1d37de --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "keyboard.regular.light@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "keyboard.regular.dark@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "keyboard.regular.light@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "keyboard.regular.dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "keyboard.regular.light@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "keyboard.regular.dark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.dark@1x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.dark@1x.png new file mode 100644 index 0000000000..2b404e91d9 Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.dark@1x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.dark@2x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.dark@2x.png new file mode 100644 index 0000000000..6a6e44e807 Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.dark@2x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.dark@3x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.dark@3x.png new file mode 100644 index 0000000000..0c0e2b49d8 Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.dark@3x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.light@1x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.light@1x.png new file mode 100644 index 0000000000..b57f50670c Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.light@1x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.light@2x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.light@2x.png new file mode 100644 index 0000000000..3f0b1228e6 Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.light@2x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.light@3x.png b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.light@3x.png new file mode 100644 index 0000000000..fd80b07354 Binary files /dev/null and b/ios/engine/KMEI/KeymanEngine/Images.xcassets/keyboard.regular.imageset/keyboard.regular.light@3x.png differ diff --git a/ios/engine/KMEI/KeymanEngine/en.lproj/Localizable.strings b/ios/engine/KMEI/KeymanEngine/en.lproj/Localizable.strings index db0966351e..05a1723ebb 100644 --- a/ios/engine/KMEI/KeymanEngine/en.lproj/Localizable.strings +++ b/ios/engine/KMEI/KeymanEngine/en.lproj/Localizable.strings @@ -226,6 +226,24 @@ /* Text showing name of spacebar caption - language + keyboard */ "menu-settings-spacebar-item-languageKeyboard" = "Language and keyboard"; +/* Label for the "Adjust Keyboard Height" item on the main settings screen */ +"menu-settings-adjust-keyboard-height" = "Adjust Keyboard Height"; + +/* Title for the "Adjust Keyboard Height" settings child screen */ +"adjust-keyboard-height-title" = "Adjust Keyboard"; + +/* Label for "Reset to Default Keyboard Height" button on the adjust height screen */ +"button-label-reset-default-keyboard-height" = "Reset to Default Keyboard Height"; + +/* Instruction text to drag keyboard to resize */ +"keyboard-drag-instructions" = "Drag arrow control to adjust keyboard height."; + +/* Instruction to rotate to adjust landscape keyboard height (displayed when device is portrait) */ +"portrait-keyboard-rotate-instructions" = "Rotate device to adjust for landscape."; + +/* Instruction to rotate to adjust portrait keyboard height (displayed when device is landscape) */ +"landscape-keyboard-rotate-instructions" = "Rotate device to adjust for portrait."; + /* Short text for notification: download failure for keyboard */ "notification-download-failure-keyboard" = "Keyboard download failed"; diff --git a/ios/engine/KMEI/KeymanEngine/es.lproj/Localizable.strings b/ios/engine/KMEI/KeymanEngine/es.lproj/Localizable.strings index d2e753247b..54fc546f0b 100644 --- a/ios/engine/KMEI/KeymanEngine/es.lproj/Localizable.strings +++ b/ios/engine/KMEI/KeymanEngine/es.lproj/Localizable.strings @@ -226,6 +226,24 @@ /* Text showing name of spacebar caption - language + keyboard */ "menu-settings-spacebar-item-languageKeyboard" = "Idioma y teclado"; +/* Label for the "Adjust Keyboard Height" item on the main settings screen */ +"menu-settings-adjust-keyboard-height" = "Ajustar altura del teclado"; + +/* Title for the "Adjust Keyboard Height" settings child screen */ +"adjust-keyboard-height-title" = "Ajustar teclado"; + +/* Label for "Reset to Default Keyboard Height" button on the adjust height screen */ +"button-label-reset-default-keyboard-height" = "Restablecer a la altura predeterminada del teclado"; + +/* Instruction text to drag keyboard to resize */ +"keyboard-drag-instructions" = "Arrastre el control de flecha para ajustar la altura del teclado."; + +/* Instruction to rotate to adjust landscape keyboard height (displayed when device is portrait) */ +"portrait-keyboard-rotate-instructions" = "Gira el dispositivo para ajustarlo al formato horizontal."; + +/* Instruction to rotate to adjust portrait keyboard height (displayed when device is landscape) */ +"landscape-keyboard-rotate-instructions" = "Gira el dispositivo para ajustarlo en vertical."; + /* Short text for notification: download failure for keyboard */ "notification-download-failure-keyboard" = "Error al descargar el teclado"; diff --git a/ios/engine/KMEI/KeymanEngine/km.lproj/Localizable.strings b/ios/engine/KMEI/KeymanEngine/km.lproj/Localizable.strings index 47b548eea2..d6592e3e98 100644 --- a/ios/engine/KMEI/KeymanEngine/km.lproj/Localizable.strings +++ b/ios/engine/KMEI/KeymanEngine/km.lproj/Localizable.strings @@ -226,6 +226,24 @@ /* Text showing name of spacebar caption - language + keyboard */ "menu-settings-spacebar-item-languageKeyboard" = "ភាសា និងក្ដារចុច"; +/* Label for the "Adjust Keyboard Height" item on the main settings screen */ +"menu-settings-adjust-keyboard-height" = "កែកម្ពស់ក្ដារចុច"; + +/* Title for the "Adjust Keyboard Height" settings child screen */ +"adjust-keyboard-height-title" = "កែក្ដារចុច"; + +/* Label for "Reset to Default Keyboard Height" button on the adjust height screen */ +"button-label-reset-default-keyboard-height" = "ប្ដូរ​កម្ពស់ក្ដារចុចទៅលំនាំដើម"; + +/* Instruction text to drag keyboard to resize */ +"keyboard-drag-instructions" = "រំកិលព្រួញដើម្បីកែកម្ពស់ក្ដារចុច"; + +/* Instruction to rotate to adjust landscape keyboard height (displayed when device is portrait) */ +"portrait-keyboard-rotate-instructions" = "បង្វិលឧបករណ៍ដើម្បីប្តូរទៅជាផ្ដេក"; + +/* Instruction to rotate to adjust portrait keyboard height (displayed when device is landscape) */ +"landscape-keyboard-rotate-instructions" = "បង្វិលឧបករណ៍ដើម្បីប្តូរទៅជាបញ្ឈរ"; + /* Short text for notification: download failure for keyboard */ "notification-download-failure-keyboard" = "មិន​អាច​ទាញ​យក​ក្ដារចុច​បាន​ទេ"; diff --git a/linux/Dockerfile b/linux/Dockerfile deleted file mode 100644 index ea07ec699a..0000000000 --- a/linux/Dockerfile +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (c) 2022-2023 SIL International. All rights reserved. -# -# builder image for a linux build -# see ../docs/build/linux-ubuntu.md - -ARG OS_VERSION=latest -ARG OS_PLATFORM=amd64 - -FROM --platform=${OS_PLATFORM} ubuntu:${OS_VERSION} -LABEL org.opencontainers.image.authors="SIL International." -LABEL org.opencontainers.image.url="https://github.com/keymanapp/keyman.git" -LABEL org.opencontainers.image.title="Keyman Linux Build Image" - -# We will switch to a build user after some installation -USER root -ENV HOME /home/build -RUN useradd -c "Build user" --home-dir $HOME --create-home --shell /usr/bin/bashwrapper build -VOLUME /home/build/build -WORKDIR /home/build/build -ENV DEBIAN_FRONTEND noninteractive -ENV DEBIAN_PRIORITY critical -ENV DEBCONF_NOWARNINGS yes - -# Update to the latest -RUN apt-get -q -y update && \ - apt-get -q -y install devscripts equivs meson python3 python3-setuptools software-properties-common curl && \ - add-apt-repository ppa:keymanapp/keyman && \ - add-apt-repository ppa:keymanapp/keyman-alpha -RUN apt-get -q -y update && \ - apt-get -q -y upgrade - -# Install dependencies -ADD debian/control /tmp/control -# Answer 'yes' to install questions -RUN (yes | mk-build-deps --install /tmp/control) || true && \ - rm /tmp/control - -# Install Node -ARG NODE_MAJOR=18 -RUN apt-get install -q -y ca-certificates curl gnupg && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_"${NODE_MAJOR}".x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && apt-get update && apt-get install nodejs -y - -ARG EMSCRIPTEN_VERSION=3.1.44 -# Install emscripten -RUN cd /usr/share && \ - git clone https://github.com/emscripten-core/emsdk.git && \ - cd emsdk && \ - ./emsdk install ${EMSCRIPTEN_VERSION} && \ - ./emsdk activate ${EMSCRIPTEN_VERSION} && \ - echo "#!/bin/bash" > /usr/bin/bashwrapper && \ - echo "export EMSCRIPTEN_BASE=/usr/share/emsdk/upstream/emscripten" >> /usr/bin/bashwrapper - -# Keyman Web -RUN curl --output google-chrome-stable_current_amd64.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \ - apt-get -q -y install ./google-chrome-stable_current_amd64.deb && \ - rm google-chrome-stable_current_amd64.deb && \ - echo "export CHROME_BIN=/opt/google/chrome/chrome" >> /usr/bin/bashwrapper - -# Keyman for Android -RUN apt-get -q -y install gradle maven pandoc sdkmanager jq && \ - sdkmanager platform-tools && \ - yes | sdkmanager --licenses && \ - chown -R build:build /opt/android-sdk/ && \ - echo "export ANDROID_HOME=/opt/android-sdk" >> /usr/bin/bashwrapper && \ - echo "export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64" >> /usr/bin/bashwrapper - -# Finish bashwrapper script and adjust permissions -RUN echo "\${@:-bash}" >> /usr/bin/bashwrapper && \ - chmod +x /usr/bin/bashwrapper && \ - chown -R build:build $HOME - -# now, switch to build user -USER build - -# Pre-install gradle. This will put files in ~/.gradle which will speed up builds. -RUN mkdir -p $HOME/tmp/gradle/wrapper && \ - # KMEA uses gradle-7.5.1-bin - curl --location --output $HOME/tmp/gradle/wrapper/gradle-wrapper.jar https://raw.githubusercontent.com/keymanapp/keyman/master/android/KMEA/gradle/wrapper/gradle-wrapper.jar && \ - curl --location --output $HOME/tmp/gradle/wrapper/gradle-wrapper.properties https://raw.githubusercontent.com/keymanapp/keyman/master/android/KMEA/gradle/wrapper/gradle-wrapper.properties && \ - curl --location --output $HOME/tmp/gradlew https://raw.githubusercontent.com/keymanapp/keyman/master/android/KMEA/gradlew && \ - chmod +x $HOME/tmp/gradlew && \ - $HOME/tmp/gradlew --quiet && \ - # Some projects use gradle-7.5.1-all, so we pre-install that as well - curl --location --output $HOME/tmp/gradle/wrapper/gradle-wrapper.jar https://raw.githubusercontent.com/keymanapp/keyman/master/android/Samples/KMSample1/gradle/wrapper/gradle-wrapper.jar && \ - curl --location --output $HOME/tmp/gradle/wrapper/gradle-wrapper.properties https://raw.githubusercontent.com/keymanapp/keyman/master/android/Samples/KMSample1/gradle/wrapper/gradle-wrapper.properties && \ - curl --location --output $HOME/tmp/gradlew https://raw.githubusercontent.com/keymanapp/keyman/master/android/Samples/KMSample1/gradlew && \ - chmod +x $HOME/tmp/gradlew && \ - $HOME/tmp/gradlew --quiet && \ - rm -rf $HOME/tmp - -ENTRYPOINT [ "/usr/bin/bashwrapper" ] diff --git a/linux/debian/changelog b/linux/debian/changelog index 2f89e332c0..22fd40caad 100644 --- a/linux/debian/changelog +++ b/linux/debian/changelog @@ -1,3 +1,18 @@ +keyman (17.0.333-1) unstable; urgency=medium + + * New upstream release. + + -- Eberhard Beilharz Thu, 16 Jan 2025 16:29:44 +0100 + +keyman (17.0.332-1) unstable; urgency=medium + + * set environment variable for rendering of downloads dialog (#12617) + * add `keymanFacename` to .ldml file (#12283) + * ignore exceptions trying to install cache (#11885) + * New upstream release. + + -- Eberhard Beilharz Wed, 11 Dec 2024 15:53:59 +0100 + keyman (17.0.326-1) unstable; urgency=medium * New upstream release @@ -22,12 +37,6 @@ keyman (17.0.279-1) unstable; urgency=medium -- Eberhard Beilharz Thu, 29 Feb 2024 19:17:08 +0100 -keyman (17.0.274-2) UNRELEASED; urgency=medium - - * Add libicu-dev dependency to libkeymancore-dev (closes: #1064915) - - -- Eberhard Beilharz Wed, 28 Feb 2024 17:54:58 +0100 - keyman (17.0.274-1) unstable; urgency=medium * Fix autopkg tests diff --git a/linux/debian/com.keyman.config.appdata.xml b/linux/debian/com.keyman.config.appdata.xml index 9df49a37dc..e7f7152422 100644 --- a/linux/debian/com.keyman.config.appdata.xml +++ b/linux/debian/com.keyman.config.appdata.xml @@ -5,10 +5,15 @@ MIT MIT Keyman - SIL International + + SIL Global + https://keyman.com/cdn/dev/img/keyman-logo.png Keyman for Linux Keyman makes it possible for you to type in over 2,000 languages + + mild +

diff --git a/linux/debian/com.keyman.ibus_keyman.metainfo.xml b/linux/debian/com.keyman.ibus_keyman.metainfo.xml index 1f021d2fce..7b109278a1 100644 --- a/linux/debian/com.keyman.ibus_keyman.metainfo.xml +++ b/linux/debian/com.keyman.ibus_keyman.metainfo.xml @@ -2,9 +2,11 @@ com.keyman.ibus_keyman MIT - GPL-2+ + GPL-2.0+ Keyman - SIL International + + SIL Global + https://keyman.com/cdn/dev/img/keyman-logo.png ibus-keyman diff --git a/linux/debian/copyright b/linux/debian/copyright index ee6603bded..46f156e19b 100644 --- a/linux/debian/copyright +++ b/linux/debian/copyright @@ -4,24 +4,24 @@ Upstream-Contact: Keyman team Source: https://github.com/keymanapp/keyman Files: * -Copyright: 2018-2024 SIL International +Copyright: 2018-2025 SIL Global License: MIT Files: linux/ibus-keyman/* -Copyright: 2004-2024 SIL International +Copyright: 2004-2025 SIL Global License: GPL-2+ Files: linux/ibus-keyman/src/keymanutil.c linux/ibus-keyman/src/keymanutil.h linux/ibus-keyman/src/kmpdetails.c linux/ibus-keyman/src/kmpdetails.h -Copyright: 2009-2024 SIL International +Copyright: 2009-2025 SIL Global License: GPL-2+ or MIT Files: linux/ibus-keyman/src/keyman-service.c linux/ibus-keyman/src/keyman-service.h linux/keyman-config/buildtools/help2md -Copyright: 2018-2024 SIL International +Copyright: 2018-2025 SIL Global License: GPL-3+ Files: linux/ibus-keyman/tests/ibusimcontext.c @@ -31,7 +31,7 @@ Copyright: 2008-2010, Peng Huang 2008-2013, Peng Huang 2008-2021, Red Hat, Inc. 2015-2021, Takao Fujiwara - 2021-2024, SIL International + 2021-2025, SIL Global License: LGPL-2.1+ Files: linux/keyman-config/buildtools/help2man @@ -41,7 +41,7 @@ License: GPL-3+ Files: debian/com.keyman.config.appdata.xml debian/com.keyman.ibus_keyman.metainfo.xml Copyright: 2019 Daniel Glassey - 2022-2024 SIL International + 2022-2025 SIL Global License: MIT License: MIT diff --git a/linux/debian/libkeymancore2.symbols b/linux/debian/libkeymancore2.symbols index 5ecfeb58a2..590b675ddb 100644 --- a/linux/debian/libkeymancore2.symbols +++ b/linux/debian/libkeymancore2.symbols @@ -1,8 +1,8 @@ libkeymancore.so.2 libkeymancore2 #MINVER# * Build-Depends-Package: libkeymancore-dev - (c++|optional)"typeinfo name for std::codecvt_utf8_utf16@Base" 17.0.244 - (c++|optional)std::piecewise_construct@Base 18.0.145 +# We only use a C API, so ignore all C++ symbols in the std namespace that bleed through + (c++|regex|optional)"std::" 18.0.156 km_core_context_clear@Base 17.0.195 km_core_context_get@Base 17.0.195 km_core_context_item_list_size@Base 17.0.195 diff --git a/linux/ibus-keyman/src/engine.c b/linux/ibus-keyman/src/engine.c index 1dffba9cf3..296467e863 100644 --- a/linux/ibus-keyman/src/engine.c +++ b/linux/ibus-keyman/src/engine.c @@ -3,7 +3,7 @@ /* * Keyman Input Method for IBUS (The Input Bus) * - * Copyright (C) 2009-2023 SIL International + * Copyright (C) 2009-2023 SIL Global * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public diff --git a/linux/ibus-keyman/src/engine.h b/linux/ibus-keyman/src/engine.h index 5aaa6303a7..21a99314fb 100644 --- a/linux/ibus-keyman/src/engine.h +++ b/linux/ibus-keyman/src/engine.h @@ -3,7 +3,7 @@ /* * Keyman Input Method for IBUS (The Input Bus) * - * Copyright (C) 2018 SIL International + * Copyright (C) 2018 SIL Global * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public diff --git a/linux/ibus-keyman/src/keyman-service.c b/linux/ibus-keyman/src/keyman-service.c index b5c4afc75f..fa2bdb702b 100644 --- a/linux/ibus-keyman/src/keyman-service.c +++ b/linux/ibus-keyman/src/keyman-service.c @@ -3,7 +3,7 @@ /* * Keyman Input Method for IBUS (The Input Bus) * - * Copyright (C) 2018-2023 SIL International + * Copyright (C) 2018-2023 SIL Global * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public diff --git a/linux/ibus-keyman/src/keyman-service.h b/linux/ibus-keyman/src/keyman-service.h index 0f7221478a..a989d6b513 100644 --- a/linux/ibus-keyman/src/keyman-service.h +++ b/linux/ibus-keyman/src/keyman-service.h @@ -3,7 +3,7 @@ /* * Keyman Input Method for IBUS (The Input Bus) * - * Copyright (C) 2018-2023 SIL International + * Copyright (C) 2018-2023 SIL Global * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public diff --git a/linux/ibus-keyman/src/keymanutil.c b/linux/ibus-keyman/src/keymanutil.c index 80256f7d4b..9f48f0ffce 100644 --- a/linux/ibus-keyman/src/keymanutil.c +++ b/linux/ibus-keyman/src/keymanutil.c @@ -3,7 +3,7 @@ /* * Keyman Input Method for IBUS (The Input Bus) * - * Copyright (C) 2018 SIL International + * Copyright (C) 2018 SIL Global * * keymanutil is dual licensed under the MIT or GPL licenses as described below. * diff --git a/linux/ibus-keyman/src/keymanutil.h b/linux/ibus-keyman/src/keymanutil.h index a4d5355bda..896db5e652 100644 --- a/linux/ibus-keyman/src/keymanutil.h +++ b/linux/ibus-keyman/src/keymanutil.h @@ -3,7 +3,7 @@ /* * Keyman Input Method for IBUS (The Input Bus) * - * Copyright (C) 2018-2023 SIL International + * Copyright (C) 2018-2023 SIL Global * * keymanutil is dual licensed under the MIT or GPL licenses as described below. * diff --git a/linux/ibus-keyman/src/kmpdetails.c b/linux/ibus-keyman/src/kmpdetails.c index ba6e26faea..5d98c3bf34 100644 --- a/linux/ibus-keyman/src/kmpdetails.c +++ b/linux/ibus-keyman/src/kmpdetails.c @@ -3,7 +3,7 @@ /* * Keyman Input Method for IBUS (The Input Bus) * - * Copyright (C) 2018 SIL International + * Copyright (C) 2018 SIL Global * * kmpdetails is dual licensed under the MIT or GPL licenses as described below. * diff --git a/linux/ibus-keyman/src/main.c b/linux/ibus-keyman/src/main.c index 477d744d9b..b7da28e97d 100644 --- a/linux/ibus-keyman/src/main.c +++ b/linux/ibus-keyman/src/main.c @@ -3,7 +3,7 @@ /* * Keyman Input Method for IBUS (The Input Bus) * - * Copyright (C) 2018 SIL International + * Copyright (C) 2018 SIL Global * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public diff --git a/linux/ibus-keyman/src/test/testdata/kmp.json b/linux/ibus-keyman/src/test/testdata/kmp.json index d2ee4fa9e1..4320826b2f 100644 --- a/linux/ibus-keyman/src/test/testdata/kmp.json +++ b/linux/ibus-keyman/src/test/testdata/kmp.json @@ -14,7 +14,7 @@ "description": "LIBTRALO" }, "copyright": { - "description": "\u00A9 2008-2018 SIL International" + "description": "\u00A9 2008-2018 SIL Global" }, "author": { "description": "support@keyman.com", diff --git a/linux/ibus-keyman/src/test/testdata/kmp1.json b/linux/ibus-keyman/src/test/testdata/kmp1.json index 5d9954c330..5ca7bd87d0 100644 --- a/linux/ibus-keyman/src/test/testdata/kmp1.json +++ b/linux/ibus-keyman/src/test/testdata/kmp1.json @@ -14,7 +14,7 @@ "description": "LIBTRALO" }, "copyright": { - "description": "\u00A9 2008-2018 SIL International" + "description": "\u00A9 2008-2018 SIL Global" }, "author": { "description": "support@keyman.com", diff --git a/linux/ibus-keyman/src/test/testdata/kmp2.json b/linux/ibus-keyman/src/test/testdata/kmp2.json index 5e889091f7..187f2dbbb6 100644 --- a/linux/ibus-keyman/src/test/testdata/kmp2.json +++ b/linux/ibus-keyman/src/test/testdata/kmp2.json @@ -14,7 +14,7 @@ "description": "LIBTRALO" }, "copyright": { - "description": "\u00A9 2008-2018 SIL International" + "description": "\u00A9 2008-2018 SIL Global" }, "author": { "description": "support@keyman.com", diff --git a/linux/ibus-keyman/tests/ibusimcontext.c b/linux/ibus-keyman/tests/ibusimcontext.c index 8cf389d749..c6d7af1e98 100644 --- a/linux/ibus-keyman/tests/ibusimcontext.c +++ b/linux/ibus-keyman/tests/ibusimcontext.c @@ -4,7 +4,7 @@ * Copyright (C) 2008-2013 Peng Huang * Copyright (C) 2015-2022 Takao Fujiwara * Copyright (C) 2008-2022 Red Hat, Inc. - * Copyright (C) 2021-2022 SIL International + * Copyright (C) 2021-2022 SIL Global * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public diff --git a/linux/ibus-keyman/tests/ibusimcontext.h b/linux/ibus-keyman/tests/ibusimcontext.h index f45b404a26..94e8687bde 100644 --- a/linux/ibus-keyman/tests/ibusimcontext.h +++ b/linux/ibus-keyman/tests/ibusimcontext.h @@ -3,7 +3,7 @@ /* ibus - The Input Bus * Copyright (C) 2008-2010 Peng Huang * Copyright (C) 2008-2010 Red Hat, Inc. - * Copyright (C) 2021-2022 SIL International + * Copyright (C) 2021-2022 SIL Global * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public diff --git a/linux/keyman-config/COPYING b/linux/keyman-config/COPYING index 5260aa6d9e..485c2c057c 100644 --- a/linux/keyman-config/COPYING +++ b/linux/keyman-config/COPYING @@ -1,4 +1,4 @@ -Copyright (c) 2018 SIL International +Copyright (c) 2018 SIL Global Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/linux/keyman-config/Makefile b/linux/keyman-config/Makefile index f057dcad0c..d36bf16a0f 100644 --- a/linux/keyman-config/Makefile +++ b/linux/keyman-config/Makefile @@ -48,7 +48,7 @@ MOFILES := $(POFILES:.po=/LC_MESSAGES/keyman-config.mo) update-template: version xgettext --package-name "keyman" --package-version "$(VERSION)" \ - --msgid-bugs-address "" --copyright-holder "SIL International" \ + --msgid-bugs-address "" --copyright-holder "SIL Global" \ --language=Python --directory=. --output-dir=locale --output=keyman-config.pot \ --add-comments=i18n: --sort-by-file --width=98 \ keyman_config/*.py km-* diff --git a/linux/keyman-config/buildtools/help2md b/linux/keyman-config/buildtools/help2md index 23a3f5b26b..a32685d1de 100755 --- a/linux/keyman-config/buildtools/help2md +++ b/linux/keyman-config/buildtools/help2md @@ -5,7 +5,7 @@ # Copyright (C) 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004, 2005, 2009, # 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2020, 2021 Free Software # Foundation, Inc. -# Copyright (C) 2021 SIL International +# Copyright (C) 2021 SIL Global # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -76,7 +76,7 @@ my $version_info = enc_user sprintf _(<<'EOT'), $this_program, $this_version; Copyright (C) 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004, 2005, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2020, 2021 Free Software Foundation, Inc. -Copyright (C) 2021 SIL International +Copyright (C) 2021 SIL Global This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. diff --git a/linux/keyman-config/keyman_config/keyman_option.py b/linux/keyman-config/keyman_config/keyman_option.py index 791d799cb8..b7f16195fd 100644 --- a/linux/keyman-config/keyman_config/keyman_option.py +++ b/linux/keyman-config/keyman_config/keyman_option.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 ''' - Keyman is copyright (C) SIL International. MIT License. + Keyman is copyright (C) SIL Global. MIT License. Implementation of the Keyman DConf options ''' diff --git a/linux/keyman-config/keyman_config/sentry_handling.py b/linux/keyman-config/keyman_config/sentry_handling.py index a16ba8016c..e7b3d40da3 100644 --- a/linux/keyman-config/keyman_config/sentry_handling.py +++ b/linux/keyman-config/keyman_config/sentry_handling.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 ''' -Keyman is copyright (C) SIL International. MIT License. +Keyman is copyright (C) SIL Global. MIT License. Implements the Sentry error handling ''' diff --git a/linux/keyman-config/locale/keyman-config.pot b/linux/keyman-config/locale/keyman-config.pot index 50b0d4d8ea..ed377151f7 100644 --- a/linux/keyman-config/locale/keyman-config.pot +++ b/linux/keyman-config/locale/keyman-config.pot @@ -1,5 +1,5 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR SIL International +# Copyright (C) YEAR SIL Global # This file is distributed under the same license as the keyman package. # FIRST AUTHOR , YEAR. # diff --git a/linux/scripts/debian.sh b/linux/scripts/debian.sh index 66cc7ba157..3a896c10b2 100755 --- a/linux/scripts/debian.sh +++ b/linux/scripts/debian.sh @@ -36,7 +36,7 @@ for proj in ${projects}; do EXTRA_ARGS="--distribution ${DIST} --force-distribution" fi # shellcheck disable=SC2086 - dch --newversion "${version}-${DEBREVISION-1}" ${EXTRA_ARGS} "Re-release to Debian" + dch --newversion "${version}-${DEBREVISION-1}" ${EXTRA_ARGS} "" debuild -d -S -sa -Zxz cd "${BASEDIR}" done diff --git a/linux/scripts/upload-to-debian.sh b/linux/scripts/upload-to-debian.sh index 85472a4ed1..ead2943983 100755 --- a/linux/scripts/upload-to-debian.sh +++ b/linux/scripts/upload-to-debian.sh @@ -92,14 +92,19 @@ function get_latest_stable_branch_name() { echo "${stable_branch##* }" } +# Push the changelog changes to GitHub and create a PR. Returns the PR# +# in the environment variable PR_NUMBER. function push_to_github_and_create_pr() { - local BRANCH=$1 - local BASE=$2 - local PR_TITLE=$3 - local PR_BODY=$4 + local BRANCH=$1 # `chore/linux/changelog` or `chore/linux/cherry-pick/changelog` + local BASE=$2 # stable branch, `beta` or `master` + local PR_TITLE=$3 # `Update debian changelog` + local PR_BODY=$4 # `@keymanapp-test-bot skip` if [[ -n "${PUSH}" ]]; then - ${NOOP} git push --force-with-lease origin "${BRANCH}" + # Push to origin. We force push to reset the branch the commit we just made. + # There shouldn't be any other commits on ${BRANCH} except the one we want to replace + # (if any). + ${NOOP} git push --force origin "${BRANCH}" PR_NUMBER=$(gh pr list --draft --search "${PR_TITLE}" --base "${BASE}" --json number --jq '.[].number') if [[ -n ${PR_NUMBER} ]]; then builder_echo "PR #${PR_NUMBER} already exists" diff --git a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m index aa2e914371..b938279197 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/KMInputMethodAppDelegate.m @@ -944,6 +944,7 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef } - (void)showOSK { + [self.oskWindow prepareToShowOsk]; [[self.oskWindow window] makeKeyAndOrderFront:nil]; [[self.oskWindow window] setLevel:NSStatusWindowLevel]; [[self.oskWindow window] setTitle:self.oskWindowTitle]; @@ -1242,13 +1243,32 @@ CGEventRef eventTapFunction(CGEventTapProxy proxy, CGEventType type, CGEventRef NSEventModifierFlags modifierFlags = 0; if (self.receivedKeyDownFromOsk) { + /** + * the event was generated from the OSK, so use the oskEventModifiers in effect at time of event generation + */ modifierFlags = self.oskEventModifiers; os_log_debug([KMLogs eventsLog], "--- use modifiers from OSK, oskEventModifiers: 0x%lX", (unsigned long)modifierFlags); self.oskEventModifiers = 0; - } else { + } else { + /** + * the event originated from the physical keyboard, use the modifiers on kCGEventFlagsChanged events + */ NSEventModifierFlags originalModifiers = self.currentModifiers; modifierFlags = [self.modifierMapping adjustModifiers:originalModifiers]; os_log_debug([KMLogs eventsLog], "--- use adjusted modifiers from current state: 0x%lX", (unsigned long)modifierFlags); + + /** + * If the OSK is open, then we also need to apply its modifiers, if any, to this event originating from the physical keyboard. + * If the OSK is closed, its modifiers will be zero. + */ + NSEventModifierFlags oskModifiers = [self.oskWindow getOskEventModifierFlags]; + if (oskModifiers != 0) { + os_log_debug([KMLogs eventsLog], "--- modifiers from OSK to apply to physical keyboard event: 0x%lX", (unsigned long)oskModifiers); + + // combine osk modifiers with adjusted modifiers + modifierFlags = oskModifiers | modifierFlags; + os_log_debug([KMLogs eventsLog], "--- combined modifiers to apply to physical keyboard event: 0x%lX", (unsigned long)modifierFlags); + } } return modifierFlags; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.h b/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.h index fd3a953a85..ecb2f2566e 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.h +++ b/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.h @@ -13,6 +13,8 @@ @property (nonatomic, weak) IBOutlet OSKView *oskView; +- (void)prepareToShowOsk; - (void)resetOSK; +- (NSEventModifierFlags)getOskEventModifierFlags; @end diff --git a/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.m b/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.m index 33d7eda406..b3824fa897 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/OnScreenKeyboard/OSKWindowController.m @@ -15,9 +15,9 @@ @interface OSKWindowController () @property (nonatomic, strong) NSButton *helpButton; @property (nonatomic, strong) NSTimer *modFlagsTimer; -@property (nonatomic, assign) BOOL xShiftState; -@property (nonatomic, assign) BOOL xAltState; -@property (nonatomic, assign) BOOL xCtrlState; +@property (nonatomic, assign) BOOL previousShiftState; +@property (nonatomic, assign) BOOL previousOptionState; +@property (nonatomic, assign) BOOL previousControlState; @end @implementation OSKWindowController @@ -58,7 +58,7 @@ [super windowDidLoad]; [self.oskView setKvk:[self.AppDelegate kvk]]; [self startTimerWithTimeInterval:0.1]; - // Implement this method to handle any initialization after your window controller's window has been loaded from its nib file. + // [self startTimerToTrackPhysicalModifierState:0.1]; } - (void)windowDidResize:(NSNotification *)notification { @@ -66,9 +66,16 @@ [self.oskView resizeOSKLayout]; } +- (void)prepareToShowOsk { + os_log_info([KMLogs oskLog], "OSKWindowController prepareToShowOsk"); +} + - (void)windowWillClose:(NSNotification *)notification { + os_log_debug([KMLogs oskLog], "OSKWindowController windowWillClose"); [KMSettingsRepository.shared writeShowOskOnActivate:NO]; - os_log_debug([KMLogs oskLog], "OSKWindowController windowWillClose, updating settings writeShowOsk to NO"); + + // whenever the OSK is closing clear all of its modifier keys + [self.oskView clearOskModifiers]; } - (void)helpAction:(id)sender { @@ -97,6 +104,16 @@ } } +/** + * returns current event flags representing the state of the modifiers on the OSK or zero if OSK is closed + */ +- (NSEventModifierFlags)getOskEventModifierFlags { + if (self.window.isVisible) { + return [self.oskView getOskEventModifierFlags]; + } else { + return 0; + } +} - (void)startTimerWithTimeInterval:(NSTimeInterval)interval { if (_modFlagsTimer == nil) { TimerTarget *timerTarget = [[TimerTarget alloc] init]; @@ -116,52 +133,73 @@ } } +/** + * Called repeatedly by timer to track the state of the physical modifier keys. + * Changes in state are reported to the OSKView, so it can make corresponding updates to the OSK's appearance + * and apply the state as necessary when keys on the OSK are clicked. + */ - (void)timerAction:(NSTimer *)timer { UInt32 modifiers = GetCurrentKeyModifiers(); + + [self trackPhysicalShiftKeyState: modifiers]; + [self trackPhysicalOptionKeyState: modifiers]; + [self trackPhysicalControlKeyState: modifiers]; +} + +- (void)trackPhysicalShiftKeyState:(UInt32) modifiers { if ((modifiers & shiftKey) == shiftKey) { - [self.oskView setShiftState:YES]; - self.xShiftState = YES; + [self.oskView setPhysicalShiftState:YES]; + self.previousShiftState = YES; } else { - [self.oskView setShiftState:NO]; - if (self.xShiftState) + [self.oskView setPhysicalShiftState:NO]; + if (self.previousShiftState) { [self.oskView setOskShiftState:NO]; - self.xShiftState = NO; + } + self.previousShiftState = NO; } - +} + +- (void)trackPhysicalOptionKeyState:(UInt32) modifiers { if ((modifiers & optionKey) == optionKey) { - [self.oskView setAltState:YES]; - self.xAltState = YES; + [self.oskView setPhysicalOptionState:YES]; + self.previousOptionState = YES; } else { - [self.oskView setAltState:NO]; - if (self.xAltState) - [self.oskView setOskAltState:NO]; - self.xAltState = NO; + [self.oskView setPhysicalOptionState:NO]; + if (self.previousOptionState) { + [self.oskView setOskOptionState:NO]; + } + self.previousOptionState = NO; } - +} + +- (void)trackPhysicalControlKeyState:(UInt32) modifiers { if ((modifiers & controlKey) == controlKey) { - [self.oskView setCtrlState:YES]; - self.xCtrlState = YES; + [self.oskView setPhysicalControlState:YES]; + self.previousControlState = YES; } else { - [self.oskView setCtrlState:NO]; - if (self.xCtrlState) - [self.oskView setOskCtrlState:NO]; - self.xCtrlState = NO; + [self.oskView setPhysicalControlState:NO]; + if (self.previousControlState) { + [self.oskView setOskControlState:NO]; + } + self.previousControlState = NO; } } - (BOOL)hasHelpDocumentation { NSString *kvkPath = [self AppDelegate].kvk.filePath; - if (!kvkPath) + if (!kvkPath) { return NO; + } NSString *packagePath = [kvkPath stringByDeletingLastPathComponent]; if (packagePath != nil) { NSString *welcomeFile = [packagePath stringByAppendingPathComponent:@"welcome.htm"]; - if ([[NSFileManager defaultManager] fileExistsAtPath:welcomeFile]) + if ([[NSFileManager defaultManager] fileExistsAtPath:welcomeFile]) { return YES; + } } return NO; diff --git a/mac/Keyman4MacIM/Keyman4MacIM/es.lproj/Localizable.strings b/mac/Keyman4MacIM/Keyman4MacIM/es.lproj/Localizable.strings index 7c8cdc73fc..ad6771f652 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/es.lproj/Localizable.strings +++ b/mac/Keyman4MacIM/Keyman4MacIM/es.lproj/Localizable.strings @@ -69,3 +69,6 @@ /* message displayed to alert user to need grant accessibility permission */ "privacy-alert-text" = "Para funcionar correctamente, Keyman requiere las siguientes características de accesibilidad:\nel acceso en Preferencias del Sistema, Seguridad y Privacidad.\nel reinicio del sistema."; + +/* Text of menu item in Input Menu when no Keyboards are configured -- include parentheses */ +"no-keyboard-configured-menu-placeholder" = "(Sin teclado configurado)"; diff --git a/mac/KeymanEngine4Mac/KeymanEngine4Mac/KME/OnScreenKeyboard/OSKView.h b/mac/KeymanEngine4Mac/KeymanEngine4Mac/KME/OnScreenKeyboard/OSKView.h index 26914858f9..19c3263baa 100644 --- a/mac/KeymanEngine4Mac/KeymanEngine4Mac/KME/OnScreenKeyboard/OSKView.h +++ b/mac/KeymanEngine4Mac/KeymanEngine4Mac/KME/OnScreenKeyboard/OSKView.h @@ -19,12 +19,14 @@ + (BOOL)isOskKeyDownEvent:(CGEventRef)event; + (NSEventModifierFlags)extractModifierFlagsFromOskEvent:(CGEventRef)event; - (void)handleKeyEvent:(NSEvent *)event; -- (void)setShiftState:(BOOL)shiftState; +- (void)setPhysicalShiftState:(BOOL)shiftState; - (void)setOskShiftState:(BOOL)oskShiftState; -- (void)setAltState:(BOOL)altState; -- (void)setOskAltState:(BOOL)oskAltState; -- (void)setCtrlState:(BOOL)ctrlState; -- (void)setOskCtrlState:(BOOL)oskCtrlState; +- (void)setPhysicalOptionState:(BOOL)altState; +- (void)setOskOptionState:(BOOL)oskAltState; +- (void)setPhysicalControlState:(BOOL)ctrlState; +- (void)setOskControlState:(BOOL)oskCtrlState; +- (void)clearOskModifiers; +- (NSEventModifierFlags)getOskEventModifierFlags; - (void)resetOSK; - (void)resizeOSKLayout; - (int64_t)createOskEventUserData; diff --git a/mac/KeymanEngine4Mac/KeymanEngine4Mac/KME/OnScreenKeyboard/OSKView.m b/mac/KeymanEngine4Mac/KeymanEngine4Mac/KME/OnScreenKeyboard/OSKView.m index 4a4cb98c77..e98fe60663 100644 --- a/mac/KeymanEngine4Mac/KeymanEngine4Mac/KME/OnScreenKeyboard/OSKView.m +++ b/mac/KeymanEngine4Mac/KeymanEngine4Mac/KME/OnScreenKeyboard/OSKView.m @@ -23,12 +23,12 @@ const int64_t OSK_EVENT_MODIFIER_MASK = 0x00000000FFFFFFFF; @interface OSKView() @property (nonatomic, strong) NSArray *oskLayout; @property (nonatomic, strong) NSArray *oskDefaultNKeys; -@property (nonatomic, assign) BOOL shiftState; +@property (nonatomic, assign) BOOL physicalShiftState; @property (nonatomic, assign) BOOL oskShiftState; -@property (nonatomic, assign) BOOL altState; -@property (nonatomic, assign) BOOL oskAltState; -@property (nonatomic, assign) BOOL ctrlState; -@property (nonatomic, assign) BOOL oskCtrlState; +@property (nonatomic, assign) BOOL physicalOptionState; +@property (nonatomic, assign) BOOL oskOptionState; +@property (nonatomic, assign) BOOL physicalControlState; +@property (nonatomic, assign) BOOL oskControlState; @end @implementation OSKView @@ -69,8 +69,8 @@ const int64_t OSK_EVENT_MODIFIER_MASK = 0x00000000FFFFFFFF; } - (NSString *)description { - NSString *format = @""; - NSString *str = [NSString stringWithFormat:format, _shiftState, _oskShiftState, _altState, _oskAltState, _ctrlState, _oskCtrlState]; + NSString *format = @""; + NSString *str = [NSString stringWithFormat:format, _physicalShiftState, _oskShiftState, _physicalOptionState, _oskOptionState, _physicalControlState, _oskControlState]; return str; } @@ -140,7 +140,7 @@ const int64_t OSK_EVENT_MODIFIER_MASK = 0x00000000FFFFFFFF; _oskLayout = nil; _oskDefaultNKeys = nil; - [self setKeyLabels:self.oskShiftState alt:self.oskAltState ctrl:self.oskCtrlState]; + [self updateKeyLabelsForCurrentLayer]; } - (void)initOSKKeys { @@ -166,7 +166,7 @@ const int64_t OSK_EVENT_MODIFIER_MASK = 0x00000000FFFFFFFF; [keyView setTarget:self]; if (self.oskShiftState && (keyView.key.keyCode == MVK_LEFT_SHIFT || keyView.key.keyCode == MVK_RIGHT_SHIFT)) [keyView setKeyPressed:YES]; - else if (self.oskAltState && (keyView.key.keyCode == MVK_LEFT_ALT || keyView.key.keyCode == MVK_RIGHT_ALT)) + else if (self.oskOptionState && (keyView.key.keyCode == MVK_LEFT_ALT || keyView.key.keyCode == MVK_RIGHT_ALT)) [keyView setKeyPressed:YES]; [self addSubview:keyView]; px += width; @@ -174,7 +174,7 @@ const int64_t OSK_EVENT_MODIFIER_MASK = 0x00000000FFFFFFFF; py += keyHeight; } - [self setKeyLabels:self.oskShiftState alt:self.oskAltState ctrl:self.oskCtrlState]; + [self updateKeyLabelsForCurrentLayer]; } - (NSArray *)oskLayout { @@ -427,9 +427,21 @@ const int64_t OSK_EVENT_MODIFIER_MASK = 0x00000000FFFFFFFF; return _oskDefaultNKeys; } +/** + * called when closing the OSK + * when the OSK is not visible then modifier state is determined solely by the physical keyboard + */ +- (void)clearOskModifiers { + _oskShiftState = NO; + _oskOptionState = NO; + _oskControlState = NO; + [self updateModifierKeysForState]; + [self updateKeyLabelsForCurrentLayer]; +} + - (void)resetOSK { [self setOskShiftState:NO]; - [self setOskAltState:NO]; + [self setOskOptionState:NO]; [self.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)]; [self initOSKKeys]; } @@ -467,14 +479,55 @@ const int64_t OSK_EVENT_MODIFIER_MASK = 0x00000000FFFFFFFF; [self setOskShiftState:!self.oskShiftState]; } else if (keyCode == MVK_LEFT_ALT || keyCode == MVK_RIGHT_ALT) { - [self setOskAltState:!self.oskAltState]; + [self setOskOptionState:!self.oskOptionState]; } else if (keyCode == MVK_LEFT_CTRL || keyCode == MVK_RIGHT_CTRL) { - [self setOskCtrlState:!self.oskCtrlState]; + [self setOskControlState:!self.oskControlState]; } } } +/** + * get the event modifier flags representing the current state of the osk modifiers + */ +- (NSEventModifierFlags)getOskEventModifierFlags { + return [self getEventModifierFlags:self.oskShiftState optionFlag:self.oskOptionState controlFlag:self.oskControlState]; +} + +/** + * get the event modifier flags that represent the combined state of the physical and osk modifiers + */ +- (NSEventModifierFlags) getCombinedEventModifierFlags { + BOOL shift = self.physicalShiftState | self.oskShiftState; + BOOL option = self.physicalOptionState | self.oskOptionState; + BOOL control = self.physicalControlState | self.oskControlState; + + return [self getEventModifierFlags:shift optionFlag:option controlFlag:control]; +} + +/** + * get the NSEventModifierFlags value that corresponds to the specified modifier states + */ +- (NSEventModifierFlags)getEventModifierFlags:(BOOL)shift optionFlag:(BOOL)option controlFlag:(BOOL)control { + NSEventModifierFlags modifierFlags = 0; + + if (shift) { + modifierFlags = modifierFlags | NSEventModifierFlagShift; + } + /** + * Both left and right option keys on the OSK cause oskOptionState to be true without distinction of left or right. + * However, the generated event is a right alt so that it will trigger the right alt rules in the Keyman keyboard. + */ + if (option) { + modifierFlags = modifierFlags | MK_RIGHT_ALT_MASK; + } + if (control) { + modifierFlags = modifierFlags | NSEventModifierFlagControl; + } + + return modifierFlags; +} + /** * Create the 64-bit value to pass in the kCGEventSourceUserData field of the generated CGEvent * The upper-most bit is set to distinguish it from the default value of zero, and the lower 32 bits @@ -488,22 +541,9 @@ const int64_t OSK_EVENT_MODIFIER_MASK = 0x00000000FFFFFFFF; - (int64_t) createOskEventUserData { // set bit to identify this user data as originating from the OSK int64_t oskEventData = OSK_EVENT_FLAG; - - if (self.oskShiftState || self.shiftState) { - oskEventData = oskEventData | kCGEventFlagMaskShift; - } - /** - * Both left and right alt keys on the OSK cause oskAltState to be true without distinction of left or right. - * However, the generated event is a right alt so that it will trigger the right alt rules in the Keyman keyboard. - */ - if (self.oskAltState || self.altState) { - oskEventData = oskEventData | MK_RIGHT_ALT_MASK; - } - if (self.oskCtrlState || self.oskCtrlState) { - oskEventData = oskEventData | kCGEventFlagMaskControl; - } - - return oskEventData; + NSEventModifierFlags eventModifierFlags = [self getCombinedEventModifierFlags]; + + return oskEventData | eventModifierFlags; } - (void)handleKeyEvent:(NSEvent *)event { @@ -523,34 +563,59 @@ const int64_t OSK_EVENT_MODIFIER_MASK = 0x00000000FFFFFFFF; [keyView setKeyPressed:NO]; } -- (void)setKeyLabels:(BOOL)shift alt:(BOOL)alt ctrl:(BOOL)ctrl { - os_log_debug([KMELogs oskLog], "OSKView setKeyLabels, shift: %d, alt: %d, ctrl: %d", shift, alt, ctrl); - [self resetKeyLabels]; - NSMutableArray *mKeys = [[self keyTags] mutableCopy]; - NSArray *nkeys = [self.kvk keys]; - if (nkeys == nil) - nkeys = self.oskDefaultNKeys; +/** + * get the Keyman modifier flags that represent the combined state of the physical and osk modifiers + */ +- (WORD) getKeymanModifierFlagsForCurrentLayer { + BOOL shift = self.physicalShiftState | self.oskShiftState; + BOOL option = self.physicalOptionState | self.oskOptionState; + BOOL control = self.physicalControlState | self.oskControlState; + return [self getKeymanModifierFlags:shift optionFlag:option controlFlag:control]; +} + +/** + * get Keyman modifier flags for the specified modifier states + */ +- (WORD)getKeymanModifierFlags:(BOOL)shift optionFlag:(BOOL)option controlFlag:(BOOL)control { WORD flags = 0; if (shift) { flags |= KVKS_SHIFT; } - if (alt) { + if (option) { if (self.kvk.containsRightAltKeys) { flags |= KVKS_RALT; } else if (self.kvk.containsAltKeys) { flags |= KVKS_ALT; } } - if (ctrl) { + if (control) { flags |= KVKS_RCTRL; } + return flags; +} + +/** + * Display the key labels that correspond to the current state of the modifier keys. + * The layer shown depends on both the physical modifier state and the osk modifier state. + */ +- (void)updateKeyLabelsForCurrentLayer { + os_log_debug([KMELogs oskLog], "OSKView updateKeyLabelsForState, phsyical modifiers [shift: %d, option: %d, control: %d]\nosk modifiers [shift: %d, option: %d, control: %d]", self.physicalShiftState, self.physicalOptionState, self.physicalControlState, self.oskShiftState, self.oskOptionState, self.oskControlState); + + [self resetKeyLabels]; + NSMutableArray *mKeys = [[self keyTags] mutableCopy]; + NSArray *nkeys = [self.kvk keys]; + if (nkeys == nil) + nkeys = self.oskDefaultNKeys; + + WORD keymanFlagsForCurrentLayer = [self getKeymanModifierFlagsForCurrentLayer]; + NSString *ansiFont = [self ansiFont]; NSString *unicodeFont = [self unicodeFont]; unsigned short keyCode; for (NKey *nkey in nkeys) { - if (nkey.modifierFlags == flags) { + if (nkey.modifierFlags == keymanFlagsForCurrentLayer) { keyCode = [self MacKeyCode:nkey.keyCode]; os_log_debug([KMELogs keyLog], "keyCode: %d, %x for key: %{public}@", keyCode, keyCode, nkey); if (keyCode < USHRT_MAX) { @@ -613,114 +678,104 @@ const int64_t OSK_EVENT_MODIFIER_MASK = 0x00000000FFFFFFFF; } } -- (void)setShiftState:(BOOL)shiftState { - if (_shiftState != shiftState) { - _shiftState = shiftState; - KeyView *shiftKeyL = (KeyView *)[self viewWithTag:MVK_LEFT_SHIFT|0x1000]; - KeyView *shiftKeyR = (KeyView *)[self viewWithTag:MVK_RIGHT_SHIFT|0x1000]; - if (shiftState) { - [self setKeyLabels:YES alt:self.oskAltState ctrl:self.oskCtrlState]; - [shiftKeyL setKeyPressed:YES]; - [shiftKeyR setKeyPressed:YES]; - } - else { - [self setKeyLabels:NO alt:self.oskAltState ctrl:self.oskCtrlState]; - [shiftKeyL setKeyPressed:NO]; - [shiftKeyR setKeyPressed:NO]; - } +- (void)setPhysicalShiftState:(BOOL)state { + if (_physicalShiftState != state) { + _physicalShiftState = state; + + /** + * When setting the physical shift state, clear the OSK shift state + * The state of the physical key overrides the modifier key clicked in the OSK. + */ + _oskShiftState = NO; + + os_log_debug([KMELogs oskLog], "hardware shift key released"); + [self updateKeyLabelsForCurrentLayer]; + [self updateShiftKeysForState]; } } -- (void)setOskShiftState:(BOOL)oskShiftState { - if (_oskShiftState != oskShiftState && !self.shiftState) { - _oskShiftState = oskShiftState; - KeyView *shiftKeyL = (KeyView *)[self viewWithTag:MVK_LEFT_SHIFT|0x1000]; - KeyView *shiftKeyR = (KeyView *)[self viewWithTag:MVK_RIGHT_SHIFT|0x1000]; - if (oskShiftState) { - [self setKeyLabels:YES alt:self.oskAltState ctrl:self.oskCtrlState]; - [shiftKeyL setKeyPressed:YES]; - [shiftKeyR setKeyPressed:YES]; - } - else if (!self.shiftState) { - [self setKeyLabels:NO alt:self.oskAltState ctrl:self.oskCtrlState]; - [shiftKeyL setKeyPressed:NO]; - [shiftKeyR setKeyPressed:NO]; - } +- (void)setOskShiftState:(BOOL)state { + if (_oskShiftState != state && !self.physicalShiftState) { + _oskShiftState = state; + [self updateKeyLabelsForCurrentLayer]; + [self updateShiftKeysForState]; } } -- (void)setAltState:(BOOL)altState { - if (_altState != altState) { - _altState = altState; - KeyView *altKeyL = (KeyView *)[self viewWithTag:MVK_LEFT_ALT|0x1000]; - KeyView *altKeyR = (KeyView *)[self viewWithTag:MVK_RIGHT_ALT|0x1000]; - if (altState) { - [self setKeyLabels:self.oskShiftState alt:YES ctrl:self.oskCtrlState]; - [altKeyL setKeyPressed:YES]; - [altKeyR setKeyPressed:YES]; - } - else { - [self setKeyLabels:self.oskShiftState alt:NO ctrl:self.oskCtrlState]; - [altKeyL setKeyPressed:NO]; - [altKeyR setKeyPressed:NO]; - } +- (void)setPhysicalOptionState:(BOOL)state { + if (_physicalOptionState != state) { + _physicalOptionState = state; + + /** + * When setting the physical option state, clear the OSK option state + * The state of the physical key overrides the modifier key clicked in the OSK. + */ + _oskOptionState = NO; + [self updateKeyLabelsForCurrentLayer]; + [self updateOptionKeysForState]; } } -- (void)setOskAltState:(BOOL)oskAltState { - if (_oskAltState != oskAltState && !self.altState) { - _oskAltState = oskAltState; - KeyView *altKeyL = (KeyView *)[self viewWithTag:MVK_LEFT_ALT|0x1000]; - KeyView *altKeyR = (KeyView *)[self viewWithTag:MVK_RIGHT_ALT|0x1000]; - if (oskAltState) { - [self setKeyLabels:self.oskShiftState alt:YES ctrl:self.oskCtrlState]; - [altKeyL setKeyPressed:YES]; - [altKeyR setKeyPressed:YES]; - } - else if (!self.altState) { - [self setKeyLabels:self.oskShiftState alt:NO ctrl:self.oskCtrlState]; - [altKeyL setKeyPressed:NO]; - [altKeyR setKeyPressed:NO]; - } +- (void)setOskOptionState:(BOOL)state { + if (_oskOptionState != state && !self.physicalOptionState) { + _oskOptionState = state; + [self updateKeyLabelsForCurrentLayer]; + [self updateOptionKeysForState]; } } -- (void)setCtrlState:(BOOL)ctrlState { - if (_ctrlState != ctrlState) { - _ctrlState = ctrlState; - KeyView *ctrlKeyL = (KeyView *)[self viewWithTag:MVK_LEFT_CTRL|0x1000]; - KeyView *ctrlKeyR = (KeyView *)[self viewWithTag:MVK_RIGHT_CTRL|0x1000]; - if (ctrlState) { - [self setKeyLabels:self.oskShiftState alt:self.oskAltState ctrl:YES]; - [ctrlKeyL setKeyPressed:YES]; - [ctrlKeyR setKeyPressed:YES]; - } - else { - [self setKeyLabels:self.oskShiftState alt:self.oskAltState ctrl:NO]; - [ctrlKeyL setKeyPressed:NO]; - [ctrlKeyR setKeyPressed:NO]; - } +- (void)setPhysicalControlState:(BOOL)state { + if (_physicalControlState != state) { + _physicalControlState = state; + + /** + * When setting the physical control state, clear the OSK control state + * The state of the physical key overrides the modifier key clicked in the OSK. + */ + _oskControlState = NO; + [self updateKeyLabelsForCurrentLayer]; + [self updateControlKeysForState]; } } -- (void)setOskCtrlState:(BOOL)oskCtrlState { - if (_oskCtrlState != oskCtrlState && !self.ctrlState) { - _oskCtrlState = oskCtrlState; - KeyView *ctrlKeyL = (KeyView *)[self viewWithTag:MVK_LEFT_CTRL|0x1000]; - KeyView *ctrlKeyR = (KeyView *)[self viewWithTag:MVK_RIGHT_CTRL|0x1000]; - if (oskCtrlState) { - [self setKeyLabels:self.oskShiftState alt:self.oskAltState ctrl:YES]; - [ctrlKeyL setKeyPressed:YES]; - [ctrlKeyR setKeyPressed:YES]; - } - else if (!self.ctrlState) { - [self setKeyLabels:self.oskShiftState alt:self.oskAltState ctrl:NO]; - [ctrlKeyL setKeyPressed:NO]; - [ctrlKeyR setKeyPressed:NO]; - } +- (void)setOskControlState:(BOOL)state { + if (_oskControlState != state && !self.physicalControlState) { + _oskControlState = state; + [self updateKeyLabelsForCurrentLayer]; + [self updateControlKeysForState]; } } +- (void)updateModifierKeysForState { + [self updateShiftKeysForState]; + [self updateOptionKeysForState]; + [self updateControlKeysForState]; +} + +- (void)updateShiftKeysForState { + BOOL pressed = self.oskShiftState || self.physicalShiftState; + KeyView *leftShiftKey = (KeyView *)[self viewWithTag:MVK_LEFT_SHIFT|0x1000]; + KeyView *rightShiftKey = (KeyView *)[self viewWithTag:MVK_RIGHT_SHIFT|0x1000]; + [leftShiftKey setKeyPressed:pressed]; + [rightShiftKey setKeyPressed:pressed]; +} + +- (void)updateOptionKeysForState{ + BOOL pressed = self.oskOptionState || self.physicalOptionState; + KeyView *leftOptionKey = (KeyView *)[self viewWithTag:MVK_LEFT_ALT|0x1000]; + KeyView *rightOptionKey = (KeyView *)[self viewWithTag:MVK_RIGHT_ALT|0x1000]; + [leftOptionKey setKeyPressed:pressed]; + [rightOptionKey setKeyPressed:pressed]; +} + +- (void)updateControlKeysForState { + BOOL pressed = self.oskControlState || self.physicalControlState; + KeyView *leftControlKey = (KeyView *)[self viewWithTag:MVK_LEFT_CTRL|0x1000]; + KeyView *rightControlKey = (KeyView *)[self viewWithTag:MVK_RIGHT_CTRL|0x1000]; + [leftControlKey setKeyPressed:pressed]; + [rightControlKey setKeyPressed:pressed]; +} + - (NSString *)ansiFont { return [self.kvk ansiFont].name; } diff --git a/mac/docs/help/start/install-keyboard.md b/mac/docs/help/start/install-keyboard.md index c8f2aa025a..bf9310c8bb 100644 --- a/mac/docs/help/start/install-keyboard.md +++ b/mac/docs/help/start/install-keyboard.md @@ -8,7 +8,9 @@ You must have a Keyman keyboard installed to type with Keyman for macOS in your Here's how to download and install a Keyman keyboard within Keyman for macOS: -[![](../mac_images/video.png) Watch Video](https://youtu.be/VirHygx8iqg) for installing the Khmer Angkor keyboard for Keyman for macOS. +![](../mac_images/video.png) Watch this video for installing the Khmer Angkor keyboard for Keyman for macOS (0:51) + +:Tutorial: Install Khmer Angkor Keyboard on Keyman for macOS 1. Choose Keyman from the Input menu in the menu bar. 2. Open Keyman Configuration, from the Configuration link under Keyman in the Input menu. diff --git a/mac/docs/help/start/install-keyman.md b/mac/docs/help/start/install-keyman.md index a3bbdb0733..b8aaa3a19d 100644 --- a/mac/docs/help/start/install-keyman.md +++ b/mac/docs/help/start/install-keyman.md @@ -5,7 +5,9 @@ title: Install Keyman for macOS *Keyman for macOS* is available for [download](https://keyman.com/mac/download) as a standard Apple Disk Image (.dmg) file. It should be installed according to the following instructions. -[![Play](../mac_images/video.png) Watch Video](https://youtu.be/7Ax71QeUNfo) +![Play](../mac_images/video.png) Watch Video (0:33) + +:Tutorial: Install Keyman for macOS 1. Double click the DMG file to open it in Finder. 2. Double click the **Install Keyman** icon to open the Keyman installer. diff --git a/oem/firstvoices/windows/src/xml/strings.xml b/oem/firstvoices/windows/src/xml/strings.xml index fbd099c8c7..692a582dd7 100644 --- a/oem/firstvoices/windows/src/xml/strings.xml +++ b/oem/firstvoices/windows/src/xml/strings.xml @@ -388,6 +388,11 @@ Show welcome screen + + + + Automatically download updates in the background, for installation later + diff --git a/resources/build/builder.md b/resources/build/builder.md index b1accd5c8a..2c6162dfc0 100644 --- a/resources/build/builder.md +++ b/resources/build/builder.md @@ -86,13 +86,12 @@ This somewhat unwieldy incantation handles all our build environments. The intent is to get a good solid consistent path for the script so that we can safely include the build script, no matter what `pwd` is when the script is run. - The only modification permissible in this block is the `` text which will be a series of `../` paths taking us to the repository root from the location of the script itself. It is essential to make the include relative to the repo root, even for scripts -under the resources/ folder. Doing this gives us significant performance +under the `resources/` folder. Doing this gives us significant performance benefits. Inclusion of other scripts should be kept outside this standard build script @@ -1117,4 +1116,4 @@ Note: it is recommended that you use `$(builder_term text)` instead of [`builder_echo`]: #builderecho-function [`builder_die`]: #builderdie-function [`builder_echo_debug`]: #builderechodebug-function -[`builder_is_debug_build`]: #builderisdebugbuild-function \ No newline at end of file +[`builder_is_debug_build`]: #builderisdebugbuild-function diff --git a/resources/build/increment-version.sh b/resources/build/increment-version.sh index 1a577b480f..da459776eb 100755 --- a/resources/build/increment-version.sh +++ b/resources/build/increment-version.sh @@ -104,9 +104,9 @@ echo "increment-version.sh: running resources/build/version" pushd "$KEYMAN_ROOT" ABORT=0 if [[ -z "$fromversion" ]]; then - node resources/build/version/build/src/index.js history version -t "$GITHUB_TOKEN" -b "$base" $HISTORY_FORCE || ABORT=$? + node resources/build/version/build/src/index.js history version -t "$GITHUB_TOKEN" -b "$base" $HISTORY_FORCE --github-pr || ABORT=$? else - node resources/build/version/build/src/index.js history version -t "$GITHUB_TOKEN" -b "$base" $HISTORY_FORCE --from "$fromversion" --to "$toversion" || ABORT=$? + node resources/build/version/build/src/index.js history version -t "$GITHUB_TOKEN" -b "$base" $HISTORY_FORCE --github-pr --from "$fromversion" --to "$toversion" || ABORT=$? fi if [[ $ABORT = 50 ]]; then @@ -179,7 +179,7 @@ if [ "$action" == "commit" ]; then # In order to avoid potential git conflicts, we run the history collater # again on the master HISTORY.md. Note that the script always exits 1 to # indicate it hasn't updated VERSION.md. We could tweak that in the future. - node resources/build/version/lib/index.js history --no-write-github-comment -t "$GITHUB_TOKEN" -b "$base" || true + node resources/build/version/lib/index.js history --no-write-github-comment --github-pr -t "$GITHUB_TOKEN" -b "$base" || true # If HISTORY.md has been updated, then we want to create a branch and push # it for review diff --git a/resources/build/minimum-versions.inc.sh b/resources/build/minimum-versions.inc.sh index 198495f0cc..41a7c3690f 100644 --- a/resources/build/minimum-versions.inc.sh +++ b/resources/build/minimum-versions.inc.sh @@ -31,7 +31,8 @@ KEYMAN_MIN_VERSION_EMSCRIPTEN=3.1.58 # Use KEYMAN_USE_EMSDK to automati KEYMAN_MIN_VERSION_VISUAL_STUDIO=2019 KEYMAN_MIN_VERSION_MESON=1.0.0 -KEYMAN_VERSION_ICU=73.1 # See /core/subprojects/icu-minimal.wrap +KEYMAN_VERSION_GRADLE=7.6.4 # See /android/KMEA/gradle/wrapper/gradle-wrapper.properties +KEYMAN_VERSION_ICU=73.1 # See /core/subprojects/icu-minimal.wrap # Language and runtime versions KEYMAN_VERSION_JAVA=11 # We're using Java/OpenJDK 11 diff --git a/resources/build/version/src/fixupHistory.ts b/resources/build/version/src/fixupHistory.ts index 2183b11f70..a8ceda3c50 100644 --- a/resources/build/version/src/fixupHistory.ts +++ b/resources/build/version/src/fixupHistory.ts @@ -184,7 +184,7 @@ export const sendCommentToPullRequestAndRelatedIssues = async ( */ export const fixupHistory = async ( - octokit: GitHub, base: string, force: boolean, writeGithubComment: boolean, from?: string, to?: string + octokit: GitHub, base: string, force: boolean, writeGithubComment: boolean, useGitHubPRInfo: boolean, from?: string, to?: string ): Promise => { // @@ -194,7 +194,7 @@ export const fixupHistory = async ( let pulls: PRInformation[] = []; try { - pulls = await reportHistory(octokit, base, force, false, from, to); + pulls = await reportHistory(octokit, base, force, useGitHubPRInfo, from, to); } catch(e) { logWarning(String(e)); return -1; diff --git a/resources/build/version/src/index.ts b/resources/build/version/src/index.ts index 16f1583e39..ea0b12e7e7 100644 --- a/resources/build/version/src/index.ts +++ b/resources/build/version/src/index.ts @@ -108,7 +108,7 @@ const main = async (): Promise => { if(argv._.includes('history')) { logInfo(`# Validating history for ${version}`); - changeCount = await fixupHistory(octokit, argv.base, argv.force, argv['write-github-comment'], argv.from, argv.to); + changeCount = await fixupHistory(octokit, argv.base, argv.force, argv['write-github-comment'], argv['github-pr'], argv.from, argv.to); logInfo(`# ${changeCount} change(s) found for ${version}\n`); } diff --git a/resources/builder.inc.sh b/resources/builder.inc.sh index f72c36ca72..6e06313935 100755 --- a/resources/builder.inc.sh +++ b/resources/builder.inc.sh @@ -584,7 +584,7 @@ function builder_run_action() { local action=$1 shift if builder_start_action $action; then - ("$@") + "$@" builder_finish_action success $action fi return 0 @@ -1407,6 +1407,10 @@ _builder_parse_expanded_parameters() { # internal reporting function, ignores all other parameters _builder_report_dependencies ;; + --builder-completion-describe) + _builder_completion_describe + exit 0 + ;; --offline) _builder_offline=--offline ;; @@ -1502,6 +1506,28 @@ _builder_pad() { printf $fmt "$text1" "$text2" } +_builder_completion_describe() { + printf '%s ' "${_builder_actions[@]}" + echo -n "; " + printf '%s ' "${_builder_targets[@]}" + echo -n "; " + # Remove all '+' suffixes from options; they're a config on the option, not part + # of the actual option text itself. + local _builder_opts=() + for e in "${!_builder_params[@]}"; do + if [[ $e =~ ^-- ]]; then + _builder_opts+=(${e%+*}) + fi + done + + # Add default options + _builder_opts+=( --verbose --debug --color --no-color --offline --help ) + if builder_has_dependencies; then + _builder_opts+=( --deps --no-deps --force-deps ) + fi + printf '%s ' "${_builder_opts[@]}" +} + builder_display_usage() { local e program description diff --git a/resources/builder_completion.sh b/resources/builder_completion.sh new file mode 100755 index 0000000000..9add1130b0 --- /dev/null +++ b/resources/builder_completion.sh @@ -0,0 +1,129 @@ +# As this script must be sourced by shells to be useful, no practical #shebang is possible. + +# Note: must 'source' this file again in the terminal after any edits! +_comp_builder() { + # Does the file actually exist? If not, abort. + local CMD_PATH=`readlink -f "$1"` + if [ -z "${CMD_PATH}" ]; then + exit 0 + fi + + # Detect - is this a keyman repo? If not, exit early; we don't expect repo-external compatibility. + local CMD_DIR="$(dirname "${CMD_PATH}")" + cd "${CMD_DIR}" + # Works correctly even if the directory has no backing repository. + git remote get-url origin | grep -q 'github.com/keymanapp' || exit 0 + + # Some of the configuration differs when ZSH is the OS's primary terminal; this flag + # may be used to tailor this function accordingly. + local IS_ZSH=false + if [ "${SHELL##*/}" = "zsh" ]; then + IS_ZSH=true + fi + + # Warning - zsh uses -A, not -a, for array-read operations! + # This is 'sourced' by the shell profile script, so macOS zsh terminal + # usage must be aware of this and adapt. + local READ_ARRAY="-a" + if [ $IS_ZSH = true ]; then + READ_ARRAY="-A" + fi + + # Is it actually a builder-script? If not, abort. If so, it defines + # a special option used to provide us with completion-target data. + local builder_params=`${CMD_PATH} --builder-completion-describe` || exit 0 + + local BASE_NAME=$(basename "$CMD_PATH") + local BUILDER_ARG_STR="${COMP_LINE##*${BASE_NAME} }" + + local builder_args + # Does not actually preserve an empty token at the end. + read -r $READ_ARRAY builder_args <<< "${BUILDER_ARG_STR}" + + # Determine the current token (given the caret position) and + # all existing, already-completed actions, targets, and options + # for the script before the current token. + local current_token= + # If the caret is adjacent to non-whitespace - thus is editing + # a builder argument... + if [ ! -z "${COMP_WORDS[COMP_CWORD]}" ]; then + # Then note the last token as the token being edited... + current_token="${builder_args[-1]}" + # ... and thus not pre-completed. + unset builder_args[-1] + fi + + # Parse the builder-description for completion target data. + local action_str target_str option_str + IFS=";" read -r action_str target_str option_str <<< "$builder_params" + local actions targets options + IFS=" " read -r $READ_ARRAY actions <<< "$action_str" + IFS=" " read -r $READ_ARRAY targets <<< "$target_str" + IFS=" " read -r $READ_ARRAY options <<< "$option_str" + + local action target + if [[ $current_token =~ : ]]; then + IFS=: read -r action target <<< "$current_token" + target=:$target + else + action="$current_token" + target= + fi + + local all=() + # If there is no $action component, it's a standalone target. + + if [[ -z "$action" ]] && [[ -z "$target" ]]; then + all+="${actions[@]}" + all+=" ${targets[@]}" + all+=" ${options[@]}" + + COMPREPLY=( $(compgen -W "${all[@]}" -- "${current_token}") ) + elif [[ -z "$action" ]]; then + # It's an unpaired target. + all="${targets[@]}" + COMPREPLY=( $(compgen -W "${all[@]}" -- "${current_token}") ) + + if [ $IS_ZSH == false ]; then + # bash doesn't handle completion with colons well; we should remove the + # colon prefix from each entry. ZSH actually doesn't have this issue. + local i + for (( i=0; i<${#COMPREPLY[@]}; i++ )); do + COMPREPLY[$i]="${COMPREPLY[$i]##*:}" + done + fi + elif [[ -z "$target" ]]; then + # It's an untargeted action or an option. + all+="${actions[@]}" + all+=" ${options[@]}" + + COMPREPLY=( $(compgen -W "${all[@]}" -- "${current_token}") ) + else + # Ah, an action-target pair. We have an existing action, so we only need to + # complete the target part. + + # First, build up the list of legal paired tokens. + local actiontargets=() + for e in "${targets[@]}"; do + actiontargets+=("${action}${e}") + done + + # Now, complete from that. + all+="${actiontargets[@]}" + COMPREPLY=( $(compgen -W "${all[@]}" -- "${current_token}") ) + + if [ $IS_ZSH == false ]; then + # bash doesn't handle completion with colons well; we should remove the + # colon prefix from each entry. ZSH actually doesn't have this issue. + local i + for (( i=0; i<${#COMPREPLY[@]}; i++ )); do + COMPREPLY[$i]="${COMPREPLY[$i]##*:}" + done + fi + fi +} + +# When this script is sourced by .bashrc or similar, this provides autocompletion for +# builder scripts named build.sh and test.sh. +complete -o bashdefault -F _comp_builder build.sh +complete -o bashdefault -F _comp_builder test.sh \ No newline at end of file diff --git a/resources/devbox/macos/keyman.macos.env.sh b/resources/devbox/macos/keyman.macos.env.sh index 47895d575e..5250856f9c 100755 --- a/resources/devbox/macos/keyman.macos.env.sh +++ b/resources/devbox/macos/keyman.macos.env.sh @@ -5,7 +5,6 @@ export ANT_HOME=/usr/local/opt/ant export MAVEN_HOME=/usr/local/opt/maven export GRADLE_HOME=/usr/local/opt/gradle export ANDROID_HOME=~/.android -export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk" export PATH=$ANT_HOME/bin:$PATH export PATH=$MAVEN_HOME/bin:$PATH @@ -13,10 +12,16 @@ export PATH=$GRADLE_HOME/bin:$PATH export PATH=$ANDROID_HOME/tools:$PATH export PATH=$ANDROID_HOME/tools/bin:$PATH export PATH=$ANDROID_HOME/platform-tools:$PATH +export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$PATH export PATH=$ANDROID_HOME/build-tools/30.0.3:$PATH -export PATH="/usr/local/opt/openjdk@8/bin:$PATH" + +if [ -z "$HOMEBREW_PREFIX" ]; then + HOMEBREW_PREFIX=`brew --prefix` +fi + export PATH="$HOMEBREW_PREFIX/opt/coreutils/libexec/gnubin:$PATH" -export JAVA_HOME=/usr/local/opt/openjdk@8 +export PATH="$HOMEBREW_PREFIX/opt/java11/bin:$PATH" +export JAVA_HOME="$HOMEBREW_PREFIX/opt/java11" # Python 2.7 eval "$(pyenv init --path)" diff --git a/resources/docker-images/README.md b/resources/docker-images/README.md new file mode 100644 index 0000000000..c36503ad6a --- /dev/null +++ b/resources/docker-images/README.md @@ -0,0 +1,65 @@ +# Container + +Docker containers that can be used to build Keyman on the respective +platforms. They contain everything that a CI build agent needs to +build for the platform. + +## Prerequisites + +You'll need Docker Buildx installed to successfully be able to build the +container images. This is most easily achieved by installing the [official +Docker version](https://docs.docker.com/engine/install/ubuntu/). + +Currently it is not possible to use Podman instead of Docker due to a number +of bugs and incompatibilities in the Podman implementation. + +## Building the images + +To build the docker images: + +```shell +resources/docker-images/build.sh +``` + +By default this will create 64-bit images for building +Keyman Core, Keyman for Android, Keyman for Linux and +Keyman for Web. These images are based on the Ubuntu 24.04 +with Node 20 and Emscripten 3.1.58 (for the exact versions, +see [`minimum-versions.inc.sh`](../build/minimum-versions.inc.sh)) +and are named e.g. `keyman-core-ci:default`. + +The versions can be changed, e.g. + +```shell +resources/docker-images/build.sh --ubuntu-version jammy --node 20 +``` + +This will create an image named e.g. `keyman-core-ci:jammy-node20`. + +Once the image is built, it may be used to build parts of Keyman. + +## Building locally + +It is possible to build locally with these images: + +```shell +# build 'Keyman Core' in docker +resources/docker-images/run.sh core -- core/build.sh --debug build +``` + +Note: For Core and Linux we put the generated binaries in a +container specific directory because they are platform dependent. + +If you build both with Docker and directly with the build scripts, it is +advisable to run a `git clean -dxf` before switching between the two. The +reason is that the Docker images use a different user, so that paths +will be different. + +## Running tests locally + +To run the tests locally, use the `run.sh` script: + +```shell +# Run common/web tests +resources/docker-images/run.sh web -- common/web/build.sh --debug test +``` diff --git a/resources/docker-images/android/Dockerfile b/resources/docker-images/android/Dockerfile new file mode 100644 index 0000000000..91cd095a69 --- /dev/null +++ b/resources/docker-images/android/Dockerfile @@ -0,0 +1,68 @@ +# Keyman is copyright (C) SIL Global. MIT License. + +ARG BASE_VERSION=default +FROM keymanapp/keyman-base-ci:${BASE_VERSION} +LABEL org.opencontainers.image.authors="SIL Global." +LABEL org.opencontainers.image.url="https://github.com/keymanapp/keyman.git" +LABEL org.opencontainers.image.title="Keyman Android Build Image" + +# Keyman for Android +SHELL ["/bin/bash", "-c"] + +# Starting with Ubuntu 24.04 sdkmanager is no longer available, instead +# a version dependent package allows to install the cmdline tools +ARG JAVA_VERSION=11 +RUN </dev/null) + echo "OS_VER=${OS_VER}" + if (( ${OS_VER%%.*} > 22 )); then + PKG_SDKMANAGER=google-android-cmdline-tools-13.0-installer + DIR_SDK=/usr/lib/android-sdk + else + PKG_SDKMANAGER=sdkmanager + DIR_SDK=/opt/android-sdk + fi + apt-get -q -y install gradle maven pandoc $PKG_SDKMANAGER jq openjdk-${JAVA_VERSION}-jdk + sdkmanager platform-tools + yes | sdkmanager --licenses + chown -R build:build $DIR_SDK + echo "export ANDROID_HOME=$DIR_SDK" >> /usr/bin/bashwrapper + echo "export JAVA_HOME_${JAVA_VERSION}=/usr/lib/jvm/java-${JAVA_VERSION}-openjdk-amd64" >> /usr/bin/bashwrapper +EOF + +# Finish bashwrapper script and adjust permissions +RUN <> /usr/bin/bashwrapper + +if [[ "\$@" =~ test ]] && [ -f /usr/bin/run-tests.sh ]; then + /usr/bin/run-tests.sh "\${@:-bash}" +else + "\${@:-bash}" +fi +EOF + +# now, switch to build user +USER build + +VOLUME /home/build/build +WORKDIR /home/build/build + +# Pre-install gradle. This will put files in ~/.gradle which will speed up builds. +# Note it would be safer to copy these files directly from our repo rather than +# getting it over the Internet, but Docker doesn't allow us to copy files +# from outside the current directory when building the image. +RUN mkdir -p $HOME/tmp/gradle/wrapper && \ + # KMEA uses gradle-7.6.4-bin + curl --location --output $HOME/tmp/gradle/wrapper/gradle-wrapper.jar https://raw.githubusercontent.com/keymanapp/keyman/master/android/KMEA/gradle/wrapper/gradle-wrapper.jar && \ + curl --location --output $HOME/tmp/gradle/wrapper/gradle-wrapper.properties https://raw.githubusercontent.com/keymanapp/keyman/master/android/KMEA/gradle/wrapper/gradle-wrapper.properties && \ + curl --location --output $HOME/tmp/gradlew https://raw.githubusercontent.com/keymanapp/keyman/master/android/KMEA/gradlew && \ + chmod +x $HOME/tmp/gradlew && \ + $HOME/tmp/gradlew --quiet && \ + # Some projects use gradle-7.6.4-all, so we pre-install that as well + curl --location --output $HOME/tmp/gradle/wrapper/gradle-wrapper.jar https://raw.githubusercontent.com/keymanapp/keyman/master/android/Samples/KMSample1/gradle/wrapper/gradle-wrapper.jar && \ + curl --location --output $HOME/tmp/gradle/wrapper/gradle-wrapper.properties https://raw.githubusercontent.com/keymanapp/keyman/master/android/Samples/KMSample1/gradle/wrapper/gradle-wrapper.properties && \ + curl --location --output $HOME/tmp/gradlew https://raw.githubusercontent.com/keymanapp/keyman/master/android/Samples/KMSample1/gradlew && \ + chmod +x $HOME/tmp/gradlew && \ + $HOME/tmp/gradlew --quiet && \ + rm -rf $HOME/tmp + +ENTRYPOINT [ "/usr/bin/bashwrapper" ] diff --git a/resources/docker-images/base/Dockerfile b/resources/docker-images/base/Dockerfile new file mode 100644 index 0000000000..87516b3f96 --- /dev/null +++ b/resources/docker-images/base/Dockerfile @@ -0,0 +1,59 @@ +# Keyman is copyright (C) SIL Global. MIT License. + +ARG UBUNTU_VERSION=latest +FROM ubuntu:${UBUNTU_VERSION} + +LABEL org.opencontainers.image.authors="SIL Global." +LABEL org.opencontainers.image.url="https://github.com/keymanapp/keyman.git" +LABEL org.opencontainers.image.title="Keyman Build Base Image" + +# We will switch to a build user after some installation +USER root +ENV HOME=/home/build +RUN grep ubuntu /etc/passwd && userdel ubuntu || true && \ + rm -rf /home/ubuntu && \ + useradd -c "Build user" --uid 1000 --home-dir $HOME --create-home --shell /usr/bin/bashwrapper build + +ENV DEBIAN_FRONTEND=noninteractive +ENV DEBIAN_PRIORITY=critical +ENV DEBCONF_NOWARNINGS=yes + +# Update to the latest +RUN apt-get -q -y update && \ + apt-get -q -y install ca-certificates curl gnupg meson software-properties-common sudo && \ + add-apt-repository ppa:keymanapp/keyman && \ + add-apt-repository ppa:keymanapp/keyman-alpha +RUN apt-get -q -y update && \ + apt-get -q -y upgrade + +# Allow build user to use `sudo` +RUN echo "build ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + +RUN < /usr/bin/bashwrapper +#!/bin/bash +export KEYMAN_USE_NVM=1 +export DOCKER_RUNNING=true +EOF + +# Install NVM +RUN NVM_RELEASE=$(curl -s https://api.github.com/repos/nvm-sh/nvm/releases/latest | grep tag_name | cut -d : -f 2 | cut -d '"' -f 2) && \ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_RELEASE}/install.sh | bash +RUN <> /usr/bin/bashwrapper +PATH=/home/build/.keyman/node:\$PATH +export NVM_DIR="$HOME/.nvm" +. /home/build/.nvm/nvm.sh +EOF + +RUN chmod +x /usr/bin/bashwrapper && \ + chown -R build:build $HOME + +USER build +# Pre-install node +ARG REQUIRED_NODE_VERSION=unset +RUN echo "HOME=\${HOME}; REQUIRED_NODE_VERSION=${REQUIRED_NODE_VERSION}" && \ + export NVM_DIR="/home/build/.nvm" && \ + . /home/build/.nvm/nvm.sh && \ + nvm install "${REQUIRED_NODE_VERSION}" && \ + nvm use "${REQUIRED_NODE_VERSION}" + +USER root diff --git a/resources/docker-images/build.sh b/resources/docker-images/build.sh new file mode 100755 index 0000000000..8100459d47 --- /dev/null +++ b/resources/docker-images/build.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../resources/build/builder.inc.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +################################ Main script ################################ + +. "${KEYMAN_ROOT}/resources/build/minimum-versions.inc.sh" +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +builder_describe \ + "Build docker images" \ + ":android" \ + ":base" \ + ":core" \ + ":linux" \ + ":web" \ + "--ubuntu-version=UBUNTU_VERSION The Ubuntu version (default: ${KEYMAN_DEFAULT_VERSION_UBUNTU_CONTAINER})" \ + "--no-cache Force rebuild of docker images" \ + "build Build docker images" \ + "test Test the docker images by running configure,build,test for all or the specified platforms" + +builder_parse "$@" + +_add_build_args() { + local var=$1 + local default_var=$2 + local name=$3 + local value + + if [[ -n "${!var:-}" ]]; then + value="${!var}" + else + value="${!default_var:-}" + fi + + build_args+=(--build-arg="${var}=${value}") + + if [[ -n "${build_version:-}" ]]; then + build_version="${build_version}-${name:-}${value}" + else + build_version="${name}${value}" + fi +} + +_convert_parameters_to_build_args() { + build_args=() + build_version= + local required_node_version + # shellcheck disable=SC2034 + required_node_version="$(_print_expected_node_version)" + + _add_build_args UBUNTU_VERSION KEYMAN_DEFAULT_VERSION_UBUNTU_CONTAINER "" + _add_build_args JAVA_VERSION KEYMAN_VERSION_JAVA java + _add_build_args REQUIRED_NODE_VERSION required_node_version node + _add_build_args REQUIRED_EMSCRIPTEN_VERSION KEYMAN_MIN_VERSION_EMSCRIPTEN emsdk + + if [[ -n "${BASE_VERSION:-}" ]]; then + build_args+=(--build-arg="BASE_VERSION=${BASE_VERSION}") + fi +} + +_is_default_values() { + [[ -z "${UBUNTU_VERSION:-}" ]] && [[ -z "${JAVA_VERSION:-}" ]] +} + +build_action() { + local platform=$1 + + builder_echo debug "Building image for ${platform}" + + _convert_parameters_to_build_args + + if [[ "${platform}" == "base" ]]; then + docker pull --platform "amd64" "ubuntu:${UBUNTU_VERSION:-${KEYMAN_DEFAULT_VERSION_UBUNTU_CONTAINER}}" + elif [[ "${platform}" == "linux" ]]; then + cp "${KEYMAN_ROOT}/linux/debian/control" "${platform}" + fi + + if builder_has_option --no-cache; then + OPTION_NO_CACHE="--no-cache" + fi + + # shellcheck disable=SC2164 + cd "${platform}" + # shellcheck disable=SC2248,SC2086 + docker build ${OPTION_NO_CACHE:-} --platform amd64 -t "keymanapp/keyman-${platform}-ci:${build_version}" "${build_args[@]}" . + # If the user didn't specify particular versions we will additionaly create an image + # with the tag 'default'. + if _is_default_values; then + builder_echo debug "Setting default tag for ${platform}" + docker build --platform amd64 -t "keymanapp/keyman-${platform}-ci:default" "${build_args[@]}" . + fi + # shellcheck disable=SC2164,SC2103 + cd - + builder_echo success "Docker image 'keymanapp/keyman-${platform}-ci:${build_version}' built" +} + +test_action() { + local platform=$1 + + builder_echo debug "Testing image for ${platform}" + ./run.sh "${platform}" -- ./build.sh configure,build,test:"${platform}" +} + +if builder_has_action build; then + build_action base + BASE_VERSION="${build_version}" + builder_run_action build:android build_action android + builder_run_action build:core build_action core + builder_run_action build:linux build_action linux + builder_run_action build:web build_action web +fi + +builder_run_action test:core test_action core +builder_run_action test:linux test_action linux +builder_run_action test:web test_action web +# Android uses artifacts from web, so it has to come after web +builder_run_action test:android test_action android diff --git a/resources/docker-images/core/Dockerfile b/resources/docker-images/core/Dockerfile new file mode 100644 index 0000000000..924e8f1e39 --- /dev/null +++ b/resources/docker-images/core/Dockerfile @@ -0,0 +1,54 @@ +# Keyman is copyright (C) SIL Global. MIT License. +# +# ARGS used in this file: +# - ARG BASE_VERSION +# - ARG REQUIRED_EMSCRIPTEN_VERSION + +ARG BASE_VERSION=default +FROM keymanapp/keyman-base-ci:${BASE_VERSION} + +LABEL org.opencontainers.image.authors="SIL Global." +LABEL org.opencontainers.image.url="https://github.com/keymanapp/keyman.git" +LABEL org.opencontainers.image.title="Keyman Core Build Image" + +USER root +RUN apt-get install -qy git jq llvm meson pkgconf \ + xvfb xserver-xephyr metacity + +# Pre-install emscripten +USER build +ARG REQUIRED_EMSCRIPTEN_VERSION=unset +RUN echo "Installing emscripten version ${REQUIRED_EMSCRIPTEN_VERSION}" && \ + export EMSDK_KEEP_DOWNLOADS=1 && \ + cd /home/build/ && \ + git clone https://github.com/emscripten-core/emsdk.git && \ + cd emsdk && \ + ./emsdk install ${REQUIRED_EMSCRIPTEN_VERSION} && \ + ./emsdk activate ${REQUIRED_EMSCRIPTEN_VERSION} +USER root +RUN echo "export EMSCRIPTEN_BASE=/home/build/emsdk/upstream/emscripten" >> /usr/bin/bashwrapper && \ + echo "export KEYMAN_USE_EMSDK=1" >> /usr/bin/bashwrapper + +# Finish bashwrapper script and adjust permissions +RUN <> /usr/bin/bashwrapper + +if [[ "\$@" =~ test ]] && [ -f /usr/bin/run-tests.sh ]; then + /usr/bin/run-tests.sh "\${@:-bash}" +else + "\${@:-bash}" +fi +EOF + +# now, switch to build user +USER build + +# Pre-install node +RUN export NVM_DIR="/home/build/.nvm" && \ + . /home/build/.nvm/nvm.sh && \ + cd /home/build/emsdk/upstream/emscripten && \ + npm install + +VOLUME /home/build/build +WORKDIR /home/build/build + +ENTRYPOINT [ "/usr/bin/bashwrapper" ] diff --git a/resources/docker-images/linux/.gitignore b/resources/docker-images/linux/.gitignore new file mode 100644 index 0000000000..4db28ac495 --- /dev/null +++ b/resources/docker-images/linux/.gitignore @@ -0,0 +1 @@ +control diff --git a/resources/docker-images/linux/Dockerfile b/resources/docker-images/linux/Dockerfile new file mode 100644 index 0000000000..3f680efe3e --- /dev/null +++ b/resources/docker-images/linux/Dockerfile @@ -0,0 +1,52 @@ +# Keyman is copyright (C) SIL Global. MIT License. + +ARG BASE_VERSION=default +FROM keymanapp/keyman-base-ci:${BASE_VERSION} +LABEL org.opencontainers.image.authors="SIL Global." +LABEL org.opencontainers.image.url="https://github.com/keymanapp/keyman.git" +LABEL org.opencontainers.image.title="Keyman Linux Build Image" + +# Install dependencies +ADD control /tmp/control +# Answer 'yes' to install questions +RUN apt-get install -qy python3 python3-setuptools python3-coverage \ + devscripts equivs libdatetime-perl lcov gcovr xvfb \ + xserver-xephyr metacity mutter dbus-x11 weston xwayland && \ + (yes | mk-build-deps --install /tmp/control) || true && \ + rm /tmp/control + +# Install lcov for code coverage +# Update to the latest and install packages needed for Linux coverage reporting +# and integration tests. We need at least version 2.0 of lcov. However, +# version 2.0-4 from Noble doesn't work either on Jammy. So we use +# version 2.0-1 from Mantic. +RUN LCOV_VERSION=$(dpkg -s lcov | grep Version | cut -d' ' -f2) && \ + if dpkg --compare-versions "${LCOV_VERSION}" lt 2.0; then \ + curl -sS -o /tmp/lcov.deb --location http://mirrors.kernel.org/ubuntu/pool/universe/l/lcov/lcov_2.0-1_all.deb && \ + apt-get -qy install /tmp/lcov.deb && \ + rm /tmp/lcov.deb ; \ + fi + +RUN mkdir -p /var/run/1000 && \ + chown build:build /var/run/1000 && \ + echo "export XDG_RUNTIME_DIR=/var/run/1000" >> /usr/bin/bashwrapper + +COPY run-tests.sh /usr/bin/run-tests.sh + +# Finish bashwrapper script and adjust permissions +RUN <> /usr/bin/bashwrapper + +if [[ "\$@" =~ test ]] && [ -f /usr/bin/run-tests.sh ]; then + /usr/bin/run-tests.sh "\${@:-bash}" +else + "\${@:-bash}" +fi +EOF + +# now, switch to build user +USER build + +VOLUME /home/build/build +WORKDIR /home/build/build + +ENTRYPOINT [ "/usr/bin/bashwrapper" ] diff --git a/resources/docker-images/linux/run-tests.sh b/resources/docker-images/linux/run-tests.sh new file mode 100755 index 0000000000..fbc5fb3b67 --- /dev/null +++ b/resources/docker-images/linux/run-tests.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -e + +if [[ -z "${DOCKER_RUNNING:-}" ]]; then + echo "This script is intended to be run inside a docker container." + exit 0 +fi + +# Start system dbus +sudo dbus-daemon --system --fork + +# Start session dbus +# shellcheck disable=SC2046 # SC2046: quote this to prevent word-splitting +export $(dbus-launch) + +# Start Wayland +weston --no-config --socket=wayland-0 --backend=headless & +export WAYLAND_DISPLAY=wayland-0 + +# Start X11 (on Wayland) +Xwayland & +export DISPLAY=:0 + +"${@:-bash}" diff --git a/resources/docker-images/run.sh b/resources/docker-images/run.sh new file mode 100755 index 0000000000..c42d6c2a02 --- /dev/null +++ b/resources/docker-images/run.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../resources/build/builder.inc.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "${KEYMAN_ROOT}/resources/build/minimum-versions.inc.sh" + +################################ Main script ################################ + +builder_describe \ + "Run build.sh script inside of a docker image. Pass the build script and parameters after --." \ + "android" \ + "core" \ + "linux" \ + "web" \ + "--ubuntu-version=UBUNTU_VERSION The Ubuntu version (default: ${KEYMAN_DEFAULT_VERSION_UBUNTU_CONTAINER})" + +builder_parse "$@" + +run_android() { + docker run -it --rm -v "${KEYMAN_ROOT}":/home/build/build \ + -v "${KEYMAN_ROOT}/core/build/docker-core":/home/build/build/core/build \ + keymanapp/keyman-android-ci:default \ + "${builder_extra_params[@]}" +} + +run_core() { + docker run -it --rm -v "${KEYMAN_ROOT}":/home/build/build \ + -v "${KEYMAN_ROOT}/core/build/docker-core":/home/build/build/core/build \ + keymanapp/keyman-core-ci:default \ + "${builder_extra_params[@]}" +} + +run_linux() { + mkdir -p "${KEYMAN_ROOT}/linux/build/docker-linux" + docker run -it --privileged --rm -v "${KEYMAN_ROOT}":/home/build/build \ + -v "${KEYMAN_ROOT}/core/build/docker-core":/home/build/build/core/build \ + -v "${KEYMAN_ROOT}/linux/build/docker-linux":/home/build/build/linux/build \ + -e DESTDIR=/tmp \ + keymanapp/keyman-linux-ci:default \ + "${builder_extra_params[@]}" +} + +run_web() { + docker run -it --privileged --rm -v "${KEYMAN_ROOT}":/home/build/build \ + -v "${KEYMAN_ROOT}/core/build/docker-core":/home/build/build/core/build \ + keymanapp/keyman-web-ci:default \ + "${builder_extra_params[@]}" +} + +mkdir -p "${KEYMAN_ROOT}/core/build/docker-core" + +builder_run_action android run_android +builder_run_action core run_core +builder_run_action linux run_linux +builder_run_action web run_web diff --git a/resources/docker-images/web/Dockerfile b/resources/docker-images/web/Dockerfile new file mode 100644 index 0000000000..5a27c9724d --- /dev/null +++ b/resources/docker-images/web/Dockerfile @@ -0,0 +1,77 @@ +# Keyman is copyright (C) SIL Global. MIT License. +# +# ARGS used in this file: +# - ARG BASE_VERSION=default +# - ARG REQUIRED_EMSCRIPTEN_VERSION=unset + +ARG BASE_VERSION=default +FROM keymanapp/keyman-base-ci:${BASE_VERSION} + +LABEL org.opencontainers.image.authors="SIL Global." +LABEL org.opencontainers.image.url="https://github.com/keymanapp/keyman.git" +LABEL org.opencontainers.image.title="Keyman for Web Build Image" + +USER root +RUN apt-get install -qy git jq xvfb xserver-xephyr metacity +# For playwright: +RUN apt-get install -qy libevent-2.1-7t64 libxslt1.1 libwoff1 libvpx9 \ + libgstreamer-plugins-bad1.0-0 libwebpdemux2 libharfbuzz-icu0 \ + libenchant-2-2 libsecret-1-0 libhyphen0 libmanette-0.2-0 libflite1 \ + gstreamer1.0-libav + +COPY run-tests.sh /usr/bin/run-tests.sh + +# Pre-install emscripten +USER build +ARG REQUIRED_EMSCRIPTEN_VERSION=unset +RUN echo "Installing emscripten version ${REQUIRED_EMSCRIPTEN_VERSION}" && \ + export EMSDK_KEEP_DOWNLOADS=1 && \ + cd /home/build/ && \ + git clone https://github.com/emscripten-core/emsdk.git && \ + cd emsdk && \ + ./emsdk install ${REQUIRED_EMSCRIPTEN_VERSION} && \ + ./emsdk activate ${REQUIRED_EMSCRIPTEN_VERSION} +USER root +RUN echo "export EMSCRIPTEN_BASE=/home/build/emsdk/upstream/emscripten" >> /usr/bin/bashwrapper && \ + echo "export KEYMAN_USE_EMSDK=1" >> /usr/bin/bashwrapper + +# Keyman Web +RUN curl --output google-chrome-stable_current_amd64.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \ + apt-get -qy install ./google-chrome-stable_current_amd64.deb && \ + rm google-chrome-stable_current_amd64.deb && \ + echo "export CHROME_BIN=/opt/google/chrome/chrome" >> /usr/bin/bashwrapper + +RUN < /etc/apt/preferences.d/mozilla +Package: * +Pin: origin packages.mozilla.org +Pin-Priority: 1000 +EOF +RUN curl https://packages.mozilla.org/apt/repo-signing-key.gpg > /etc/apt/keyrings/packages.mozilla.org.asc && \ + echo "deb [signed-by=/etc/apt/keyrings/packages.mozilla.org.asc] https://packages.mozilla.org/apt mozilla main" >> /etc/apt/sources.list.d/mozilla.list && \ + apt-get update && \ + apt-get -qy install firefox && \ + echo "export FIREFOX_BIN=/usr/bin/firefox" >> /usr/bin/bashwrapper + +# Finish bashwrapper script and adjust permissions +RUN <> /usr/bin/bashwrapper + +if [[ "\$@" =~ test ]] && [ -f /usr/bin/run-tests.sh ]; then + /usr/bin/run-tests.sh "\${@:-bash}" +else + "\${@:-bash}" +fi +EOF + +# now, switch to build user +USER build + +# Pre-install node +RUN export NVM_DIR="/home/build/.nvm" && \ + . /home/build/.nvm/nvm.sh && \ + cd /home/build/emsdk/upstream/emscripten && \ + npm install + +VOLUME /home/build/build +WORKDIR /home/build/build + +ENTRYPOINT [ "/usr/bin/bashwrapper" ] diff --git a/resources/docker-images/web/run-tests.sh b/resources/docker-images/web/run-tests.sh new file mode 100755 index 0000000000..84871d8b19 --- /dev/null +++ b/resources/docker-images/web/run-tests.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +if [[ -z "${DOCKER_RUNNING:-}" ]]; then + echo "This script is intended to be run inside a docker container." + exit 0 +fi + +set -e +echo "Starting Xvfb..." +Xvfb -screen 0 1024x768x24 :33 &> /dev/null & +sleep 1 +echo "Starting Xephyr..." +DISPLAY=:33 Xephyr :32 -screen 1024x768 &> /dev/null & +sleep 1 +echo "Starting metacity" +metacity --display=:32 &> /dev/null & +export DISPLAY=:32 +"${@:-bash}" diff --git a/resources/shellHelperFunctions.sh b/resources/shellHelperFunctions.sh index bcd3da0d85..6f60d4484f 100755 --- a/resources/shellHelperFunctions.sh +++ b/resources/shellHelperFunctions.sh @@ -277,10 +277,14 @@ verify_npm_setup() { popd > /dev/null } +_print_expected_node_version() { +"$JQ" -r '.engines.node' "$KEYMAN_ROOT/package.json" +} + # Use nvm to select a node version according to package.json # see /docs/build/node.md _select_node_version_with_nvm() { - local REQUIRED_NODE_VERSION="$("$JQ" -r '.engines.node' "$KEYMAN_ROOT/package.json")" + local REQUIRED_NODE_VERSION="$(_print_expected_node_version)" local CURRENT_NODE_VERSION if [[ $BUILDER_OS != win ]]; then diff --git a/web/build.sh b/web/build.sh index d01d493517..e6d9a5feb8 100755 --- a/web/build.sh +++ b/web/build.sh @@ -166,7 +166,10 @@ builder_run_child_actions build:engine/attachment # Uses engine/interfaces (due to resource-path config interface) builder_run_child_actions build:engine/keyboard-storage -# Uses engine/interfaces, engine/keyboard-storage, & engine/osk +# Builds the predictive-text components +builder_run_child_actions build:engine/predictive-text + +# Uses engine/interfaces, engine/keyboard-storage, engine/predictive-text, & engine/osk builder_run_child_actions build:engine/main # Uses all but engine/element-wrappers and engine/attachment @@ -190,7 +193,7 @@ builder_run_child_actions build:test-pages builder_run_action build:_all build_action # Run tests -# builder_run_child_actions test +builder_run_child_actions test builder_run_action test:_all test_action function do_test_help() { diff --git a/web/ci.sh b/web/ci.sh index 43f176fdd6..70cb24b965 100755 --- a/web/ci.sh +++ b/web/ci.sh @@ -12,11 +12,11 @@ THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" . "${THIS_SCRIPT%/*}/../resources/build/build-utils.sh" ## END STANDARD BUILD SCRIPT INCLUDE -. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" -. "$KEYMAN_ROOT/resources/build/ci/pull-requests.inc.sh" +. "${KEYMAN_ROOT}/resources/shellHelperFunctions.sh" +. "${KEYMAN_ROOT}/resources/build/ci/pull-requests.inc.sh" # This script runs from its own folder -cd "$THIS_SCRIPT_PATH" +cd "${THIS_SCRIPT_PATH}" # ################################ Main script ################################ @@ -37,20 +37,25 @@ builder_parse "$@" #### -TIER=`cat ../TIER.md` -BUILD_NUMBER=`cat ../VERSION.md` +TIER=$(cat ../TIER.md) +BUILD_NUMBER=$(cat ../VERSION.md) function web_sentry_upload () { + if [[ -z "${SENTRY_ORG:-}" ]] || [[ -z "${SENTRY_PROJECT:-}" ]]; then + echo "Skipping Sentry upload: SENTRY_ORG and/or SENTRY_PROJECT are unset." + return + fi + echo "Uploading $1 to Sentry..." # --strip-common-prefix does not take an argument, unlike --strip-prefix. It auto-detects # the most common prefix instead. - sentry-cli releases files "$VERSION_GIT_TAG" upload-sourcemaps --strip-common-prefix "$1" \ + sentry-cli releases files "${VERSION_GIT_TAG}" upload-sourcemaps --strip-common-prefix "$1" \ --rewrite --ext js --ext map --ext ts echo "Upload successful." } -if builder_start_action build; then +function build_action() { # Build step: since CI builds start (and should start) from scratch, run the following # three actions: # - configure: retrieve all NPM dependencies @@ -63,19 +68,17 @@ if builder_start_action build; then # Upload the sentry-configuration engine used by the mobile apps to sentry # Also, clean 'em first. - for sourcemap in "$KEYMAN_ROOT/web/src/engine/sentry-manager/build/lib/"*.map; do - node "$KEYMAN_ROOT/web/build/tools/building/sourcemap-root/index.js" null "$sourcemap" --clean + for sourcemap in "${KEYMAN_ROOT}/web/src/engine/sentry-manager/build/lib/"*.map; do + node "${KEYMAN_ROOT}/web/build/tools/building/sourcemap-root/index.js" null "${sourcemap}" --clean done - web_sentry_upload "$KEYMAN_ROOT/web/src/engine/sentry-manager/build/lib/" + web_sentry_upload "${KEYMAN_ROOT}/web/src/engine/sentry-manager/build/lib/" # And, of course, the main build-products too - web_sentry_upload "$KEYMAN_ROOT/web/build/app/webview/release/" - web_sentry_upload "$KEYMAN_ROOT/web/build/publish/release/" + web_sentry_upload "${KEYMAN_ROOT}/web/build/app/webview/release/" + web_sentry_upload "${KEYMAN_ROOT}/web/build/publish/release/" +} - builder_finish_action success build -fi - -if builder_start_action test; then +function test_action() { # Testing step: run ALL unit tests, including those of the submodules. OPTIONS= @@ -85,24 +88,20 @@ if builder_start_action test; then # No --reporter option exists yet for the headless modules. - "$KEYMAN_ROOT/web/src/engine/keyboard/build.sh" test $OPTIONS - "$KEYMAN_ROOT/web/src/engine/osk/gesture-processor/build.sh" test $OPTIONS + "${KEYMAN_ROOT}/web/src/engine/keyboard/build.sh" test ${OPTIONS} + "${KEYMAN_ROOT}/web/src/engine/osk/gesture-processor/build.sh" test ${OPTIONS} - ./build.sh test $OPTIONS + ./build.sh test ${OPTIONS} +} - builder_finish_action success test -fi - -if builder_start_action post-test; then +function post_test_action() { # Always make sure BrowserStack stuff is terminated after the test step. # Even if it fails and/or hangs. # No matter what. Otherwise, it may leave a lock-file that breaks a later build! ../common/test/resources/test_kill_browserstack.sh +} - builder_finish_action success post-test -fi - -if builder_start_action validate-size; then +function validate_size_action() { # Performs the web build product size check as reported on Web test PRs. FLAGS= @@ -113,18 +112,16 @@ if builder_start_action validate-size; then # Do not use --report when in --debug mode; it sets an error code that is VERY difficult to # catch with our set -e & `trap` setup. ./src/tools/building/check-build-size.sh $FLAGS +} - builder_finish_action success validate-size -fi - -if builder_start_action prepare:s.keyman.com; then +function prepare_s_keyman_com_action() { if ! builder_has_option --s.keyman.com; then builder_die "--s.keyman.com is unset!" fi if builder_is_debug_build; then # First phase: make sure the s.keyman.com repo is locally-available and up to date. - pushd "$S_KEYMAN_COM" + pushd "${S_KEYMAN_COM}" # For testing on a local development machine / a machine with the repo already loaded. git checkout master @@ -135,42 +132,40 @@ if builder_start_action prepare:s.keyman.com; then # Second phase: copy the artifacts over # The main build products are expected to reside at the root of this folder. - BASE_PUBLISH_FOLDER="$S_KEYMAN_COM/kmw/engine/$VERSION" - echo "FOLDER: $BASE_PUBLISH_FOLDER" - mkdir -p "$BASE_PUBLISH_FOLDER" + BASE_PUBLISH_FOLDER="${S_KEYMAN_COM}/kmw/engine/${VERSION}" + echo "FOLDER: ${BASE_PUBLISH_FOLDER}" + mkdir -p "${BASE_PUBLISH_FOLDER}" # s.keyman.com - release-config only. It's notably smaller, thus far more favorable # for distribution via cloud service. - cp -Rf build/publish/release/* "$BASE_PUBLISH_FOLDER" + cp -Rf build/publish/release/* "${BASE_PUBLISH_FOLDER}" # Third phase: tweak the sourcemaps # We can use an alt-mode of Web's sourcemap-root tool for this. - for sourcemap in "$BASE_PUBLISH_FOLDER/"*.map; do - node "$KEYMAN_ROOT/web/build/tools/building/sourcemap-root/index.js" null "$sourcemap" --sourceRoot "https://s.keyman.com/kmw/engine/$VERSION/src" + for sourcemap in "${BASE_PUBLISH_FOLDER}/"*.map; do + node "${KEYMAN_ROOT}/web/build/tools/building/sourcemap-root/index.js" null "${sourcemap}" --sourceRoot "https://s.keyman.com/kmw/engine/${VERSION}/src" done # Construct the PR - echo "Committing and pushing KeymanWeb release $VERSION to s.keyman.com" + echo "Committing and pushing KeymanWeb release ${VERSION} to s.keyman.com" - ci_add_files "$S_KEYMAN_COM" "kmw/engine/$VERSION" - if ! ci_repo_has_cached_changes "$S_KEYMAN_COM"; then + ci_add_files "${S_KEYMAN_COM}" "kmw/engine/${VERSION}" + if ! ci_repo_has_cached_changes "${S_KEYMAN_COM}"; then builder_die "No release was added to s.keyman.com, something went wrong" fi - ci_open_pull_request "$S_KEYMAN_COM" auto/keymanweb/release "auto: KeymanWeb release $VERSION" - - builder_finish_action success prepare:s.keyman.com -fi + ci_open_pull_request "${S_KEYMAN_COM}" auto/keymanweb/release "auto: KeymanWeb release ${VERSION}" +} # Note: for now, this command is used to prepare the artifacts used by the download site, but # NOT to actually UPLOAD them via rsync or to produce related .download_info files. -if builder_start_action prepare:downloads.keyman.com; then - UPLOAD_PATH="$KEYMAN_ROOT/web/build/upload/$VERSION" +function prepare_downloads_keyman_com_action() { + UPLOAD_PATH="${KEYMAN_ROOT}/web/build/upload/${VERSION}" # --- First action artifact - the KMW zip file --- - ZIP="$UPLOAD_PATH/keymanweb-$VERSION.zip" + ZIP="${UPLOAD_PATH}/keymanweb-${VERSION}.zip" - mkdir -p "$UPLOAD_PATH" + mkdir -p "${UPLOAD_PATH}" # On Windows, we use 7-zip (SEVEN_Z_HOME env var). On other platforms, we use zip. @@ -178,8 +173,8 @@ if builder_start_action prepare:downloads.keyman.com; then COMPRESS_ADD= # Marc's preference; use $SEVEN_Z_HOME and have the BAs set up with THAT as an env var. - if [ ! -z "${SEVEN_Z_HOME+x}" ]; then - COMPRESS_CMD="$SEVEN_Z_HOME/7z" + if [[ ! -z "${SEVEN_Z_HOME+x}" ]]; then + COMPRESS_CMD="${SEVEN_Z_HOME}/7z" COMPRESS_ADD="a -bd -bb0 -r" # add, hide progress, log level 0, recursive fi @@ -195,28 +190,34 @@ if builder_start_action prepare:downloads.keyman.com; then pushd build/publish # Zip both the 'debug' and 'release' configurations together. - "${COMPRESS_CMD}" $COMPRESS_ADD $ZIP * + # shellcheck disable=SC2086 + "${COMPRESS_CMD}" ${COMPRESS_ADD} "${ZIP}" ./* popd # --- Second action artifact - the 'static' folder (hosted user testing on downloads.keyman.com) --- echo "" echo "Building \`static/\` folder for long-term hosting of testing resources..." - STATIC="$UPLOAD_PATH/static" - mkdir -p "$STATIC" + STATIC="${UPLOAD_PATH}/static" + mkdir -p "${STATIC}" - mkdir -p "$STATIC/build" - cp -rf build/app "$STATIC/build/app" - cp -rf build/engine "$STATIC/build/engine" - cp -rf build/tools "$STATIC/build/tools" + mkdir -p "${STATIC}/build" + cp -rf build/app "${STATIC}/build/app" + cp -rf build/engine "${STATIC}/build/engine" + cp -rf build/tools "${STATIC}/build/tools" # avoid build/upload, since that's the folder we're building! - cp -f index.html "$STATIC/index.html" + cp -f index.html "${STATIC}/index.html" - mkdir -p "$STATIC/src/tools" - cp -rf src/tools/testing "$STATIC/src/tools/testing" - cp -rf src/test "$STATIC/src/test" - cp -rf src/samples "$STATIC/src/samples" + mkdir -p "${STATIC}/src/tools" + cp -rf src/tools/testing "${STATIC}/src/tools/testing" + cp -rf src/test "${STATIC}/src/test" + cp -rf src/samples "${STATIC}/src/samples" +} - builder_finish_action success prepare:downloads.keyman.com -fi +builder_run_action build build_action +builder_run_action test test_action +builder_run_action post-test post_test_action +builder_run_action validate-size validate_size_action +builder_run_action prepare:s.keyman.com prepare_s_keyman_com_action +builder_run_action prepare:downloads.keyman.com prepare_downloads_keyman_com_action diff --git a/web/docs/engine/guide/adding-keyboards.md b/web/docs/engine/guide/adding-keyboards.md index 93e65d1442..2dba3e86c1 100644 --- a/web/docs/engine/guide/adding-keyboards.md +++ b/web/docs/engine/guide/adding-keyboards.md @@ -4,8 +4,6 @@ title: Adding Keyboards There are multiple ways to add and install keyboards into your KeymanWeb installation. -In the examples that follow, please note the use of variable `kmw` as shorthand for the `keyman` object. - ## Directly linking a local copy The most efficient way to utilize a keyboard is to obtain a local copy of it and place this copy in a static location on your website. Once this is done, it can be directly linked into KeymanWeb as follows. @@ -21,6 +19,7 @@ keyman.addKeyboards({ filename:'./us-1.0.js' // A valid path to the compiled *.js file representing the keyboard. }); ``` + Custom fonts may also be utilized via the `language.font` property. For example: ```c @@ -53,7 +52,7 @@ The `@` prefix indicates the use of the BCP 47 language code, which in this case To obtain a specific keyboard by name or by keyboard name and language code as a pair, see the following: ```c -keyman.addKeyboards('french','european2@sv','european2@no') +keyman.addKeyboards('french','sil_euro_latin@sv','sil_euro_latin@no') ``` -This will install three keyboards - one for French (named, quite simply, "French") and two copies of the EuroLatin2 keyboard - one for Swedish and one for Norwegian. +This will install three keyboards - one for French (named, quite simply, "French") and two copies of the EuroLatin keyboard - one for Swedish and one for Norwegian. diff --git a/web/docs/engine/guide/examples/__auto-control.html b/web/docs/engine/guide/examples/__auto-control.html index 9e1786c6d7..e7fc2768c6 100644 --- a/web/docs/engine/guide/examples/__auto-control.html +++ b/web/docs/engine/guide/examples/__auto-control.html @@ -20,9 +20,12 @@ +

Automatic Mode Example

+ +Back to Document \ No newline at end of file diff --git a/web/docs/engine/guide/examples/__control-by-control.html b/web/docs/engine/guide/examples/__control-by-control.html index b1f7ac5e42..440a7862d0 100644 --- a/web/docs/engine/guide/examples/__control-by-control.html +++ b/web/docs/engine/guide/examples/__control-by-control.html @@ -33,6 +33,7 @@ +

Control-by-Control Example

Email to (KeymanWeb not enabled)

@@ -43,5 +44,6 @@
+Back to Document \ No newline at end of file diff --git a/web/docs/engine/guide/examples/__first-example.html b/web/docs/engine/guide/examples/__first-example.html index 3b46160f65..1657db9440 100644 --- a/web/docs/engine/guide/examples/__first-example.html +++ b/web/docs/engine/guide/examples/__first-example.html @@ -18,6 +18,9 @@ +

First Example

Click me and type:

+ +Back to Document \ No newline at end of file diff --git a/web/docs/engine/guide/examples/__full-manual-control.html b/web/docs/engine/guide/examples/__full-manual-control.html index c9d76d90fb..24b0d004a7 100644 --- a/web/docs/engine/guide/examples/__full-manual-control.html +++ b/web/docs/engine/guide/examples/__full-manual-control.html @@ -43,7 +43,7 @@ window.addEventListener( 'load' , SetupDocument ); - +

Manual Control - Custom Interface

Keyboard:

@@ -51,5 +51,6 @@ window.addEventListener( 'load' , SetupDocument );

+Back to Document diff --git a/web/docs/engine/guide/examples/__manual-control.html b/web/docs/engine/guide/examples/__manual-control.html index 37776a7850..e9f5f219ca 100644 --- a/web/docs/engine/guide/examples/__manual-control.html +++ b/web/docs/engine/guide/examples/__manual-control.html @@ -32,7 +32,7 @@ - +

Manual Mode Example

KeymanWeb

@@ -40,6 +40,7 @@

+Back to Document
\ No newline at end of file diff --git a/web/src/engine/interfaces/src/prediction/languageProcessor.interface.ts b/web/src/engine/interfaces/src/prediction/languageProcessor.interface.ts index b26125982e..eafdac2d39 100644 --- a/web/src/engine/interfaces/src/prediction/languageProcessor.interface.ts +++ b/web/src/engine/interfaces/src/prediction/languageProcessor.interface.ts @@ -71,4 +71,6 @@ export interface LanguageProcessorSpec extends EventEmitter; get wordbreaksAfterSuggestions(): boolean; + + get mayAutoCorrect(): boolean; } diff --git a/web/src/engine/interfaces/src/prediction/predictionContext.ts b/web/src/engine/interfaces/src/prediction/predictionContext.ts index b131752d96..ec632a5058 100644 --- a/web/src/engine/interfaces/src/prediction/predictionContext.ts +++ b/web/src/engine/interfaces/src/prediction/predictionContext.ts @@ -324,7 +324,7 @@ export default class PredictionContext extends EventEmitter= Codes.keyCodes["K_NP0"] && Lkc.Lcode <= Codes.keyCodes["K_NPSLASH"]) { // Number pad, numlock on diff --git a/web/src/engine/main/src/headless/languageProcessor.ts b/web/src/engine/main/src/headless/languageProcessor.ts index 9cb73def58..82c9a0392c 100644 --- a/web/src/engine/main/src/headless/languageProcessor.ts +++ b/web/src/engine/main/src/headless/languageProcessor.ts @@ -21,6 +21,7 @@ export class LanguageProcessor extends EventEmitter { private _mayPredict: boolean = true; private _mayCorrect: boolean = true; + private _mayAutoCorrect: boolean = false; // initialized to false - #12767 private _state: StateChangeEnum = 'inactive'; @@ -402,6 +403,14 @@ export class LanguageProcessor extends EventEmitter { this._mayCorrect = flag; } + public get mayAutoCorrect() { + return this._mayAutoCorrect; + } + + public set mayAutoCorrect(flag: boolean) { + this._mayAutoCorrect = flag; + } + public get wordbreaksAfterSuggestions() { return this.configuration.wordbreaksAfterSuggestions; } diff --git a/web/src/engine/osk/src/input/gestures/browser/multitap.ts b/web/src/engine/osk/src/input/gestures/browser/multitap.ts index 99e50810f9..76280f63e7 100644 --- a/web/src/engine/osk/src/input/gestures/browser/multitap.ts +++ b/web/src/engine/osk/src/input/gestures/browser/multitap.ts @@ -8,6 +8,7 @@ import { distributionFromDistanceMaps } from '../../../corrections.js'; import Modipress from './modipress.js'; import { keySupportsModipress } from '../specsForLayout.js'; import { GesturePreviewHost } from '../../../keyboard-layout/gesturePreviewHost.js'; +import { correctionKeyFilter } from '../../../correctionLayout.js'; /** * Represents a potential multitap gesture's implementation within KeymanWeb. @@ -104,20 +105,33 @@ export default class Multitap implements GestureHandler { keyEvent.baseTranscriptionToken = this.baseContextToken; const coord = tap.sources[0].currentSample; - const baseDistances = vkbd.getSimpleTapCorrectionDistances(coord, this.baseKey.key.spec as ActiveKey); + + const baseSpec = this.baseKey.key.spec; + const baseDistances = vkbd.getSimpleTapCorrectionDistances(coord, baseSpec as ActiveKey); + // If the key is on a different layer than is shown, what if the user meant to hit a different key? + // (Bypass if the tap is part of a modipress gesture.) if(coord.stateToken != vkbd.layerId && !tap.matchedId.includes('modipress')) { const matchKey = vkbd.layerGroup.findNearestKey({...coord, stateToken: vkbd.layerId}); - // Replace the key at the current location for the current layer key - // with the multitap base key. - const p = baseDistances.get(matchKey.key.spec); - if(p == null) { - console.warn("Could not find current layer's key") + // That said, if they directly touched a non-output key, simplify and assume + // that they hit the intended key. + if(correctionKeyFilter(matchKey.key.spec)) { + // Replace the key at the current location for the current layer key + // with the multitap base key. + const p = baseDistances.get(matchKey.key.spec); + if(p == null) { + console.warn("Could not find current layer's key") + } else { + baseDistances.delete(matchKey.key.spec); + baseDistances.set(coord.item.key.spec, p); + } } - baseDistances.delete(matchKey.key.spec); - baseDistances.set(coord.item.key.spec, p); } - keyEvent.keyDistribution = this.currentStageKeyDistribution(baseDistances); + + // Will also bypass if the base key is modipress-compatible. + if(coord.item && correctionKeyFilter(coord.item.key.spec)) { + keyEvent.keyDistribution = this.currentStageKeyDistribution(baseDistances); + } // TODO for future: multitap previews. diff --git a/web/src/engine/predictive-text/build.sh b/web/src/engine/predictive-text/build.sh new file mode 100755 index 0000000000..677d8b2269 --- /dev/null +++ b/web/src/engine/predictive-text/build.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# Compile keymanweb predictive-text components. + +## START STANDARD BUILD SCRIPT INCLUDE +# adjust relative paths as necessary +THIS_SCRIPT="$(readlink -f "${BASH_SOURCE[0]}")" +. "${THIS_SCRIPT%/*}/../../../../resources/build/builder.inc.sh" +## END STANDARD BUILD SCRIPT INCLUDE + +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +# ################################ Main script ################################ + +builder_describe "Builds predictive-text components used within Keyman Engine for Web (KMW)." \ + "clean" \ + "configure" \ + "build" \ + "test" \ + ":templates Builds the model templates utilized by compiled lexical models" \ + ":wordbreakers Builds the wordbreakers provided for lexical model use" \ + ":worker-main Builds the predictive-text worker interface module" \ + ":worker-thread Builds the predictive-text worker" \ + ":_all (Meta build target used when targets are not specified)" \ + "--ci+ Set to utilize CI-based test configurations & reporting." + +# Possible TODO? +# "upload-symbols Uploads build product to Sentry for error report symbolification. Only defined for $DOC_BUILD_EMBED_WEB" \ + +builder_parse "$@" + +config=release +if builder_is_debug_build; then + config=debug +fi + +builder_describe_outputs \ + configure "/node_modules" \ + build:templates "/web/src/engine/predictive-text/build/obj/index.js" \ + build:wordbreakers "/web/src/engine/wordbreakers/build/main/obj/index.js" \ + build:worker-main "/web/src/engine/worker-main/build/obj/lmlayer.js" \ + build:worker-thread "/web/src/engine/worker-thread/build/obj/worker-main.wrapped.js" + +BUNDLE_CMD="node ${KEYMAN_ROOT}/web/src/tools/es-bundling/build/common-bundle.mjs" + +#### Build action definitions #### + +# We can run all clean & configure actions at once without much issue. + +builder_run_child_actions clean +builder_run_child_actions configure + +## Build actions + +builder_run_child_actions build:wordbreakers + +builder_run_child_actions build:templates +builder_run_child_actions build:worker-thread +builder_run_child_actions build:worker-main + +# If doing CI testing, the predictive-text child actions have their own build configuration. +# For local testing, though, we can allow them to proceed. +if ! builder_has_option --ci; then + builder_run_child_actions test +fi \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-main/build.sh b/web/src/engine/predictive-text/worker-main/build.sh index f7b788b6ef..f15462d71d 100755 --- a/web/src/engine/predictive-text/worker-main/build.sh +++ b/web/src/engine/predictive-text/worker-main/build.sh @@ -57,12 +57,13 @@ function do_build() { function do_test() { local TEST_OPTIONS= if builder_has_option --ci; then - TEST_OPTIONS=--ci + # We'll test the included libraries here for now. At some point, we may wish + # to establish a ci.sh script for predictive-text to handle this instead. + ./unit_tests/test.sh test:libraries test:headless test:browser --ci + else + # If we're not in --ci mode, then this doesn't need to trigger the sibling projects' tests. + ./unit_tests/test.sh test:headless test:browser fi - - # We'll test the included libraries here for now. At some point, we may wish - # to establish a ci.sh script for predictive-text to handle this instead. - ./unit_tests/test.sh test:libraries test:headless test:browser $TEST_OPTIONS } builder_run_action configure do_configure diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tracker.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tracker.ts index fa2cc6bdb4..e9d45db394 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tracker.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tracker.ts @@ -309,9 +309,15 @@ interface ContextMatchResult { /** * Indicates the portion of the incoming keystroke data, if any, that applies to * tokens before the last pre-caret token and thus should not be replaced by predictions - * based upon `state`. + * based upon `state`. If the provided context state + the incoming transform do not + * adequately match the current context, the match attempt will fail with a `null` result. * - * Should always be non-null if the token before the caret did not previously exist. + * Should generally be non-null if the token before the caret did not previously exist. + * + * The result may be null if it does not match the prior context state or if bookkeeping + * based upon it is problematic - say, if wordbreaking effects shift due to new input, + * causing a mismatch with the prior state's tokenization. + * (Refer to #12494 for an example case.) */ preservationTransform?: Transform; } @@ -425,17 +431,35 @@ export class ContextTracker extends CircularArray { } const transformDistIndex = i - (lastMatch + 1); const tokenDistribution = transformSequenceDistribution.map((entry) => { + const sample = entry.sample[transformDistIndex]; + if(!sample) { + return null; + } return { - sample: entry.sample[transformDistIndex], + sample, p: entry.p }; }); + const incomingToken = tokenizedContext[i - poppedTokenCount]; + // If the tokenized part of the input is a completely empty transform, // replace it with null. This can happen with our default wordbreaker // immediately after a whitespace. We don't want to include this // transform as part of the input when doing correction-search. let primaryInput = hasDistribution ? tokenDistribution[0]?.sample : null; + + // If the incoming token has text but we have no transform to match it with, + // abort the matching attempt. We can't match this case well yet. + // These cases are generally infrequent enough to be low-ROI, not worth the + // investment to optimize further. (Doing so isn't the simplest.) + // + // This may happen if tokenization for pre-existing text changes due to incoming + // input - refer to #12494 for an example case. + if(!primaryInput && incomingToken.text != '') { + return null; + } + if(primaryInput && primaryInput.insert == "" && primaryInput.deleteLeft == 0 && !primaryInput.deleteRight) { primaryInput = null; } @@ -451,7 +475,6 @@ export class ContextTracker extends CircularArray { } const isBackspace = primaryInput && TransformUtils.isBackspace(primaryInput); - const incomingToken = tokenizedContext[i - poppedTokenCount] switch(editPath[i]) { case 'substitute': if(isLastToken) { diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index a0b687b9bf..70f739611b 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -558,11 +558,13 @@ export function processSimilarity( return; } - // Generate a default 'keep' option if one was not otherwise produced. - - // IMPORTANT: duplicate the original transform. Causes nasty side-effects - // for context-tracking otherwise! - let keepTransform: Transform = { ...inputTransform }; + // Generate a full-word 'keep' replacement like other suggestions when one is not otherwise + // produced; we want to replace the full token in the same manner used for other suggestions. + const basePrefixLength = truePrefix.kmwLength() - inputTransform.insert.kmwLength() + inputTransform.deleteLeft; + const keepTransform = { + insert: truePrefix, + deleteLeft: basePrefixLength + }; // 1 is a filler value; goes unused b/c is for a 'keep'. let keepSuggestion = models.transformToSuggestion(keepTransform, 1); diff --git a/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/edit-distance/context-tracker.js b/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/edit-distance/context-tracker.js index 1067c3cf0d..c663072f6d 100644 --- a/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/edit-distance/context-tracker.js +++ b/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/edit-distance/context-tracker.js @@ -152,6 +152,29 @@ describe('ContextTracker', function() { assert.isNotEmpty(state.tokens[state.tokens.length - 2].transformDistributions); assert.isEmpty(state.tokens[state.tokens.length - 1].transformDistributions); }); + + it('rejects hard-to-handle case: tail token is split into three rather than two', function() { + let baseContext = models.tokenize(defaultBreaker, { + left: "text'" + }); + assert.equal(baseContext.left.length, 1); + let baseContextMatch = ContextTracker.modelContextState(baseContext.left); + + // Now the actual check. + let newContext = models.tokenize(defaultBreaker, { + left: "text'\"" + }); + // The reason it's a problem - current internal logic isn't prepared to shift + // from 1 to 3 tokens in a single step. + assert.equal(newContext.left.length, 3); + + let transform = { + insert: '\"', + deleteLeft: 0 + } + let problemContextMatch = ContextTracker.attemptMatchContext(newContext.left, baseContextMatch, toWrapperDistribution(transform)); + assert.isNull(problemContextMatch); + }); }); describe('modelContextState', function() { diff --git a/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/suggestion-similarity.js b/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/suggestion-similarity.js index 7f31b1eff9..18fa1307b1 100644 --- a/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/suggestion-similarity.js +++ b/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/suggestion-similarity.js @@ -340,8 +340,8 @@ describe('processSimilarity', () => { prediction: { sample: { transform: { - insert: 'e', - deleteLeft: 0 + insert: 'iphone', + deleteLeft: 5 }, displayAs: '', matchesModel: false, diff --git a/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/worker-model-compositor.js b/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/worker-model-compositor.js index 8dc108440b..0774385026 100644 --- a/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/worker-model-compositor.js +++ b/web/src/engine/predictive-text/worker-thread/src/tests/mocha/cases/worker-model-compositor.js @@ -932,8 +932,8 @@ describe('ModelCompositor', function() { assert.equal(suggestions.length, 1); let expectedTransform = { - insert: ' ', // Keeps current context the same, though it adds a wordbreak. - deleteLeft: 0 + insert: 'hi ', // Keeps current context the same, though it adds a wordbreak. + deleteLeft: 2 } assert.deepEqual(suggestions[0].transform, expectedTransform); }); diff --git a/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js b/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js index 4217aeb69b..9aa9cd8015 100644 --- a/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js +++ b/web/src/test/auto/headless/engine/interfaces/prediction/predictionContext.tests.js @@ -161,15 +161,21 @@ describe("PredictionContext", () => { assert.equal(updateFake.callCount, 3); suggestions = updateFake.thirdCall.args[0]; + // Note: this unit test was originally written with auto-correct on! + // #11941 was written 2024-07-25 (added unit test for auto-correction method) + // #12169 was written 2024-08-14, which is what added THIS unit test. + // This does re-use the apply-revert oriented mocking. // Should skip the (second) "apple", "apply", "apps" round, as it became outdated // by its following request before its response could be received. - assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['“apple”', 'applied']); - assert.equal(suggestions.find((obj) => obj.tag == 'keep').displayAs, '“apple”'); + assert.deepEqual(suggestions.map((obj) => obj.displayAs), ['applied']); // '“apple”' included with auto-correct enabled. + // Is not displayed; we only display it if auto-correct is on, as 'applied' would be automatic then. + assert.equal(predictiveContext.keepSuggestion.displayAs, '“apple”'); + // assert.equal(suggestions.find((obj) => obj.tag == 'keep').displayAs, '“apple”'); // with auto-correct enabled. assert.equal(suggestions.find((obj) => obj.transform.deleteLeft != 0).displayAs, 'applied'); // Our reused mocking doesn't directly provide the 'keep' suggestion; we // need to remove it before testing for set equality. - assert.deepEqual(suggestions.splice(1), expected); + assert.deepEqual(suggestions /*.splice(1)*/, expected); }); it('sendUpdateState retrieves the most recent suggestion set', async function() { diff --git a/windows/src/desktop/kmshell/kmshell.dpr b/windows/src/desktop/kmshell/kmshell.dpr index 3726d81cd2..19e22a946c 100644 --- a/windows/src/desktop/kmshell/kmshell.dpr +++ b/windows/src/desktop/kmshell/kmshell.dpr @@ -43,7 +43,6 @@ uses InterfaceHotkeys in '..\..\global\delphi\general\InterfaceHotkeys.pas', utilsystem in '..\..\..\..\common\windows\delphi\general\utilsystem.pas', Upload_Settings in '..\..\..\..\common\windows\delphi\general\Upload_Settings.pas', - UfrmOnlineUpdateNewVersion in 'main\UfrmOnlineUpdateNewVersion.pas' {frmOnlineUpdateNewVersion}, OnlineUpdateCheck in 'main\OnlineUpdateCheck.pas', utilxml in '..\..\..\..\common\windows\delphi\general\utilxml.pas', UfrmInstallKeyboardFromWeb in 'install\UfrmInstallKeyboardFromWeb.pas' {frmInstallKeyboardFromWeb}, @@ -77,7 +76,6 @@ uses UserMessages in '..\..\..\..\common\windows\delphi\general\UserMessages.pas', UILanguages in 'util\UILanguages.pas', UfrmKeyboardOptions in 'main\UfrmKeyboardOptions.pas' {frmKeyboardOptions}, - UfrmOnlineUpdateIcon in 'main\UfrmOnlineUpdateIcon.pas' {frmOnlineUpdateIcon}, KeymanTrayIcon in '..\..\engine\keyman\KeymanTrayIcon.pas', UImportOlderVersionKeyboards10 in 'main\UImportOlderVersionKeyboards10.pas', VisualKeyboard in '..\..\..\..\common\windows\delphi\visualkeyboard\VisualKeyboard.pas', @@ -177,7 +175,15 @@ uses Keyman.Configuration.System.HttpServer.App.TextEditorFonts in 'startup\help\Keyman.Configuration.System.HttpServer.App.TextEditorFonts.pas', Keyman.Configuration.System.HttpServer.App.Locale in 'web\Keyman.Configuration.System.HttpServer.App.Locale.pas', Keyman.System.AndroidStringToKeymanLocaleString in '..\..\..\..\common\windows\delphi\general\Keyman.System.AndroidStringToKeymanLocaleString.pas', - Keyman.Configuration.System.Main in 'main\Keyman.Configuration.System.Main.pas'; + Keyman.Configuration.System.Main in 'main\Keyman.Configuration.System.Main.pas', + UpdateXMLRenderer in 'render\UpdateXMLRenderer.pas', + Keyman.System.UpdateCheckStorage in 'main\Keyman.System.UpdateCheckStorage.pas', + Keyman.System.RemoteUpdateCheck in 'main\Keyman.System.RemoteUpdateCheck.pas', + Keyman.System.UpdateStateMachine in 'main\Keyman.System.UpdateStateMachine.pas', + Keyman.System.DownloadUpdate in 'main\Keyman.System.DownloadUpdate.pas', + Keyman.System.ExecutionHistory in '..\..\..\..\common\windows\delphi\general\Keyman.System.ExecutionHistory.pas', + Keyman.Configuration.UI.UfrmStartInstallNow in 'main\Keyman.Configuration.UI.UfrmStartInstallNow.pas' {frmInstallNow}, + Keyman.Configuration.UI.UfrmStartInstall in 'main\Keyman.Configuration.UI.UfrmStartInstall.pas' {frmStartInstall}; {$R VERSION.RES} {$R manifest.res} diff --git a/windows/src/desktop/kmshell/kmshell.dproj b/windows/src/desktop/kmshell/kmshell.dproj index d5d3922e2a..2bb3c24512 100644 --- a/windows/src/desktop/kmshell/kmshell.dproj +++ b/windows/src/desktop/kmshell/kmshell.dproj @@ -171,9 +171,6 @@ - -
frmOnlineUpdateNewVersion
-
@@ -224,9 +221,6 @@
frmKeyboardOptions
- -
frmOnlineUpdateIcon
-
@@ -354,6 +348,20 @@ + + + + + + + +
frmInstallNow
+ dfm +
+ +
frmStartInstall
+ dfm +
Cfg_2 @@ -415,13 +423,19 @@ False - + kmshell.rsm true - + + + kmshell.exe + true + + + kmshell.exe true @@ -1228,6 +1242,7 @@ + False 12 diff --git a/windows/src/desktop/kmshell/locale/es-ES/strings.xml b/windows/src/desktop/kmshell/locale/es-ES/strings.xml index 5ffb5fd25c..af321bec5c 100644 --- a/windows/src/desktop/kmshell/locale/es-ES/strings.xml +++ b/windows/src/desktop/kmshell/locale/es-ES/strings.xml @@ -295,6 +295,10 @@ Simular AltGr con Ctrl+Alt + + Permitir el modificador derecho para atajos + + Tratar teclas muertas de hardware como claves simples @@ -420,6 +424,14 @@ Copyright © SIL Internacional. Todos los derechos reservados. + + Creado por %1$s + + + + Copyright ©️ %1$s. Todos los derechos reservados. + + Versión @@ -609,13 +621,16 @@ - Keyman Iniciar Keyman + + Inicio %1$s + + Salir @@ -657,7 +672,6 @@ - Keyman @@ -781,7 +795,6 @@ - Keyman @@ -904,6 +917,10 @@ Seleccione un teclado Keyman para encontrar fuentes relacionadas. + + + + No se encontró ninguna fuente como sugerencia para el teclado %1$s. diff --git a/windows/src/desktop/kmshell/main/BackgroundUpdateStateDiagram.md b/windows/src/desktop/kmshell/main/BackgroundUpdateStateDiagram.md new file mode 100644 index 0000000000..34383de72e --- /dev/null +++ b/windows/src/desktop/kmshell/main/BackgroundUpdateStateDiagram.md @@ -0,0 +1,10 @@ +``` mermaid +stateDiagram + [*] --> Idle + Idle --> UpdateAvailable + UpdateAvailable --> Downloading + Downloading --> Installing + Downloading --> WaitingRestart + WaitingRestart --> Installing + Installing --> Idle +``` diff --git a/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstall.dfm b/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstall.dfm new file mode 100644 index 0000000000..b7766d09bc --- /dev/null +++ b/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstall.dfm @@ -0,0 +1,50 @@ +object frmStartInstall: TfrmStartInstall + Left = 0 + Top = 0 + BorderIcons = [biSystemMenu] + BorderStyle = bsDialog + Caption = 'Keyman Update' + ClientHeight = 142 + ClientWidth = 322 + Color = clBtnFace + Font.Charset = DEFAULT_CHARSET + Font.Color = clWindowText + Font.Height = -11 + Font.Name = 'Tahoma' + Font.Style = [] + OldCreateOrder = False + Position = poScreenCenter + PixelsPerInch = 96 + TextHeight = 13 + object lblInstallUpdate: TLabel + Left = 72 + Top = 48 + Width = 180 + Height = 19 + Caption = 'Keyman update available.' + Font.Charset = DEFAULT_CHARSET + Font.Color = clWindowText + Font.Height = -16 + Font.Name = 'Tahoma' + Font.Style = [] + ParentFont = False + end + object cmdInstall: TButton + Left = 140 + Top = 104 + Width = 75 + Height = 25 + Caption = 'Install' + ModalResult = 1 + TabOrder = 0 + end + object cmdLater: TButton + Left = 234 + Top = 104 + Width = 75 + Height = 25 + Caption = 'Close' + ModalResult = 8 + TabOrder = 1 + end +end diff --git a/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstall.pas b/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstall.pas new file mode 100644 index 0000000000..5d3b11767a --- /dev/null +++ b/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstall.pas @@ -0,0 +1,39 @@ +{ + Keyman is copyright (C) SIL Global. MIT License. + + // TODO: #12887 Localise all the labels and captions. +} +unit Keyman.Configuration.UI.UfrmStartInstall; +interface + +uses + System.Classes, + System.SysUtils, + System.Variants, + Vcl.Controls, + Vcl.Dialogs, + Vcl.ExtCtrls, + Vcl.Forms, + Vcl.Graphics, + Vcl.StdCtrls, + Winapi.Messages, + Winapi.Windows, + UfrmKeymanBase, + UserMessages; + +type + TfrmStartInstall = class(TfrmKeymanBase) + cmdInstall: TButton; + cmdLater: TButton; + lblInstallUpdate: TLabel; + private + public + end; + + +implementation + +{$R *.dfm} + + +end. diff --git a/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstallNow.dfm b/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstallNow.dfm new file mode 100644 index 0000000000..8c0e467033 --- /dev/null +++ b/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstallNow.dfm @@ -0,0 +1,51 @@ +object frmStartInstallNow: TfrmStartInstallNow + Left = 0 + Top = 0 + BorderIcons = [biSystemMenu] + BorderStyle = bsDialog + Caption = 'Keyman Update' + ClientHeight = 164 + ClientWidth = 354 + Color = clBtnFace + Font.Charset = DEFAULT_CHARSET + Font.Color = clWindowText + Font.Height = -11 + Font.Name = 'Tahoma' + Font.Style = [] + OldCreateOrder = False + Position = poScreenCenter + PixelsPerInch = 96 + TextHeight = 13 + object lblUpdateMessage: TLabel + Left = 32 + Top = 48 + Width = 290 + Height = 41 + Caption = 'Your computer will be restarted if you update now.' + Font.Charset = DEFAULT_CHARSET + Font.Color = clWindowText + Font.Height = -16 + Font.Name = 'Tahoma' + Font.Style = [] + ParentFont = False + WordWrap = True + end + object cmdInstall: TButton + Left = 147 + Top = 120 + Width = 75 + Height = 25 + Caption = 'Update now' + ModalResult = 1 + TabOrder = 0 + end + object cmdLater: TButton + Left = 247 + Top = 120 + Width = 75 + Height = 25 + Caption = 'Close' + ModalResult = 8 + TabOrder = 1 + end +end diff --git a/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstallNow.pas b/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstallNow.pas new file mode 100644 index 0000000000..ce3fd29da0 --- /dev/null +++ b/windows/src/desktop/kmshell/main/Keyman.Configuration.UI.UfrmStartInstallNow.pas @@ -0,0 +1,39 @@ +{ + Keyman is copyright (C) SIL Global. MIT License. + + // TODO: #12887 Localise all the labels and captions. +} +unit Keyman.Configuration.UI.UfrmStartInstallNow; +interface + +uses + System.Classes, + System.SysUtils, + System.Variants, + Vcl.Controls, + Vcl.Dialogs, + Vcl.ExtCtrls, + Vcl.Forms, + Vcl.Graphics, + Vcl.StdCtrls, + Winapi.Messages, + Winapi.Windows, + UfrmKeymanBase, + UserMessages; + +type + TfrmStartInstallNow = class(TfrmKeymanBase) + cmdInstall: TButton; + cmdLater: TButton; + lblUpdateMessage: TLabel; + private + public + end; + +implementation + +{$R *.dfm} + + + +end. diff --git a/windows/src/desktop/kmshell/main/Keyman.System.DownloadUpdate.pas b/windows/src/desktop/kmshell/main/Keyman.System.DownloadUpdate.pas new file mode 100644 index 0000000000..40cc86b3b0 --- /dev/null +++ b/windows/src/desktop/kmshell/main/Keyman.System.DownloadUpdate.pas @@ -0,0 +1,186 @@ +(* + * Keyman is copyright (C) SIL Global. MIT License. + *) +unit Keyman.System.DownloadUpdate; + +interface +uses + System.Classes, + System.SysUtils, + Sentry.Client, + httpuploader, + Keyman.System.KeymanSentryClient, + Keyman.System.UpdateCheckResponse, + KeymanPaths; + +type + TDownloadUpdateParams = record + TotalSize: Integer; + TotalDownloads: Integer; + StartPosition: Integer; + end; + + TDownloadUpdate = class + private + FShowErrors: Boolean; + FDownload: TDownloadUpdateParams; + (** + * + * Performs updates download in the background. + * @params SavePath The path where the downloaded files will be saved. + * + *@returns A Boolean value indicating the overall result of the + * download process. + *) + function DoDownloadUpdates(SavePath: string; Params: TUpdateCheckResponse): Boolean; + + public + + constructor Create; + destructor Destroy; override; + + + function DownloadUpdates : Boolean; + // TODO: #12888 verify filesizes match the ucr metadata so we know we don't have partial downloads. + //function VerifyAllFilesDownloaded : Boolean; + property ShowErrors: Boolean read FShowErrors write FShowErrors; + + end; + +implementation + + +uses + System.StrUtils, + System.Types, + ErrorControlledRegistry, + GlobalProxySettings, + keymanapi_TLB, + KeymanVersion, + Keyman.System.UpdateCheckStorage, + KLog, + kmint, + OnlineUpdateCheckMessages, + RegistryKeys, + Upload_Settings, + utilkmshell; + +procedure ErrorLogMessage(ErrorLogMessage: string); +begin + KL.Log(ErrorLogMessage); + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + ErrorLogMessage); +end; + +constructor TDownloadUpdate.Create; +begin + inherited Create; + + FShowErrors := True; + KL.Log('TDownloadUpdate.Create'); +end; + +destructor TDownloadUpdate.Destroy; +begin + inherited Destroy; +end; + +function TDownloadUpdate.DoDownloadUpdates(SavePath: string; Params: TUpdateCheckResponse): Boolean; +var + i : Integer; + http: THttpUploader; + fs: TFileStream; + + function DownloadFile(const url, savepath: string): Boolean; + begin + try + http := THttpUploader.Create(nil); + try + http.Proxy.Server := GetProxySettings.Server; + http.Proxy.Port := GetProxySettings.Port; + http.Proxy.Username := GetProxySettings.Username; + http.Proxy.Password := GetProxySettings.Password; + http.Request.Agent := API_UserAgent; + + http.Request.SetURL(url); + http.Upload; + if http.Response.StatusCode = 200 then + begin + fs := TFileStream.Create(savepath, fmCreate); + try + fs.Write(http.Response.PMessageBody^, http.Response.MessageBodyLength); + finally + fs.Free; + end; + Result := True; + end + else // I2742 + begin + // If it fails we set to false but will try the other files + Exit(False); + end; + finally + http.Free; + end; + except + on E:EHTTPUploader do + begin + if (E.ErrorCode = 12007) or (E.ErrorCode = 12029) + then ErrorLogMessage(S_OnlineUpdate_UnableToContact) + else ErrorLogMessage(WideFormat(S_OnlineUpdate_UnableToContact_Error, [E.Message])); + Result := False; + end; + end; + end; + +begin + Result := False; + + FDownload.TotalSize := 0; + FDownload.TotalDownloads := 0; + + // Keyboard Packages + FDownload.StartPosition := 0; + for i := 0 to High(Params.Packages) do + begin + Inc(FDownload.TotalDownloads); + Inc(FDownload.TotalSize, Params.Packages[i].DownloadSize); + Params.Packages[i].SavePath := SavePath + Params.Packages[i].FileName; + if not DownloadFile(Params.Packages[i].DownloadURL, Params.Packages[i].SavePath) then // I2742 + begin + Params.Packages[i].Install := False; // Download failed but install other files + end; + FDownload.StartPosition := FDownload.StartPosition + Params.Packages[i].DownloadSize; + end; + + // Keyman Installer + Inc(FDownload.TotalDownloads); + Inc(FDownload.TotalSize, Params.InstallSize); + if not DownloadFile(Params.InstallURL, SavePath + Params.FileName) then // I2742 + begin + ErrorLogMessage('DoDownloadUpdates Failed to download' + Params.InstallURL); + end + else + begin + // If installer has downloaded that is success even + // if zero packages were downloaded. + Result := True; + end; +end; + +function TDownloadUpdate.DownloadUpdates: Boolean; +var + DownloadBackGroundSavePath : String; + ucr: TUpdateCheckResponse; +begin + DownloadBackGroundSavePath := IncludeTrailingPathDelimiter(TKeymanPaths.KeymanUpdateCachePath); + if TUpdateCheckStorage.LoadUpdateCacheData(ucr) then + begin + Result := DoDownloadUpdates(DownloadBackGroundSavePath, ucr); + KL.Log('DownloadUpdates.DownloadUpdates: DownloadResult = '+IntToStr(Ord(Result))); + end + else + Result := False; +end; + +end. diff --git a/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas b/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas new file mode 100644 index 0000000000..55dde4eb1a --- /dev/null +++ b/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas @@ -0,0 +1,261 @@ +(** + * Keyman is copyright (C) SIL International. MIT License. + * + * Keyman.System.RemoteUpdateCheck: Checks for keyboard package and Keyman + * for Windows updates. +*) +unit Keyman.System.RemoteUpdateCheck; // I3306 + +interface + +uses + System.Classes, + System.SysUtils, + KeymanPaths, + httpuploader, + Keyman.System.UpdateCheckResponse; + +const + CheckPeriod: Integer = 7; // Days between checking for updates + +type + ERemoteUpdateCheck = class(Exception); + + TRemoteUpdateCheckResult = (wucUnknown, wucSuccess, wucNoUpdates, wucFailure, wucOffline); + + TRemoteUpdateCheckDownloadParams = record + TotalSize: Integer; + TotalDownloads: Integer; + StartPosition: Integer; + end; + + TRemoteUpdateCheck = class + private + FForce: Boolean; + FRemoteResult: TRemoteUpdateCheckResult; + FErrorMessage: string; + FShowErrors: Boolean; + (** + * Performs an online query of both the main keyman package and + * the keyboard packages. It utilizes the kmcom API to retrieve the current + * packages. The function then performs an HTTP request to query the remote + * versions of these packages. + * The resulting information is stored in the TUpdateCheckResponse + * variable and seralized to disk. + * + * @returns A TBackgroundUpdateResult indicating the result of the update + * check. + *) + function DoRun: TRemoteUpdateCheckResult; + public + + constructor Create(AForce: Boolean); + destructor Destroy; override; + function Run: TRemoteUpdateCheckResult; + property ShowErrors: Boolean read FShowErrors write FShowErrors; + end; + +(** + * This function checks if a week or CheckPeriod time has passed since the last + * update check. + * + * @returns True if it has been longer then CheckPeriod time since last update +*) +function ConfigCheckContinue: Boolean; + +implementation + +uses + System.WideStrUtils, + System.Win.Registry, + Winapi.Windows, + Winapi.WinINet, + Sentry.Client, + + GlobalProxySettings, + KLog, + keymanapi_TLB, + KeymanVersion, + Keyman.System.KeymanSentryClient, + Keyman.System.UpdateCheckStorage, + kmint, + ErrorControlledRegistry, + RegistryKeys, + Upload_Settings, + + OnlineUpdateCheckMessages; + +{ TRemoteUpdateCheck } + +constructor TRemoteUpdateCheck.Create(AForce: Boolean); +begin + inherited Create; + + FShowErrors := True; + FRemoteResult := wucUnknown; + + FForce := AForce; + + KL.Log('TRemoteUpdateCheck.Create'); +end; + +destructor TRemoteUpdateCheck.Destroy; +begin + if (FErrorMessage <> '') and FShowErrors then + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + '"+FErrorMessage+"'); + + KL.Log('TRemoteUpdateCheck.Destroy: FErrorMessage = ' + FErrorMessage); + KL.Log('TRemoteUpdateCheck.Destroy: FRemoteResult = ' + + IntToStr(Ord(FRemoteResult))); + + inherited Destroy; +end; + +function TRemoteUpdateCheck.Run: TRemoteUpdateCheckResult; +begin + Result := DoRun; + FRemoteResult := Result; +end; + +function TRemoteUpdateCheck.DoRun: TRemoteUpdateCheckResult; +var + flags: DWord; + i: Integer; + ucr: TUpdateCheckResponse; + pkg: IKeymanPackage; + Registry: TRegistryErrorControlled; + http: THttpUploader; + proceed: Boolean; +begin + { FProxyHost := ''; + FProxyPort := 0; } + + { Check if user is currently online } + if not InternetGetConnectedState(@flags, 0) then + begin + Result := wucOffline; + Exit; + end; + + proceed := ConfigCheckContinue; + if not proceed and not FForce then + begin + Result := wucNoUpdates; + Exit; + end; + + try + http := THttpUploader.Create(nil); + try + http.Fields.Add('version', ansistring(CKeymanVersionInfo.Version)); + http.Fields.Add('tier', ansistring(CKeymanVersionInfo.Tier)); + if FForce then + http.Fields.Add('manual', '1') + else + http.Fields.Add('manual', '0'); + + for i := 0 to kmcom.Packages.Count - 1 do + begin + pkg := kmcom.Packages[i]; + + // Due to limitations in PHP parsing of query string parameters names with + // space or period, we need to split the parameters up. The legacy pattern + // is still supported on the server side. Relates to #4886. + http.Fields.Add(ansistring('packageid_' + IntToStr(i)), + ansistring(pkg.ID)); + http.Fields.Add(ansistring('packageversion_' + IntToStr(i)), + ansistring(pkg.Version)); + pkg := nil; + end; + + http.Proxy.Server := GetProxySettings.Server; + http.Proxy.Port := GetProxySettings.Port; + http.Proxy.Username := GetProxySettings.Username; + http.Proxy.Password := GetProxySettings.Password; + + http.Request.HostName := API_Server; + http.Request.Protocol := API_Protocol; + http.Request.UrlPath := API_Path_UpdateCheck_Windows; + // OnStatus := + http.Upload; + if http.Response.StatusCode = 200 then + begin + if ucr.Parse(http.Response.MessageBodyAsString, 'bundle', CKeymanVersionInfo.Version) then + begin + TUpdateCheckStorage.SaveUpdateCacheData(ucr); + Result := wucSuccess; + end + else + begin + FErrorMessage := ucr.ErrorMessage; + Result := wucFailure; + end; + end + else + raise ERemoteUpdateCheck.Create('Error '+IntToStr(http.Response.StatusCode)); + finally + http.Free; + end; + except + on E: EHTTPUploader do + begin + if (E.ErrorCode = 12007) or (E.ErrorCode = 12029) then + FErrorMessage := S_OnlineUpdate_UnableToContact + else + FErrorMessage := WideFormat(S_OnlineUpdate_UnableToContact_Error, + [E.Message]); + Result := wucFailure; + end; + on E: Exception do + begin + FErrorMessage := E.Message; + Result := wucFailure; + end; + end; + + Registry := TRegistryErrorControlled.Create; // I2890 + try + if Registry.OpenKey(SRegKey_KeymanDesktop_CU, True) then + Registry.WriteDateTime(SRegValue_LastUpdateCheckTime, Now); + finally + Registry.Free; + end; +end; + +function ConfigCheckContinue: Boolean; +var + Registry: TRegistryErrorControlled; +begin + { Verify that it has been at least CheckPeriod days since last update check } + Result := False; + try + Registry := TRegistryErrorControlled.Create; // I2890 + try + if Registry.OpenKeyReadOnly(SRegKey_KeymanDesktop_CU) then + begin + if Registry.ValueExists(SRegValue_CheckForUpdates) and + not Registry.ReadBool(SRegValue_CheckForUpdates) then + begin + Exit; + end; + if (not Registry.ValueExists(SRegValue_LastUpdateCheckTime)) or + ((Now - Registry.ReadDateTime(SRegValue_LastUpdateCheckTime)) > CheckPeriod) then + begin + Result := True; + end; + end; + finally + Registry.Free; + end; + except + on E: ERegistryException do + begin + Result := False; + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + E.Message); + end; + end; +end; + +end. diff --git a/windows/src/desktop/kmshell/main/Keyman.System.UpdateCheckStorage.pas b/windows/src/desktop/kmshell/main/Keyman.System.UpdateCheckStorage.pas new file mode 100644 index 0000000000..a7a822df7c --- /dev/null +++ b/windows/src/desktop/kmshell/main/Keyman.System.UpdateCheckStorage.pas @@ -0,0 +1,82 @@ +unit Keyman.System.UpdateCheckStorage; + +interface + +uses + Keyman.System.UpdateCheckResponse; + +type + TUpdateCheckStorage = class sealed + private + class function MetadataFilename: string; static; + public + class function HasUpdates: Boolean; static; + class function LoadUpdateCacheData(var data: TUpdateCheckResponse): Boolean; static; + class procedure SaveUpdateCacheData(const data: TUpdateCheckResponse); static; + class function HasKeyboardPackages(const data: TUpdateCheckResponse): Boolean; static; + class function HasKeymanInstallFile(const data: TUpdateCheckResponse): Boolean; static; + end; + +implementation + +uses + System.SysUtils, + System.RegularExpressions, + + KeymanPaths, + KeymanVersion; + +{ TUpdateCheckStorage } + +class function TUpdateCheckStorage.MetadataFilename: string; +begin + Result := TKeymanPaths.KeymanUpdateCachePath(TKeymanPaths.S_UpdateCache_Metadata); +end; + +class procedure TUpdateCheckStorage.SaveUpdateCacheData( + const data: TUpdateCheckResponse); +begin + ForceDirectories(TKeymanPaths.KeymanUpdateCachePath); + data.SaveToFile(MetadataFilename); +end; + +class function TUpdateCheckStorage.HasUpdates: Boolean; +begin + Result := FileExists(MetadataFilename); +end; + +class function TUpdateCheckStorage.LoadUpdateCacheData(var data: TUpdateCheckResponse): Boolean; +begin + Result := + HasUpdates and + data.LoadFromFile(MetadataFilename, 'bundle', CKeymanVersionInfo.Version); +end; + +class function TUpdateCheckStorage.HasKeyboardPackages(const data: TUpdateCheckResponse): Boolean; +var + i : Integer; + fileName : string; + KeyboardRegex: TRegEx; +begin + Result := False; + KeyboardRegex := TRegEx.Create('\.k..$'); + for i := 0 to High(data.Packages) do + begin + fileName := data.Packages[i].FileName; + if KeyboardRegex.IsMatch(fileName) then + Result := True; + end; +end; + +class function TUpdateCheckStorage.HasKeymanInstallFile(const data: TUpdateCheckResponse): Boolean; +var + fileExtension: string; +begin + fileExtension := LowerCase(ExtractFileExt(data.FileName)); + if fileExtension = '.exe' then + Result := True + else + Result := False +end; + +end. diff --git a/windows/src/desktop/kmshell/main/Keyman.System.UpdateStateMachine.pas b/windows/src/desktop/kmshell/main/Keyman.System.UpdateStateMachine.pas new file mode 100644 index 0000000000..725fae67e6 --- /dev/null +++ b/windows/src/desktop/kmshell/main/Keyman.System.UpdateStateMachine.pas @@ -0,0 +1,1140 @@ +(* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Notes: For the state diagram in mermaid ../BackgroundUpdateStateDiagram.md +*) +unit Keyman.System.UpdateStateMachine; + +interface + +uses + System.SysUtils, + System.Types, + System.TypInfo, + Sentry.Client, + + KeymanPaths, + Keyman.System.ExecutionHistory, + Keyman.System.UpdateCheckResponse, + utilkmshell; + +type + EUpdateStateMachine = class(Exception); + + TUpdateState = (usIdle, usUpdateAvailable, usDownloading, usWaitingRestart, + usInstalling); + + // Forward declaration + TUpdateStateMachine = class; + + { State Classes Update } + + TStateClass = class of TState; + + TState = class abstract + private + bucStateContext: TUpdateStateMachine; + procedure ChangeState(newState: TStateClass); + + public + constructor Create(Context: TUpdateStateMachine); + procedure Enter; virtual; abstract; + procedure Exit; virtual; abstract; + procedure HandleCheck; virtual; abstract; + function HandleKmShell: Integer; virtual; abstract; + procedure HandleDownload; virtual; abstract; + procedure HandleAbort; virtual; abstract; + procedure HandleInstallNow; virtual; abstract; + procedure HandleInstallPackages; virtual; + procedure HandleFirstRun; virtual; + end; + + { This class also controls the state flow see + ../BackgroundUpdateStateDiagram.md } + TUpdateStateMachine = class + private + FForce: Boolean; + FAutomaticUpdate: Boolean; + FErrorMessage: string; + FShowErrors: Boolean; + + CurrentState: TState; + // State object for performance (could lazy create?) + + FStateInstance: array [TUpdateState] of TState; + + function GetState: TStateClass; + procedure SetState(const Value: TStateClass); + procedure SetStateOnly(const enumState: TUpdateState); + function ConvertStateToEnum(const StateClass: TStateClass): TUpdateState; + function IsCurrentStateAssigned: Boolean; + procedure RemoveCachedFiles; + + function SetRegistryState(Update: TUpdateState): Boolean; + function GetAutomaticUpdates: Boolean; + function SetApplyNow(Value: Boolean): Boolean; + function GetApplyNow: Boolean; + + protected + property State: TStateClass read GetState write SetState; + + public + constructor Create(AForce: Boolean); + destructor Destroy; override; + + procedure HandleCheck; + function HandleKmShell: Integer; + procedure HandleDownload; + procedure HandleAbort; + procedure HandleInstallNow; + procedure HandleInstallPackages; + procedure HandleFirstRun; + function CurrentStateName: string; + (** + * Checks if Keyman is the WaitingRestartState and that + * Keyman has not run in this Windows session. + * The sole purpose is for the calling code then produce + * a UI to confirm the user wants to continue install. + * + * @returns True if the Keyman is ready to install. + *) + function ReadyToInstall: Boolean; + + property ShowErrors: Boolean read FShowErrors write FShowErrors; + function CheckRegistryState: TUpdateState; + + end; + +implementation + +uses + + System.Win.Registry, + Winapi.Windows, + Winapi.WinINet, + ErrorControlledRegistry, + + GlobalProxySettings, + kmint, + keymanapi_TLB, + Keyman.System.KeymanSentryClient, + Keyman.System.DownloadUpdate, + Keyman.System.RemoteUpdateCheck, + Keyman.System.UpdateCheckStorage, + KLog, + RegistryKeys, + utilexecute, + utiluac; + +const + SPackageUpgradeFilename = 'upgrade_packages.inf'; + kmShellContinue = 0; + kmShellExit = 1; + + { State Class Memebers } + +constructor TState.Create(Context: TUpdateStateMachine); +begin + inherited Create; + bucStateContext := Context; +end; + +procedure TState.ChangeState(newState: TStateClass); +begin + bucStateContext.State := newState; +end; + +type + + // Derived classes for each state + IdleState = class(TState) + public + procedure Enter; override; + procedure Exit; override; + procedure HandleCheck; override; + function HandleKmShell: Integer; override; + procedure HandleDownload; override; + procedure HandleAbort; override; + procedure HandleInstallNow; override; + end; + + UpdateAvailableState = class(TState) + private + procedure StartDownloadProcess; + public + procedure Enter; override; + procedure Exit; override; + procedure HandleCheck; override; + function HandleKmShell: Integer; override; + procedure HandleDownload; override; + procedure HandleAbort; override; + procedure HandleInstallNow; override; + end; + + DownloadingState = class(TState) + private + function DownloadUpdatesBackground: Boolean; + procedure Enter; override; + procedure Exit; override; + procedure HandleCheck; override; + function HandleKmShell: Integer; override; + procedure HandleDownload; override; + procedure HandleAbort; override; + procedure HandleInstallNow; override; + end; + + WaitingRestartState = class(TState) + public + procedure Enter; override; + procedure Exit; override; + procedure HandleCheck; override; + function HandleKmShell: Integer; override; + procedure HandleDownload; override; + procedure HandleAbort; override; + procedure HandleInstallNow; override; + end; + + InstallingState = class(TState) + private + + (** + * Installs the Keyman setup file using separate shell. + * + * @params SavePath The path to the downloaded files. + * + * @returns True if the installation is successful, False otherwise. + *) + + function DoInstallKeyman: Boolean; overload; + + (** + * Installs the Keyman Keyboard files using separate shell. + * + * @params SavePath The path to the downloaded files. + * + * @returns True if the installation is successful, False otherwise. + *) + + function DoInstallPackages(Params: TUpdateCheckResponse): Boolean; + function DoInstallPackage(PackageFileName: String): Boolean; + procedure LaunchInstallPackageProcess; + + public + procedure Enter; override; + procedure Exit; override; + procedure HandleCheck; override; + function HandleKmShell: Integer; override; + procedure HandleDownload; override; + procedure HandleAbort; override; + procedure HandleInstallNow; override; + procedure HandleInstallPackages; override; + procedure HandleFirstRun; override; + end; + + { TUpdateStateMachine } + +constructor TUpdateStateMachine.Create(AForce: Boolean); +begin + inherited Create; + FShowErrors := True; + + FForce := AForce; + FAutomaticUpdate := GetAutomaticUpdates; + + FStateInstance[usIdle] := IdleState.Create(Self); + FStateInstance[usUpdateAvailable] := UpdateAvailableState.Create(Self); + FStateInstance[usDownloading] := DownloadingState.Create(Self); + FStateInstance[usWaitingRestart] := WaitingRestartState.Create(Self); + FStateInstance[usInstalling] := InstallingState.Create(Self); + + // Check the Registry setting. + SetStateOnly(CheckRegistryState); +end; + +destructor TUpdateStateMachine.Destroy; +var + lpState: TUpdateState; +begin + if (FErrorMessage <> '') and FShowErrors then + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + '"+FErrorMessage+"'); + + for lpState := Low(TUpdateState) to High(TUpdateState) do + begin + FreeAndNil(FStateInstance[lpState]); + end; + + // TODO: #10210 TODO: epic-windows-update remove debugging comments throughout this Unit. + + inherited Destroy; +end; + +function TUpdateStateMachine.SetRegistryState(Update: TUpdateState): Boolean; +var + UpdateStr: string; + Registry: TRegistryErrorControlled; +begin + Result := False; + Registry := TRegistryErrorControlled.Create; + + try + Registry.RootKey := HKEY_CURRENT_USER; + + if not Registry.OpenKey(SRegKey_KeymanEngine_CU, True) then + begin + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Failed to open registry key: "' + SRegKey_KeymanEngine_CU + '"'); + Exit; + end; + + try + UpdateStr := GetEnumName(TypeInfo(TUpdateState), Ord(Update)); + Registry.WriteString(SRegValue_Update_State, UpdateStr); + Result := True; + except + on E: ERegistryException do + begin + TKeymanSentryClient.ReportHandledException(E, + 'Failed to write install state machine state'); + end; + end; + + finally + Registry.Free; + end; + +end; + +function TUpdateStateMachine.CheckRegistryState: TUpdateState; +var + UpdateState: TUpdateState; + Registry: TRegistryErrorControlled; + StateValue: string; + EnumValue: Integer; +begin + // Default to Idle state if any issues occur + UpdateState := usIdle; + Registry := TRegistryErrorControlled.Create; + + try + Registry.RootKey := HKEY_CURRENT_USER; + if Registry.OpenKeyReadOnly(SRegKey_KeymanEngine_CU) and + Registry.ValueExists(SRegValue_Update_State) then + begin + try + StateValue := Registry.ReadString(SRegValue_Update_State); + EnumValue := GetEnumValue(TypeInfo(TUpdateState), StateValue); + + // Bounds Check EnumValue against TUpdateState + if (EnumValue >= Ord(Low(TUpdateState))) and + (EnumValue <= Ord(High(TUpdateState))) then + UpdateState := TUpdateState(EnumValue) + else + UpdateState := usIdle; // Default if out of bounds + except + on E: ERegistryException do + begin + TKeymanSentryClient.ReportHandledException(E, + 'Failed to read install state machine state'); + UpdateState := usIdle; + end; + end; + end; + finally + Registry.Free; + end; + + Result := UpdateState; +end; + +function TUpdateStateMachine.GetAutomaticUpdates: Boolean; // I2329 +var + Registry: TRegistryErrorControlled; + +begin + // check the registry value + Registry := TRegistryErrorControlled.Create; // I2890 + try + Registry.RootKey := HKEY_CURRENT_USER; + try + Result := not Registry.OpenKeyReadOnly(SRegKey_KeymanEngine_CU) or + not Registry.ValueExists(SRegValue_AutomaticUpdates) or + Registry.ReadBool(SRegValue_AutomaticUpdates); + except + on E: ERegistryException do + begin + TKeymanSentryClient.ReportHandledException(E, + 'Failed to read automatic updates'); + Result := False; + end; + end; + finally + Registry.Free; + end; +end; + +function TUpdateStateMachine.SetApplyNow(Value: Boolean): Boolean; +var + Registry: TRegistryErrorControlled; +begin + Result := False; + Registry := TRegistryErrorControlled.Create; + + try + Registry.RootKey := HKEY_CURRENT_USER; + if not Registry.OpenKey(SRegKey_KeymanEngine_CU, True) then + begin + Exit; + end; + try + Registry.WriteBool(SRegValue_ApplyNow, Value); + Result := True; + except + on E: ERegistryException do + begin + TKeymanSentryClient.ReportHandledException(E, + 'Failed to write "apply now"'); + end; + end; + finally + Registry.Free; + end; +end; + +function TUpdateStateMachine.GetApplyNow: Boolean; +var + Registry: TRegistryErrorControlled; +begin + // check the registry value + Registry := TRegistryErrorControlled.Create; + try + Registry.RootKey := HKEY_CURRENT_USER; + try + Result := Registry.OpenKeyReadOnly(SRegKey_KeymanEngine_CU) and + Registry.ValueExists(SRegValue_ApplyNow) and + Registry.ReadBool(SRegValue_ApplyNow); + except + on E: ERegistryException do + begin + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Failed to read registry: ' + E.Message); + Result := False; + end; + end; + finally + Registry.Free; + end; +end; + +function TUpdateStateMachine.GetState: TStateClass; +begin + if Assigned(CurrentState) then + Result := TStateClass(CurrentState.ClassType) + else + begin + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Error CurrentState was uninitiallised'); + Result := nil; + end; +end; + +procedure TUpdateStateMachine.SetState(const Value: TStateClass); +begin + if Assigned(CurrentState) then + begin + CurrentState.Exit; + end; + + SetStateOnly(ConvertStateToEnum(Value)); + + if Assigned(CurrentState) then + begin + CurrentState.Enter; + end + else + begin + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Set CurrentState was failed'); + end; + +end; + +procedure TUpdateStateMachine.SetStateOnly(const enumState: TUpdateState); +begin + CurrentState := FStateInstance[enumState]; +end; + +function TUpdateStateMachine.ConvertStateToEnum(const StateClass: TStateClass) + : TUpdateState; +begin + if StateClass = IdleState then + Result := usIdle + else if StateClass = UpdateAvailableState then + Result := usUpdateAvailable + else if StateClass = DownloadingState then + Result := usDownloading + else if StateClass = WaitingRestartState then + Result := usWaitingRestart + else if StateClass = InstallingState then + Result := usInstalling + else + begin + Result := usIdle; + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Unknown State Machine class'); + end; +end; + +function TUpdateStateMachine.IsCurrentStateAssigned: Boolean; +begin + if Assigned(CurrentState) then + Result := True + else + begin + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Error CurrentState was uninitiallised'); + Result := False; + end; +end; + +procedure TUpdateStateMachine.RemoveCachedFiles; +var + SavePath: string; + FileName: String; + FileNames: TStringDynArray; +begin + SavePath := IncludeTrailingPathDelimiter(TKeymanPaths.KeymanUpdateCachePath); + GetFileNamesInDirectory(SavePath, FileNames); + for FileName in FileNames do + begin + System.SysUtils.DeleteFile(FileName); + end; +end; + +procedure TUpdateStateMachine.HandleCheck; +begin + if not IsCurrentStateAssigned then + Exit; + CurrentState.HandleCheck; +end; + +function TUpdateStateMachine.HandleKmShell: Integer; +begin + if not IsCurrentStateAssigned then + Exit(kmShellContinue); + Result := CurrentState.HandleKmShell; +end; + +procedure TUpdateStateMachine.HandleDownload; +begin + if not IsCurrentStateAssigned then + Exit; + CurrentState.HandleDownload; +end; + +procedure TUpdateStateMachine.HandleAbort; +begin + if not IsCurrentStateAssigned then + Exit; + CurrentState.HandleAbort; +end; + +procedure TUpdateStateMachine.HandleInstallNow; +begin + if not IsCurrentStateAssigned then + Exit; + CurrentState.HandleInstallNow; +end; + +procedure TUpdateStateMachine.HandleInstallPackages; +begin + CurrentState.HandleInstallPackages; +end; + +procedure TUpdateStateMachine.HandleFirstRun; +begin + CurrentState.HandleFirstRun; +end; + +function TUpdateStateMachine.CurrentStateName: string; +begin + if not IsCurrentStateAssigned then + Exit('Undefined'); + Result := CurrentState.ClassName; +end; + +function TUpdateStateMachine.ReadyToInstall: Boolean; +begin + if not IsCurrentStateAssigned then + Exit(False); + if (CurrentState is WaitingRestartState) and not HasKeymanRun then + Result := True + else + Result := False; +end; + +// base implmentation to be overiden + +procedure TState.HandleInstallPackages; +begin + // Do Nothing +end; + +procedure TState.HandleFirstRun; +begin + // If Handle First run hits base implementation + // something is wrong. + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Handle first run called in state:"' + Self.ClassName + '"'); + bucStateContext.RemoveCachedFiles; + ChangeState(IdleState); +end; + +{ IdleState } + +procedure IdleState.Enter; +begin + // Enter UpdateAvailableState + bucStateContext.SetRegistryState(usIdle); +end; + +procedure IdleState.Exit; +begin + +end; + +procedure IdleState.HandleCheck; +var + CheckForUpdates: TRemoteUpdateCheck; + Result: TRemoteUpdateCheckResult; +begin + + CheckForUpdates := TRemoteUpdateCheck.Create(True); + try + Result := CheckForUpdates.Run; + finally + CheckForUpdates.Free; + end; + + { Response OK and Update is available } + if Result = wucSuccess then + begin + ChangeState(UpdateAvailableState); + end; + // else staty in idle state +end; + +function IdleState.HandleKmShell; +var + CheckForUpdates: TRemoteUpdateCheck; + UpdateCheckResult: TRemoteUpdateCheckResult; +begin + // Remote manages the last check time therefore + // we will allow it to return early if it hasn't reached + // the configured time between checks. + CheckForUpdates := TRemoteUpdateCheck.Create(False); + try + UpdateCheckResult := CheckForUpdates.Run; + finally + CheckForUpdates.Free; + end; + { Response OK and Update is available } + if UpdateCheckResult = wucSuccess then + begin + ChangeState(UpdateAvailableState); + end; + Result := kmShellContinue; +end; + +procedure IdleState.HandleDownload; +begin + // Do Nothing +end; + +procedure IdleState.HandleAbort; +begin + // Do Nothing +end; + +procedure IdleState.HandleInstallNow; +begin + // Do Nothing +end; + +{ UpdateAvailableState } + +procedure UpdateAvailableState.StartDownloadProcess; +var + FResult: Boolean; + RootPath: string; +begin + // call separate process + RootPath := ExtractFilePath(ParamStr(0)); + FResult := TUtilExecute.ShellCurrentUser(0, ParamStr(0), + IncludeTrailingPathDelimiter(RootPath), '-bd'); + if not FResult then + begin + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Executing kmshell process to download updated Failed'); + ChangeState(IdleState); + end; +end; + +procedure UpdateAvailableState.Enter; +begin + // Enter UpdateAvailableState + bucStateContext.SetRegistryState(usUpdateAvailable); + if bucStateContext.FAutomaticUpdate then + begin + StartDownloadProcess; + end; +end; + +procedure UpdateAvailableState.Exit; +begin + // Exit UpdateAvailableState +end; + +procedure UpdateAvailableState.HandleCheck; +var + CheckForUpdates: TRemoteUpdateCheck; + Result: TRemoteUpdateCheckResult; +begin + // Check if new updates while in this state + CheckForUpdates := TRemoteUpdateCheck.Create(True); + try + Result := CheckForUpdates.Run; + finally + CheckForUpdates.Free; + end; + if Result <> wucSuccess then + begin + KL.Log('UpdateAvailableState.HandleCheck CheckForUpdates not successful: '+ + GetEnumName(TypeInfo(TUpdateState), Ord(Result))); + end; +end; + +function UpdateAvailableState.HandleKmShell; +begin + if bucStateContext.FAutomaticUpdate then + begin + // we will use a new kmshell process to enable + // the download as background process. + StartDownloadProcess; + end; + Result := kmShellContinue; +end; + +procedure UpdateAvailableState.HandleDownload; +begin + ChangeState(DownloadingState); +end; + +procedure UpdateAvailableState.HandleAbort; +begin + +end; + +procedure UpdateAvailableState.HandleInstallNow; +begin + bucStateContext.SetApplyNow(True); + ChangeState(DownloadingState); +end; + +{ DownloadingState } + +procedure DownloadingState.Enter; +var + DownloadResult: Boolean; + RetryCount: Integer; +begin + // Enter DownloadingState + bucStateContext.SetRegistryState(usDownloading); + + RetryCount := 0; + DownloadResult := False; + + while (not DownloadResult) and (RetryCount < 3) do + begin + DownloadResult := DownloadUpdatesBackground; + if not DownloadResult then + Inc(RetryCount); + end; + + if (not DownloadResult) then + begin + // Failed three times in this process; return to the + // IdleState to wait 'CheckPeriod' before trying again + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Error Updates not downloaded after 3 attempts'); + ChangeState(IdleState); + end + else + begin + if HasKeymanRun then + begin + if bucStateContext.GetApplyNow then + begin + bucStateContext.SetApplyNow(False); + ChangeState(InstallingState); + end + else + ChangeState(WaitingRestartState); + end + else + begin + ChangeState(InstallingState); + end; + end + +end; + +procedure DownloadingState.Exit; +begin + // Exit DownloadingState +end; + +procedure DownloadingState.HandleCheck; +begin + +end; + +function DownloadingState.HandleKmShell; +begin + // Downloading state, in other process, so continue + Result := kmShellContinue; +end; + +procedure DownloadingState.HandleDownload; +begin + // Enter Already Downloading +end; + +procedure DownloadingState.HandleAbort; +begin + // To abort during the downloading +end; + +procedure DownloadingState.HandleInstallNow; +begin + // Already downloading set the registry apply now + bucStateContext.SetApplyNow(True); +end; + +function DownloadingState.DownloadUpdatesBackground: Boolean; +var + DownloadResult: Boolean; + DownloadUpdate: TDownloadUpdate; +begin + DownloadUpdate := TDownloadUpdate.Create; + try + DownloadResult := DownloadUpdate.DownloadUpdates; + Result := DownloadResult; + finally + DownloadUpdate.Free; + end; +end; + +{ WaitingRestartState } + +procedure WaitingRestartState.Enter; +begin + // Enter WaitingRestartState + bucStateContext.SetRegistryState(usWaitingRestart); +end; + +procedure WaitingRestartState.Exit; +begin + // Exit DownloadingState +end; + +procedure WaitingRestartState.HandleCheck; +var + CheckForUpdates: TRemoteUpdateCheck; + Result: TRemoteUpdateCheckResult; +begin + // Check if new updates while in this state + CheckForUpdates := TRemoteUpdateCheck.Create(True); + try + Result := CheckForUpdates.Run; + finally + CheckForUpdates.Free; + end; + { Response OK and go back to update available so files can be downloaded } + if Result = wucSuccess then + begin + ChangeState(UpdateAvailableState); + end; +end; + +function WaitingRestartState.HandleKmShell; +var + ucr: TUpdateCheckResponse; + hasPackages, hasKeymanInstall: Boolean; +begin + // Still can't go if keyman has run + if HasKeymanRun then + begin + Result := kmShellContinue; + end + else + begin + hasPackages := False; + hasKeymanInstall := False; + if (TUpdateCheckStorage.LoadUpdateCacheData(ucr)) then + begin + hasPackages := TUpdateCheckStorage.HasKeyboardPackages(ucr); + hasKeymanInstall := TUpdateCheckStorage.HasKeymanInstallFile(ucr); + end; + if not (hasPackages Or hasKeymanInstall) then + begin + // Return to Idle state and check for Updates state + ChangeState(IdleState); + bucStateContext.CurrentState.HandleCheck; + Result := kmShellExit; + end + else + begin + ChangeState(InstallingState); + Result := kmShellExit; + end; + end; +end; + +procedure WaitingRestartState.HandleDownload; +begin + +end; + +procedure WaitingRestartState.HandleAbort; +begin + ChangeState(UpdateAvailableState); +end; + +procedure WaitingRestartState.HandleInstallNow; +begin + bucStateContext.SetApplyNow(True); + ChangeState(InstallingState); +end; + +// Installing packages needs to be elevated +procedure InstallingState.LaunchInstallPackageProcess; +var + executeResult: Cardinal; +begin + if not kmcom.SystemInfo.IsAdministrator then + begin + if CanElevate then + begin + executeResult := WaitForElevatedConfiguration(0, '-ikp'); + if (executeResult <> 0) then + begin + TKeymanSentryClient.Client.MessageEvent + (Sentry.Client.SENTRY_LEVEL_ERROR, + 'Executing kmshell process to install keyboard packages failed:"' + + IntToStr(Ord(executeResult)) + '"'); + ChangeState(IdleState); + end; + end + else + begin + // TODO: epic-windows-updates How do we alert the user that package requires a user with admin rights + // ShowMessage('Some of these updates require an Administrator to complete installation. Please login as an Administrator and re-run the update.'); + end; + end + else + begin + HandleInstallPackages; // can install packages straight away + end; +end; + +function InstallingState.DoInstallKeyman: Boolean; +var + FResult: Boolean; + SavePath: String; + fileExt: String; + FileName: String; + FileNames: TStringDynArray; + found: Boolean; +begin + + SavePath := IncludeTrailingPathDelimiter(TKeymanPaths.KeymanUpdateCachePath); + GetFileNamesInDirectory(SavePath, FileNames); + found := False; + for FileName in FileNames do + begin + fileExt := LowerCase(ExtractFileExt(FileName)); + if fileExt = '.exe' then + begin + found := True; + break; + end; + end; + + // switch -au for auto update in silent mode. + // We will need to add the pop up that says install update now yes/no + // This will run the setup executable which will ask for elevated permissions + if found then + FResult := TUtilExecute.Shell(0, SavePath + ExtractFileName(FileName), + '', '-au') + else + FResult := False; + + if not FResult then + begin + bucStateContext.RemoveCachedFiles; + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Executing kmshell process to install failed:"' + + IntToStr(Ord(FResult)) + '"'); + ChangeState(IdleState); + end; + + Result := FResult; +end; + +function InstallingState.DoInstallPackage(PackageFileName: String): Boolean; +var + FPackage: IKeymanPackageFile2; +begin + Result := True; + FPackage := kmcom.Packages.GetPackageFromFile(PackageFileName) + as IKeymanPackageFile2; + FPackage.Install2(True); + // Force overwrites existing package and leaves most settings for it intact + FPackage := nil; + + kmcom.Refresh; + kmcom.Apply; + + System.SysUtils.DeleteFile(PackageFileName); + +end; + +function InstallingState.DoInstallPackages + (Params: TUpdateCheckResponse): Boolean; +var + i: Integer; + SavePath: String; + PackageFullPath: String; +begin + SavePath := IncludeTrailingPathDelimiter(TKeymanPaths.KeymanUpdateCachePath); + for i := 0 to High(Params.Packages) do + begin + PackageFullPath := SavePath + Params.Packages[i].FileName; + if not FileExists(PackageFullPath) then + begin + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'File does not exist:"' + PackageFullPath + '"'); + Continue; + end; + if not DoInstallPackage(PackageFullPath) then // I2742 + begin + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'Installing Package failed"' + PackageFullPath + '"'); + end; + end; + Result := True; +end; + +procedure InstallingState.Enter; +var + ucr: TUpdateCheckResponse; + hasPackages, hasKeymanInstall: Boolean; +begin + + hasPackages := False; + hasKeymanInstall := False; + bucStateContext.SetRegistryState(usInstalling); + + if (TUpdateCheckStorage.LoadUpdateCacheData(ucr)) then + begin + hasPackages := TUpdateCheckStorage.HasKeyboardPackages(ucr); + hasKeymanInstall := TUpdateCheckStorage.HasKeymanInstallFile(ucr); + end; + { Notes: The reason packages (keyboards) is installed first is + because we are trying to reduce the number of times the user has + to be asked to elevate to admin or restart. Keyboard installation always + needs elevation, when we do that and execute kmshell as an elevated process + we can then launch the Keyman installer and it will not need + to ask for elevation. } + if hasPackages then + begin + LaunchInstallPackageProcess; + Exit; + end; + // If no packages then install Keyman now + if hasKeymanInstall then + begin + DoInstallKeyman; + Exit; + end; + // unexpected: should have had either packages or a keyman file + bucStateContext.RemoveCachedFiles; + ChangeState(IdleState); +end; + +procedure InstallingState.Exit; +begin + +end; + +procedure InstallingState.HandleCheck; +begin + +end; + +function InstallingState.HandleKmShell; +begin + // Result = exit straight away as we are installing (MSI installer) + // need to just do a no-op keyman will it maybe using kmshell to install + // packages. + Result := kmShellContinue; +end; + +procedure InstallingState.HandleDownload; +begin + +end; + +procedure InstallingState.HandleAbort; +begin + // To late as MSI is installing +end; + +procedure InstallingState.HandleInstallNow; +begin + // Do Nothing. Need the UI to let user know installation in progress OR +end; + +procedure InstallingState.HandleInstallPackages; +var + ucr: TUpdateCheckResponse; + hasKeymanInstall : Boolean; +begin + TUpdateCheckStorage.LoadUpdateCacheData(ucr); + hasKeymanInstall := TUpdateCheckStorage.HasKeymanInstallFile(ucr); + // This event should only be reached in elevated process if not then + // move on to just installing Keyman + if not kmcom.SystemInfo.IsAdministrator then + begin + if hasKeymanInstall then + DoInstallKeyman; + Exit; + end; + + if (TUpdateCheckStorage.LoadUpdateCacheData(ucr)) then + begin + DoInstallPackages(ucr); + end; + + if hasKeymanInstall then + DoInstallKeyman; +end; + +procedure InstallingState.HandleFirstRun; +begin + bucStateContext.RemoveCachedFiles; + ChangeState(IdleState); +end; + +end. diff --git a/windows/src/desktop/kmshell/main/OnlineUpdateCheck.pas b/windows/src/desktop/kmshell/main/OnlineUpdateCheck.pas index 15fb06f4e9..ba5d835a17 100644 --- a/windows/src/desktop/kmshell/main/OnlineUpdateCheck.pas +++ b/windows/src/desktop/kmshell/main/OnlineUpdateCheck.pas @@ -98,11 +98,11 @@ type FShowErrors: Boolean; FDownload: TOnlineUpdateCheckDownloadParams; + FCheckOnly: Boolean; function DownloadUpdates: Boolean; procedure DoDownloadUpdates(AOwner: TfrmDownloadProgress; var Result: Boolean); function DoRun: TOnlineUpdateCheckResult; - procedure ShowUpdateForm; procedure ShutDown; procedure DownloadUpdatesHTTPStatus(Sender: THTTPUploader; const Message: string; Position, Total: Int64); // I2855 @@ -112,7 +112,9 @@ type public public - constructor Create(AOwner: TCustomForm; AForce, ASilent: Boolean); + function ResponseToParams(const ucr: TUpdateCheckResponse): TOnlineUpdateCheckParams; + + constructor Create(AOwner: TCustomForm; AForce, ASilent: Boolean; ACheckOnly: Boolean = False); destructor Destroy; override; function Run: TOnlineUpdateCheckResult; property ShowErrors: Boolean read FShowErrors write FShowErrors; @@ -146,6 +148,7 @@ uses KLog, keymanapi_TLB, KeymanVersion, + Keyman.System.UpdateCheckStorage, kmint, ErrorControlledRegistry, RegistryKeys, @@ -153,8 +156,6 @@ uses utildir, utilexecute, OnlineUpdateCheckMessages, - UfrmOnlineUpdateIcon, - UfrmOnlineUpdateNewVersion, utilkmshell, utilsystem, utiluac, @@ -165,7 +166,7 @@ const { TOnlineUpdateCheck } -constructor TOnlineUpdateCheck.Create(AOwner: TCustomForm; AForce, ASilent: Boolean); +constructor TOnlineUpdateCheck.Create(AOwner: TCustomForm; AForce, ASilent: Boolean; ACheckOnly: Boolean); begin inherited Create; @@ -176,6 +177,7 @@ begin FSilent := ASilent; FForce := AForce; + FCheckOnly := ACheckOnly; KL.Log('TOnlineUpdateCheck.Create'); end; @@ -388,86 +390,6 @@ begin end; end; -procedure TOnlineUpdateCheck.ShowUpdateForm; -var - i: Integer; - FRequiresAdmin: Boolean; - FOwnerHandle: THandle; -begin - if Assigned(FOwner) - then FOwnerHandle := FOwner.Handle - else FOwnerHandle := Application.Handle; - - { We have an update available } - with OnlineUpdateNewVersion(FOwner) do - try - Params := Self.FParams; - if ShowModal <> mrYes then - begin - Self.FParams.Result := oucUnknown; - Self.FErrorMessage := ''; - Exit; - end; - - Self.FParams := Params; - finally - Free; - end; - - if not DownloadUpdates then - begin - Self.FParams.Result := oucUnknown; // I2742 - Exit; - end - else - begin - if not kmcom.SystemInfo.IsAdministrator then - begin - FRequiresAdmin := FParams.Keyman.Install; - for i := 0 to High(FParams.Packages) do - if FParams.Packages[i].Install then - begin - FRequiresAdmin := True; - Break; - end; - end - else - FRequiresAdmin := False; - - if FRequiresAdmin then - begin - if CanElevate then - begin - SavePackageUpgradesToDownloadTempPath; - if WaitForElevatedConfiguration(FOwnerHandle, '-ou "'+DownloadTempPath+'"', not FParams.Keyman.Install) <> 0 then // I2513 - FParams.Result := oucFailure - else if FParams.Keyman.Install then - FParams.Result := oucShutDown - else - FParams.Result := oucSuccess; - end - else - begin - ShowMessage('Some of these updates require an Administrator to complete installation. Please login as an Administrator and re-run the update.'); - FParams.Result := oucFailure; - end; - end - else - begin - FParams.Result := oucSuccess; - for i := 0 to High(FParams.Packages) do - if FParams.Packages[i].Install then - if not DoInstallPackage(FParams.Packages[i]) then FParams.Result := oucFailure; - - if FParams.Keyman.Install then - begin - DoInstallKeyman; - FParams.Result := oucShutDown; - end; - end; - end; -end; - procedure TOnlineUpdateCheck.ShutDown; begin if Assigned(Application) then @@ -477,10 +399,9 @@ end; function TOnlineUpdateCheck.DoRun: TOnlineUpdateCheckResult; var flags: DWord; - i, n: Integer; - pkg: IKeymanPackage; - j: Integer; + i: Integer; ucr: TUpdateCheckResponse; + pkg: IKeymanPackage; begin {FProxyHost := ''; FProxyPort := 0;} @@ -567,54 +488,9 @@ begin begin if ucr.Parse(Response.MessageBodyAsString, 'bundle', CKeymanVersionInfo.Version) then begin - SetLength(FParams.Packages,0); - for i := Low(ucr.Packages) to High(ucr.Packages) do - begin - n := kmcom.Packages.IndexOf(ucr.Packages[i].ID); - if n >= 0 then - begin - pkg := kmcom.Packages[n]; - j := Length(FParams.Packages); - SetLength(FParams.Packages, j+1); - FParams.Packages[j].NewID := ucr.Packages[i].NewID; - FParams.Packages[j].ID := ucr.Packages[i].ID; - FParams.Packages[j].Description := ucr.Packages[i].Name; - FParams.Packages[j].OldVersion := pkg.Version; - FParams.Packages[j].NewVersion := ucr.Packages[i].NewVersion; - FParams.Packages[j].DownloadSize := ucr.Packages[i].DownloadSize; - FParams.Packages[j].DownloadURL := ucr.Packages[i].DownloadURL; - FParams.Packages[j].FileName := ucr.Packages[i].FileName; - pkg := nil; - end - else - FErrorMessage := 'Unable to find package '+ucr.Packages[i].ID; - end; - - case ucr.Status of - ucrsNoUpdate: - begin - FErrorMessage := ucr.ErrorMessage; - end; - ucrsUpdateReady: - begin - FParams.Keyman.OldVersion := ucr.CurrentVersion; - FParams.Keyman.NewVersion := ucr.NewVersion; - FParams.Keyman.DownloadURL := ucr.InstallURL; - FParams.Keyman.DownloadSize := ucr.InstallSize; - FParams.Keyman.FileName := ucr.FileName; - end; - end; - - if (Length(FParams.Packages) > 0) or (FParams.Keyman.DownloadURL <> '') then - begin - if not FSilent then - ShowUpdateForm - else - begin - ShowUpdateIcon; - end; - Result := FParams.Result; - end; + ResponseToParams(ucr); + TUpdateCheckStorage.SaveUpdateCacheData(ucr); + Result := FParams.Result; end else begin @@ -651,6 +527,52 @@ begin end; end; +function TOnlineUpdateCheck.ResponseToParams(const ucr: TUpdateCheckResponse): TOnlineUpdateCheckParams; +var + i, j, n: Integer; + pkg: IKeymanPackage; +begin + SetLength(FParams.Packages,0); + for i := Low(ucr.Packages) to High(ucr.Packages) do + begin + n := kmcom.Packages.IndexOf(ucr.Packages[i].ID); + if n >= 0 then + begin + pkg := kmcom.Packages[n]; + j := Length(FParams.Packages); + SetLength(FParams.Packages, j+1); + FParams.Packages[j].NewID := ucr.Packages[i].NewID; + FParams.Packages[j].ID := ucr.Packages[i].ID; + FParams.Packages[j].Description := ucr.Packages[i].Name; + FParams.Packages[j].OldVersion := pkg.Version; + FParams.Packages[j].NewVersion := ucr.Packages[i].NewVersion; + FParams.Packages[j].DownloadSize := ucr.Packages[i].DownloadSize; + FParams.Packages[j].DownloadURL := ucr.Packages[i].DownloadURL; + FParams.Packages[j].FileName := ucr.Packages[i].FileName; + pkg := nil; + end + else + FErrorMessage := 'Unable to find package '+ucr.Packages[i].ID; + end; + + case ucr.Status of + ucrsNoUpdate: + begin + FErrorMessage := ucr.ErrorMessage; + end; + ucrsUpdateReady: + begin + FParams.Keyman.OldVersion := ucr.CurrentVersion; + FParams.Keyman.NewVersion := ucr.NewVersion; + FParams.Keyman.DownloadURL := ucr.InstallURL; + FParams.Keyman.DownloadSize := ucr.InstallSize; + FParams.Keyman.FileName := ucr.FileName; + end; + end; + + Result := FParams; +end; + procedure OnlineUpdateAdmin(OwnerForm: TCustomForm; Path: string); var Package: TOnlineUpdateCheckParamsPackage; diff --git a/windows/src/desktop/kmshell/main/UImportOlderVersionSettings.pas b/windows/src/desktop/kmshell/main/UImportOlderVersionSettings.pas index 007d854797..48a8293f50 100644 --- a/windows/src/desktop/kmshell/main/UImportOlderVersionSettings.pas +++ b/windows/src/desktop/kmshell/main/UImportOlderVersionSettings.pas @@ -1,18 +1,18 @@ (* Name: UImportOlderVersionSettings Copyright: Copyright (C) 2003-2017 SIL International. - Documentation: - Description: + Documentation: + Description: Create Date: 22 Feb 2011 Modified Date: 3 Jun 2014 Authors: mcdurdin - Related Files: - Dependencies: + Related Files: + Dependencies: - Bugs: - Todo: - Notes: + Bugs: + Todo: + Notes: History: 22 Feb 2011 - mcdurdin - I2651 - Install does not set desired default options 22 Feb 2011 - mcdurdin - I2753 - Firstrun crashes because start with windows and auto update check options are set in Engine instead of Desktop 03 May 2011 - mcdurdin - I2890 - Record diagnostic data when encountering registry errors @@ -24,7 +24,7 @@ unit UImportOlderVersionSettings; interface -function FirstRunInstallDefaults(DoDefaults,DoStartWithWindows,DoCheckForUpdates: Boolean; FDisablePackages, FDefaultUILanguage: string; DoAutomaticallyReportUsage: Boolean): Boolean; // I2753 +function FirstRunInstallDefaults(DoDefaults,DoStartWithWindows,DoCheckForUpdates,DoAutomaticUpdates: Boolean; FDisablePackages, FDefaultUILanguage: string; DoAutomaticallyReportUsage: Boolean): Boolean; // I2753 implementation @@ -41,14 +41,24 @@ uses keymanapi_TLB, ErrorControlledRegistry, RegistryKeys, + Keyman.System.UpdateStateMachine, UImportOlderKeyboardUtils; -function FirstRunInstallDefaults(DoDefaults,DoStartWithWindows,DoCheckForUpdates: Boolean; FDisablePackages, FDefaultUILanguage: string; DoAutomaticallyReportUsage: Boolean): Boolean; // I2753 +function FirstRunInstallDefaults(DoDefaults,DoStartWithWindows,DoCheckForUpdates,DoAutomaticUpdates: Boolean; FDisablePackages, FDefaultUILanguage: string; DoAutomaticallyReportUsage: Boolean): Boolean; // I2753 var n, I: Integer; v: Integer; p: string; + UpdateSM : TUpdateStateMachine; begin + // send event to statemachine (should result in setting state to idle) + UpdateSM := TUpdateStateMachine.Create(False); + try + UpdateSM.HandleFirstRun; + finally + UpdateSM.Free; + end; + { Copy over all the user settings and set defaults for version 8.0: http://blog.tavultesoft.com/2011/02/keyman-desktop-80-default-options.html } if DoDefaults then // I2753 @@ -136,6 +146,7 @@ begin if DoStartWithWindows then kmcom.Options['koStartWithWindows'].Value := True; // I2753 if DoCheckForUpdates then kmcom.Options['koCheckForUpdates'].Value := True; // I2753 + if DoAutomaticUpdates then kmcom.Options['koAutomaticUpdate'].Value := True; if DoAutomaticallyReportUsage then kmcom.Options['koAutomaticallyReportUsage'].Value := True; diff --git a/windows/src/desktop/kmshell/main/UfrmMain.pas b/windows/src/desktop/kmshell/main/UfrmMain.pas index d9a6f8b441..394cb09377 100644 --- a/windows/src/desktop/kmshell/main/UfrmMain.pas +++ b/windows/src/desktop/kmshell/main/UfrmMain.pas @@ -85,6 +85,7 @@ uses Winapi.Windows, keymanapi_TLB, + Sentry.Client, XMLRenderer, KeyboardListXMLRenderer, UfrmKeymanBase, @@ -141,13 +142,14 @@ type procedure Support_Diagnostics; procedure Support_Online; - procedure Support_UpdateCheck; procedure Support_ProxyConfig; procedure Support_ContactSupport(params: TStringList); // I4390 procedure OpenSite(params: TStringList); procedure DoApply; procedure DoRefresh; + procedure Update_CheckNow; + procedure Update_ApplyNow; protected procedure FireCommand(const command: WideString; params: TStringList); override; @@ -184,12 +186,15 @@ uses LanguagesXMLRenderer, MessageIdentifierConsts, MessageIdentifiers, - OnlineUpdateCheck, + Keyman.System.ExecutionHistory, + Keyman.System.KeymanSentryClient, + Keyman.System.UpdateStateMachine, OptionsXMLRenderer, Keyman.Configuration.System.UmodWebHttpServer, Keyman.Configuration.System.HttpServer.App.ConfigMain, Keyman.Configuration.UI.InstallFile, Keyman.Configuration.UI.UfrmSettingsManager, + Keyman.Configuration.UI.UfrmStartInstallNow, RegistryKeys, SupportXMLRenderer, UfrmChangeHotkey, @@ -202,12 +207,14 @@ uses UfrmTextEditor, uninstall, Upload_Settings, + UpdateXMLRenderer, utildir, utilexecute, utilkmshell, utilhttp, utiluac, - utilxml; + utilxml, + KeymanPaths; type PHKL = ^HKL; @@ -301,6 +308,7 @@ begin FXMLRenderers.Add(TOptionsXMLRenderer.Create(FXMLRenderers)); FXMLRenderers.Add(TLanguagesXMLRenderer.Create(FXMLRenderers)); FXMLRenderers.Add(TSupportXMLRenderer.Create(FXMLRenderers)); + FXMLRenderers.Add(TUpdateXMLRenderer.Create(FXMLRenderers)); xml := FXMLRenderers.RenderToString(s); sharedData.Init( @@ -342,9 +350,11 @@ begin else if command = 'support_diagnostics' then Support_Diagnostics else if command = 'support_online' then Support_Online - else if command = 'support_updatecheck' then Support_UpdateCheck else if command = 'support_proxyconfig' then Support_ProxyConfig + else if command = 'update_checknow' then Update_CheckNow + else if command = 'update_applynow' then Update_ApplyNow + else if command = 'contact_support' then Support_ContactSupport(params) // I4390 else if command = 'opensite' then OpenSite(params) @@ -789,29 +799,47 @@ begin end; end; -procedure TfrmMain.Support_UpdateCheck; +procedure TfrmMain.Update_CheckNow; +var + BUpdateSM : TUpdateStateMachine; begin - with TOnlineUpdateCheck.Create(Self, True, False) do + BUpdateSM := TUpdateStateMachine.Create(False); try - case Run of - oucShutDown: - begin - try - if kmcom.Control.IsKeymanRunning then - try - kmcom.Control.StopKeyman; - except - on E:Exception do KL.Log(E.Message); - end; - except - on E:Exception do KL.Log(E.Message); - end; - end; - oucSuccess: - DoRefresh; - end + BUpdateSM.HandleCheck; finally - Free; + BUpdateSM.Free; + end; + DoRefresh; +end; + +procedure TfrmMain.Update_ApplyNow; +var + ShellPath : string; + FResult, InstallNow: Boolean; + frmStartInstallNow: TfrmStartInstallNow; +begin + InstallNow := True; + // Confirm User is ok that this will require a reset + if HasKeymanRun then + begin + frmStartInstallNow := TfrmStartInstallNow.Create(nil); + try + if frmStartInstallNow.ShowModal = mrOk then + InstallNow := True + else + InstallNow := False; + finally + frmStartInstallNow.Free; + end; + end; + + if InstallNow = True then + begin + ShellPath := TKeymanPaths.KeymanDesktopInstallPath(TKeymanPaths.S_KMShell); + FResult := TUtilExecute.Shell(0, ShellPath, '', '-an'); + if not FResult then + TKeymanSentryClient.Client.MessageEvent(Sentry.Client.SENTRY_LEVEL_ERROR, + 'TrmfMain: Shell Execute Update_ApplyNow Failed'); end; end; diff --git a/windows/src/desktop/kmshell/main/initprog.pas b/windows/src/desktop/kmshell/main/initprog.pas index c22591e79c..651387bc47 100644 --- a/windows/src/desktop/kmshell/main/initprog.pas +++ b/windows/src/desktop/kmshell/main/initprog.pas @@ -80,8 +80,12 @@ type fmUninstallPackage, fmRegistryAdd, fmRegistryRemove, fmMain, fmHelp, fmHelpKMShell, fmMigrate, fmSplash, fmStart, - fmUpgradeKeyboards, fmOnlineUpdateCheck,// I2548 - fmOnlineUpdateAdmin, fmTextEditor, + fmUpgradeKeyboards, // I2548 + fmTextEditor, + fmInstallKeyboardPackageAdmin, + fmBackgroundUpdateCheck, + fmBackgroundDownload, + fmApplyInstallNow, fmFirstRun, // I2562 fmKeyboardWelcome, // I2569 fmKeyboardPrint, // I2329 @@ -113,13 +117,14 @@ uses Keyman.Configuration.System.TIPMaintenance, Keyman.Configuration.System.UImportOlderVersionKeyboards11To13, Keyman.Configuration.UI.UfrmSettingsManager, + Keyman.Configuration.UI.UfrmStartInstall, Keyman.System.KeymanStartTask, KeymanPaths, KLog, kmint, KMShellHints, KeymanMutex, - OnlineUpdateCheck, + Keyman.System.RemoteUpdateCheck, RegistryKeys, UfrmBaseKeyboard, UfrmKeymanBase, @@ -141,6 +146,7 @@ uses UpgradeMnemonicLayout, utilfocusappwnd, utilkmshell, + Keyman.System.UpdateStateMachine, KeyboardTIPCheck, @@ -240,7 +246,7 @@ begin else if s = '-uk' then FMode := fmUninstallKeyboard { I1201 - Fix crash uninstalling admin-installed keyboards and packages } else if s = '-ukl' then FMode := fmUninstallKeyboardLanguage // I3624 else if s = '-up' then FMode := fmUninstallPackage { I1201 - Fix crash uninstalling admin-installed keyboards and packages } - else if s = '-ou' then FMode := fmOnlineUpdateAdmin { I1730 - Check update of keyboards (admin elevation) } + else if s = '-ikp' then FMode := fmInstallKeyboardPackageAdmin else if s = '-a' then FMode := fmAbout else if s = '-ra' then FMode := fmRegistryAdd else if s = '-rr' then FMode := fmRegistryRemove @@ -248,7 +254,9 @@ begin else if s = '-?' then FMode := fmHelpKMShell else if s = '-h' then FMode := fmHelp else if s = '-t' then FMode := fmTextEditor - else if s = '-ouc' then FMode := fmOnlineUpdateCheck + else if s = '-buc' then FMode := fmBackgroundUpdateCheck + else if s = '-bd' then FMode := fmBackgroundDownload + else if s = '-an' then FMode := fmApplyInstallNow else if s = '-basekeyboard' then FMode := fmBaseKeyboard // I4169 else if s = '-nowelcome' then FNoWelcome := True else if s = '-kw' then FMode := fmKeyboardWelcome // I2569 @@ -381,6 +389,9 @@ var kdl: IKeymanDefaultLanguage; FIcon: string; FMutex: TKeymanMutex; // I2720 + BUpdateSM : TUpdateStateMachine; + frmStartInstall: TfrmStartInstall; + UserCanceled : Boolean; function FirstKeyboardFileName: WideString; begin if KeyboardFileNames.Count = 0 @@ -428,6 +439,55 @@ begin Exit; end; + BUpdateSM := TUpdateStateMachine.Create(False); + try + if (FMode = fmBackgroundUpdateCheck) then + begin + BUpdateSM.HandleCheck; + Exit; + end + else if (FMode = fmBackgroundDownload) then + begin + BUpdateSM.HandleDownload; + Exit; + end + else if (FMode = fmApplyInstallNow) then + begin + BUpdateSM.HandleInstallNow; + Exit; + end + else if (FMode = fmInstallKeyboardPackageAdmin) then + begin + BUpdateSM.HandleInstallPackages; + Exit; + end + else + begin + // The following logic around the WaitingRestartState should be + // encapsulated in the state machine however as we want separation of + // UI elements from the state machine we have bring some of logic here. + UserCanceled := False; + if BUpdateSM.ReadyToInstall and + (not FSilent and (FMode in [fmStart, fmSplash, fmMain, fmAbout])) then + begin + frmStartInstall := TfrmStartInstall.Create(nil); + try + if frmStartInstall.ShowModal = mrOk then + UserCanceled := False + else + UserCanceled := True + finally + frmStartInstall.Free; + end; + end; + if not UserCanceled and (BUpdateSM.HandleKmShell = 1) then + Exit; + end; + finally + BUpdateSM.Free; + end; + + if not FSilent or (FMode = fmUpgradeMnemonicLayout) then // I4553 begin // Note: will elevate and re-run if required @@ -469,17 +529,6 @@ begin then ExitCode := 0 else ExitCode := 2; - fmOnlineUpdateAdmin: - OnlineUpdateAdmin(nil, FirstKeyboardFileName); - - fmOnlineUpdateCheck: - with TOnlineUpdateCheck.Create(nil, FForce, FSilent) do - try - Run; - finally - Free; - end; - fmUpgradeKeyboards:// I2548 begin if FQuery='13,backup' then @@ -619,6 +668,7 @@ begin Pos('installdefaults', FQuery) > 0, Pos('startwithwindows', FQuery) > 0, Pos('checkforupdates', FQuery) > 0, + Pos('automaticupdates', FQuery) > 0, FDisablePackages, FDefaultUILanguage, Pos('automaticallyreportusage', FQuery) > 0); // I2651, I2753 diff --git a/windows/src/desktop/kmshell/render/UpdateXMLRenderer.pas b/windows/src/desktop/kmshell/render/UpdateXMLRenderer.pas new file mode 100644 index 0000000000..c2f7a2bfc2 --- /dev/null +++ b/windows/src/desktop/kmshell/render/UpdateXMLRenderer.pas @@ -0,0 +1,94 @@ +(* + Name: UpdateXMLRenderer + Copyright: Copyright (C) SIL International. +*) +unit UpdateXMLRenderer; + +interface + +uses + XMLRenderer, + Windows; + +type + TUpdateXMLRenderer = class(TXMLRenderer) + protected + function XMLData: WideString; override; + end; + +implementation + +uses + StrUtils, + SysUtils, + VersionInfo, + kmint, + KeymanVersion, + keymanapi_TLB, + Keyman.System.LocaleStrings, + Keyman.System.UpdateCheckResponse, + Keyman.System.UpdateCheckStorage, + MessageIdentifierConsts, + MessageIdentifiers, + utilxml; + +{ TUpdateXMLRenderer } + +function TUpdateXMLRenderer.XMLData: WideString; +var + xml: string; + ucr: TUpdateCheckResponse; + i, n : Integer; + pkg: IKeymanPackage; +begin + xml := ''; + + if TUpdateCheckStorage.LoadUpdateCacheData(ucr) then + begin + if (ucr.InstallURL <> '') then + begin + xml := xml + + ''+ + '0'+ + IfThen(not kmcom.SystemInfo.IsAdministrator, '')+ + ''+ + ''+xmlencode(TLocaleStrings.MsgFromIdFormat(kmcom, SKUpdate_KeymanText, [ucr.NewVersion]))+''+ + ''+xmlencode(ucr.NewVersion)+''+ + ''+xmlencode(ucr.CurrentVersion)+''+ + ''+xmlencode(Format('%d', [ucr.InstallSize div 1024]))+'KB'+ + ''+xmlencode(ucr.InstallURL)+''+ + ''+ + ''; + end; + + for i := 0 to High(ucr.Packages) do + begin + n := kmcom.Packages.IndexOf(ucr.Packages[i].ID); + if n >= 0 then + begin + pkg := kmcom.Packages[n]; + + xml := xml + + ''+ + ''+IntToStr(i+1)+''+ + IfThen(not kmcom.SystemInfo.IsAdministrator, '')+ + ''+ + ''+xmlencode(TLocaleStrings.MsgFromIdFormat(kmcom, SKUpdate_PackageText, + [ucr.Packages[i].Name, ucr.Packages[i].NewVersion]))+''+ + ''+xmlencode(ucr.Packages[i].NewVersion)+''+ + ''+xmlencode(pkg.version)+''+ + ''+xmlencode(Format('%d', [ucr.Packages[i].DownloadSize div 1024]))+'KB'+ + ''+xmlencode(ucr.Packages[i].DownloadURL)+''+ + ''+ + ''; + pkg := nil; + end; + // else Package not found, skip + end; + end; + + Result := ''+xml+''; +end; + +end. + diff --git a/windows/src/desktop/kmshell/startup/UfrmSplash.pas b/windows/src/desktop/kmshell/startup/UfrmSplash.pas index 1be49479f1..5a6cccd529 100644 --- a/windows/src/desktop/kmshell/startup/UfrmSplash.pas +++ b/windows/src/desktop/kmshell/startup/UfrmSplash.pas @@ -90,7 +90,6 @@ uses MessageIdentifierConsts, MessageIdentifiers, KeymanMutex, - OnlineUpdateCheck, PngImage, ErrorControlledRegistry, RegistryKeys, @@ -268,8 +267,7 @@ begin if kmcom.Options[KeymanOptionName(TUtilKeymanOption.koCheckForUpdates)].Value then begin - if not kmcom.Control.IsOnlineUpdateCheckOpen then - RunConfiguration(0, '-ouc -s'); + RunConfiguration(0, '-buc -s'); end; end; end; diff --git a/windows/src/desktop/kmshell/util/UfrmDownloadProgress.pas b/windows/src/desktop/kmshell/util/UfrmDownloadProgress.pas index 2edcfbbcf7..9b5f779c8f 100644 --- a/windows/src/desktop/kmshell/util/UfrmDownloadProgress.pas +++ b/windows/src/desktop/kmshell/util/UfrmDownloadProgress.pas @@ -1,18 +1,18 @@ (* Name: UfrmDownloadProgress Copyright: Copyright (C) SIL International. - Documentation: - Description: + Documentation: + Description: Create Date: 4 Dec 2006 Modified Date: 18 May 2012 Authors: mcdurdin - Related Files: - Dependencies: + Related Files: + Dependencies: - Bugs: - Todo: - Notes: + Bugs: + Todo: + Notes: History: 04 Dec 2006 - mcdurdin - Initial version 05 Dec 2006 - mcdurdin - Localize caption 15 Jan 2007 - mcdurdin - Use font from locale.xml diff --git a/windows/src/desktop/kmshell/util/utilkmshell.pas b/windows/src/desktop/kmshell/util/utilkmshell.pas index 7485753283..76eb4929dd 100644 --- a/windows/src/desktop/kmshell/util/utilkmshell.pas +++ b/windows/src/desktop/kmshell/util/utilkmshell.pas @@ -33,7 +33,7 @@ unit utilkmshell; // I3306 // I4181 interface uses - System.UITypes, + System.UITypes, System.IOUtils, System.Types, Dialogs, Windows, ComObj, shlobj, controls, sysutils, classes; const @@ -96,6 +96,7 @@ procedure SplitString(const instr: string; var outstr1, outstr2: string; const s function ValidDirectory(const dir: string): string; function GetLongFile(APath:String):String; +procedure GetFileNamesInDirectory(const directoryPath: string; var fileNames: TStringDynArray); function TSFInstalled: Boolean; @@ -540,6 +541,19 @@ begin until Length(APath)=0; end; {Peter Haas} +procedure GetFileNamesInDirectory(const directoryPath: string; var fileNames: TStringDynArray); + +begin + // Check if the directory exists + if TDirectory.Exists(directoryPath) then + begin + // Retrieve file names within the directory + fileNames := TDirectory.GetFiles(directoryPath); + end + else + KL.Log('Directory does not exist.'); +end; + { TString } constructor TString.Create(const AString: string); diff --git a/windows/src/desktop/kmshell/xml/config.css b/windows/src/desktop/kmshell/xml/config.css index 851964112b..e452cca5b8 100644 --- a/windows/src/desktop/kmshell/xml/config.css +++ b/windows/src/desktop/kmshell/xml/config.css @@ -499,6 +499,20 @@ table tr padding: 1px 0 1px 10px; } +.grid_container_update { + display: grid; + position: relative; + grid-template-columns: 50px 1fr 1fr 1fr; + margin: 10px 50px 10px 64px; + /* From https://geary.co/internal-borders-css-grid/ */ + overflow: hidden; + gap: var(--gap); + --gap: 2em; + --line-offset: calc(var(--gap) / 2); + --line-thickness: 1px; + --line-color: black; +} + .grid_container.grid_disabled { opacity: 0.5; @@ -508,11 +522,36 @@ table tr { position: relative; font-size: 12px; - margin: 0px; padding: 1px; margin: 0 0 0 5px; } +.grid_item::before, +.grid_item::after +{ + content: ''; + position: absolute; + background-color: var(--line-color); + z-index: 1; +} + +/* row borders */ +.grid_item::after +{ + inline-size: 100vw; + block-size: var(--line-thickness); + inset-inline-start: 0; + inset-block-start: calc(var(--line-offset) * -1); +} + +/* column borders */ +.grid_item::before +{ + inline-size: var(--line-thickness); + block-size: 100vh; + inset-inline-start: calc(var(--line-offset) * -1); +} + .grid_item_title{ font-weight: bold; } @@ -614,6 +653,7 @@ table tr { display: flex; flex-direction: column; + height: 200px; gap: 4px; } .list_languages_title @@ -622,6 +662,11 @@ table tr } /* keyboard languages */ +.keyboard_language_list +{ + flex-grow:1 +} + .keyboard_language { display: flex; @@ -1093,17 +1138,42 @@ th height: 16px; } -#keepintouch_content { - height: 100%; - overflow: hidden; +.update_title +{ + background: url('keyman-title.png') 96px 13px no-repeat; + height: 60px; } -#keepintouch_frame { - box-sizing: border-box; - width:100%; - height:100%; - border:none; - user-select: none; +.update_title img +{ + margin: 13px 0 0 10px; +} + +.update_edition +{ + float: left; + font-size: 16px; + margin-left: 64px; +} + +#update_status +{ + font-size: 13px; + margin-left: 64px; + margin-top: 30px; +} + +#update_content { + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + padding: 5px 10px 5px 10px; +} + +.update_controls +{ + margin-left: 64px; } /* QRCodes */ @@ -1199,10 +1269,8 @@ th } .list_languages_add { - padding-top: 10px; - position: fixed; - top: 223px; /* Have to use fixed position can't use flex or grid inside a fixed child */ - right: 281px; + padding-bottom: 10px; + align-self: flex-end; } .modify .keyboard_language { diff --git a/windows/src/desktop/kmshell/xml/config.js b/windows/src/desktop/kmshell/xml/config.js index cf761ce6c2..47b66ca89c 100644 --- a/windows/src/desktop/kmshell/xml/config.js +++ b/windows/src/desktop/kmshell/xml/config.js @@ -35,7 +35,7 @@ function windowResize() e = _$('subcontent_pro'); if(e) e.style.height = h; _$('subcontent_support').style.height = h; - _$('subcontent_keepintouch').style.height = h; + _$('subcontent_update').style.height = h; } } diff --git a/windows/src/desktop/kmshell/xml/keyman.xsl b/windows/src/desktop/kmshell/xml/keyman.xsl index 94c7ed7e95..a774e0aa9c 100644 --- a/windows/src/desktop/kmshell/xml/keyman.xsl +++ b/windows/src/desktop/kmshell/xml/keyman.xsl @@ -12,7 +12,7 @@ - + @@ -50,7 +50,7 @@
-
+
diff --git a/windows/src/desktop/kmshell/xml/keyman_keepintouch.xsl b/windows/src/desktop/kmshell/xml/keyman_keepintouch.xsl deleted file mode 100644 index 780166619f..0000000000 --- a/windows/src/desktop/kmshell/xml/keyman_keepintouch.xsl +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - -
- - -
- -
-
- -
-
-
-
\ No newline at end of file diff --git a/windows/src/desktop/kmshell/xml/keyman_keyboardlist.xsl b/windows/src/desktop/kmshell/xml/keyman_keyboardlist.xsl index f61c1b3f7f..5f15a88d0a 100644 --- a/windows/src/desktop/kmshell/xml/keyman_keyboardlist.xsl +++ b/windows/src/desktop/kmshell/xml/keyman_keyboardlist.xsl @@ -394,7 +394,7 @@
-
+
1 diff --git a/windows/src/desktop/kmshell/xml/keyman_menu.xsl b/windows/src/desktop/kmshell/xml/keyman_menu.xsl index 4fc1b0f225..99d5e05fac 100644 --- a/windows/src/desktop/kmshell/xml/keyman_menu.xsl +++ b/windows/src/desktop/kmshell/xml/keyman_menu.xsl @@ -9,20 +9,20 @@ - + - + diff --git a/windows/src/desktop/kmshell/xml/keyman_support.xsl b/windows/src/desktop/kmshell/xml/keyman_support.xsl index 4bf93f89bd..b36a30a0d8 100644 --- a/windows/src/desktop/kmshell/xml/keyman_support.xsl +++ b/windows/src/desktop/kmshell/xml/keyman_support.xsl @@ -49,7 +49,6 @@
  • keyman:link?url=/keyman.com
  • -
  • keyman:link?url=/go//support
  • diff --git a/windows/src/desktop/kmshell/xml/keyman_update.xsl b/windows/src/desktop/kmshell/xml/keyman_update.xsl new file mode 100644 index 0000000000..0daa6ace3b --- /dev/null +++ b/windows/src/desktop/kmshell/xml/keyman_update.xsl @@ -0,0 +1,136 @@ + + + + + + + +
    + + +
    + +
    +
    + +
    + +
    + +
    +   +
    + +
    + + Updates are available which will be applied when Windows is next restarted: + + + No updates are available. + +
    + +
    +
    Select
    +
    +
    +
    + + + +
    + +
    + + + + + + keyman:update_applynow + 220px + + + + + + + 220px + 1 + + + + + + + keyman:update_checknow + 220px + +
    + + + +
    +
    +
    + + + +
    + + javascript:updateTick(""); + Update_ + checked + Update_ + + + Update__RequiresAdmin + +
    + + +
    + +
    +
    + +
    +
    + +
    +
    + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + + +
    +
    +
    + +
    +
    +
    +
    + +
    diff --git a/windows/src/desktop/kmshell/xml/menuframe_update.png b/windows/src/desktop/kmshell/xml/menuframe_update.png new file mode 100644 index 0000000000..5a7bc9d47b Binary files /dev/null and b/windows/src/desktop/kmshell/xml/menuframe_update.png differ diff --git a/windows/src/desktop/kmshell/xml/strings.xml b/windows/src/desktop/kmshell/xml/strings.xml index c8910b55b9..24909d3486 100644 --- a/windows/src/desktop/kmshell/xml/strings.xml +++ b/windows/src/desktop/kmshell/xml/strings.xml @@ -417,6 +417,11 @@ Show welcome screen + + + + Automatically check for updates and download + @@ -586,8 +591,6 @@ Diagnostics - - @@ -712,6 +715,10 @@ keyboard that you use in Windows. Keyman keyboards will adapt automatically to + + + + Update @@ -803,6 +810,16 @@ keyboard that you use in Windows. Keyman keyboards will adapt automatically to + + + + Apply update now + + + + + Check for new updates + diff --git a/windows/src/desktop/setup/RunTools.pas b/windows/src/desktop/setup/RunTools.pas index 341f036c6c..0b654cfeec 100644 --- a/windows/src/desktop/setup/RunTools.pas +++ b/windows/src/desktop/setup/RunTools.pas @@ -82,7 +82,7 @@ type InstallSuccess: Boolean); function InstallMSI(msiLocation: TInstallInfoFileLocation; var InstallDefaults: Boolean; ContinueSetup: Boolean): Boolean; procedure ConfigFirstRun(StartKeyman,StartWithWindows, - CheckForUpdates,StartDisabled,StartWithConfiguration,InstallDefaults, + CheckForUpdates,AutomaticUpdates,StartDisabled,StartWithConfiguration,InstallDefaults, AutomaticallyReportUsage: Boolean); procedure PrepareForReboot(res: Cardinal; InstallDefaults: Boolean); function RestartWindows: Boolean; @@ -101,7 +101,7 @@ type destructor Destroy; override; procedure CheckInternetConnectedState; function DoInstall(Handle: THandle; - StartAfterInstall, StartWithWindows, CheckForUpdates, StartDisabled, + StartAfterInstall, StartWithWindows, CheckForUpdates, AutomaticUpdates, StartDisabled, StartWithConfiguration, InstallDefaults, AutomaticallyReportUsage, ContinueSetup: Boolean): Boolean; procedure LogError(const msg: WideString; ShowDialogIfNotSilent: Boolean = True); procedure LogInfo(const msg: string; ShowDialogIfNotSilent: Boolean = False); @@ -194,7 +194,7 @@ begin end; function TRunTools.DoInstall(Handle: THandle; - StartAfterInstall, StartWithWindows, CheckForUpdates, StartDisabled, + StartAfterInstall, StartWithWindows, CheckForUpdates, AutomaticUpdates, StartDisabled, StartWithConfiguration, InstallDefaults, AutomaticallyReportUsage, ContinueSetup: Boolean): Boolean; var msiLocation: TInstallInfoFileLocation; @@ -224,7 +224,7 @@ begin Exit(False); end; - ConfigFirstRun(StartAfterInstall,StartWithWindows,CheckForUpdates, + ConfigFirstRun(StartAfterInstall,StartWithWindows,CheckForUpdates,AutomaticUpdates, StartDisabled,StartWithConfiguration,InstallDefaults,AutomaticallyReportUsage); Result := True; @@ -588,7 +588,7 @@ begin end; end; -procedure TRunTools.ConfigFirstRun(StartKeyman,StartWithWindows,CheckForUpdates, +procedure TRunTools.ConfigFirstRun(StartKeyman,StartWithWindows,CheckForUpdates,AutomaticUpdates, StartDisabled,StartWithConfiguration,InstallDefaults,AutomaticallyReportUsage: Boolean); var i: Integer; @@ -686,6 +686,7 @@ begin if StartWithWindows then s := s + 'StartWithWindows,'; if CheckForUpdates then s := s + 'CheckForUpdates,'; + if AutomaticUpdates then s := s + 'AutomaticUpdates,'; if AutomaticallyReportUsage then s := s + 'AutomaticallyReportUsage,'; if InstallDefaults then diff --git a/windows/src/desktop/setup/UfrmRunDesktop.pas b/windows/src/desktop/setup/UfrmRunDesktop.pas index 293df6a613..ded19ae75c 100644 --- a/windows/src/desktop/setup/UfrmRunDesktop.pas +++ b/windows/src/desktop/setup/UfrmRunDesktop.pas @@ -113,6 +113,7 @@ type FCanUpgrade9: Boolean; FCanUpgrade10: Boolean; FCheckForUpdates: Boolean; + FAutomaticUpdates: Boolean; FStartAfterInstall: Boolean; FStartWithWindows: Boolean; FAutomaticallyReportUsage: Boolean; @@ -523,7 +524,7 @@ begin SetupMSI; // I2644 if GetRunTools.DoInstall(Handle, FStartAfterInstall, FStartWithWindows, FCheckForUpdates, - FInstallInfo.StartDisabled, FInstallInfo.StartWithConfiguration, FInstallDefaults, + FAutomaticUpdates, FInstallInfo.StartDisabled, FInstallInfo.StartWithConfiguration, FInstallDefaults, FAutomaticallyReportUsage, FContinueSetup) then begin if not Silent and not FStartAfterInstall then // I2610 @@ -1032,6 +1033,7 @@ procedure TfrmRunDesktop.GetDefaultSettings; // I2651 begin FStartWithWindows := True; // I2607 FCheckForUpdates := True; // I2609 + FAutomaticUpdates := True; try with CreateHKCURegistry do // I2749 @@ -1041,6 +1043,8 @@ begin FCheckForUpdates := ValueExists(SRegValue_CheckForUpdates) and ReadBool(SRegValue_CheckForUpdates); FStartWithWindows := ValueExists(SRegValue_UpgradeRunKeyman) or (OpenKeyReadOnly('\' + SRegKey_WindowsRun_CU) and ValueExists(SRegValue_WindowsRun_Keyman)); + FAutomaticUpdates := not ValueExists(SRegValue_AutomaticUpdates) or ReadBool(SRegValue_AutomaticUpdates); + end else if FCanUpgrade10 and OpenKeyReadOnly(SRegKey_KeymanEngine100_ProductOptions_Desktop_CU) then // I4293 begin diff --git a/windows/src/engine/keyman/UfrmKeyman7Main.dfm b/windows/src/engine/keyman/UfrmKeyman7Main.dfm index d04a7f649c..ab0d25d3e5 100644 --- a/windows/src/engine/keyman/UfrmKeyman7Main.dfm +++ b/windows/src/engine/keyman/UfrmKeyman7Main.dfm @@ -27,10 +27,10 @@ object frmKeyman7Main: TfrmKeyman7Main Left = 28 Top = 40 end - object tmrOnlineUpdateCheck: TTimer + object tmrBackgroundUpdateCheck: TTimer Enabled = False Interval = 300000 - OnTimer = tmrOnlineUpdateCheckTimer + OnTimer = tmrBackgroundUpdateCheckTimer Left = 280 Top = 104 end diff --git a/windows/src/engine/keyman/UfrmKeyman7Main.pas b/windows/src/engine/keyman/UfrmKeyman7Main.pas index d8cb3e30e7..c0f6e9d62f 100644 --- a/windows/src/engine/keyman/UfrmKeyman7Main.pas +++ b/windows/src/engine/keyman/UfrmKeyman7Main.pas @@ -198,13 +198,13 @@ type TfrmKeyman7Main = class(TForm) mnu: TPopupMenu; tmrTestKeymanFunctioning: TTimer; - tmrOnlineUpdateCheck: TTimer; + tmrBackgroundUpdateCheck: TTimer; tmrCheckInputPane: TTimer; tmrRefresh: TTimer; procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObject); procedure tmrTestKeymanFunctioningTimer(Sender: TObject); - procedure tmrOnlineUpdateCheckTimer(Sender: TObject); + procedure tmrBackgroundUpdateCheckTimer(Sender: TObject); procedure tmrCheckInputPaneTimer(Sender: TObject); procedure tmrRefreshTimer(Sender: TObject); private @@ -1838,7 +1838,7 @@ begin end; end; -procedure TfrmKeyman7Main.tmrOnlineUpdateCheckTimer(Sender: TObject); +procedure TfrmKeyman7Main.tmrBackgroundUpdateCheckTimer(Sender: TObject); begin with TRegistryErrorControlled.Create do // I2890 try @@ -1846,7 +1846,7 @@ begin begin if ValueExists(SRegValue_CheckForUpdates) and not ReadBool(SRegValue_CheckForUpdates) then Exit; if ValueExists(SRegValue_LastUpdateCheckTime) and (Now - ReadDateTime(SRegValue_LastUpdateCheckTime) < 7) then Exit; - TKeymanDesktopShell.RunKeymanConfiguration('-ouc'); + TKeymanDesktopShell.RunKeymanConfiguration('-buc'); end; finally Free; diff --git a/windows/src/engine/keyman/keyman.dpr b/windows/src/engine/keyman/keyman.dpr index 8685e8f4a6..dadcca01fc 100644 --- a/windows/src/engine/keyman/keyman.dpr +++ b/windows/src/engine/keyman/keyman.dpr @@ -32,7 +32,7 @@ uses UfrmOSKPlugInBase in 'viskbd\UfrmOSKPlugInBase.pas' {frmOSKPlugInBase}, UfrmOSKCharacterMap in 'viskbd\UfrmOSKCharacterMap.pas' {frmOSKCharacterMap}, UfrmOSKEntryHelper in 'viskbd\UfrmOSKEntryHelper.pas' {frmOSKEntryHelper}, - TTInfo in '..\..\..\..\common\windows\delphi\general\TTInfo.pas', + ttinfo in '..\..\..\..\common\windows\delphi\general\ttinfo.pas', UnicodeData in '..\..\..\..\common\windows\delphi\charmap\UnicodeData.pas', CharacterMapSettings in '..\..\..\..\common\windows\delphi\charmap\CharacterMapSettings.pas', CharacterRanges in '..\..\..\..\common\windows\delphi\charmap\CharacterRanges.pas', @@ -112,7 +112,8 @@ uses Sentry.Client.Vcl in '..\..\..\..\common\windows\delphi\ext\sentry\Sentry.Client.Vcl.pas', sentry in '..\..\..\..\common\windows\delphi\ext\sentry\sentry.pas', Keyman.System.KeymanSentryClient in '..\..\..\..\common\windows\delphi\general\Keyman.System.KeymanSentryClient.pas', - Keyman.System.LocaleStrings in '..\..\global\delphi\cust\Keyman.System.LocaleStrings.pas'; + Keyman.System.LocaleStrings in '..\..\global\delphi\cust\Keyman.System.LocaleStrings.pas', + Keyman.System.ExecutionHistory in '..\..\..\..\common\windows\delphi\general\Keyman.System.ExecutionHistory.pas'; {$R ICONS.RES} {$R VERSION.RES} diff --git a/windows/src/engine/keyman/keyman.dproj b/windows/src/engine/keyman/keyman.dproj index 44959854d5..aa70b2ab7b 100644 --- a/windows/src/engine/keyman/keyman.dproj +++ b/windows/src/engine/keyman/keyman.dproj @@ -141,7 +141,7 @@
    frmOSKEntryHelper
    - + @@ -238,6 +238,7 @@ + Cfg_2 @@ -299,21 +300,21 @@ False - + - keyman.exe + .\ true - + keyman.rsm true - + - .\ + keyman.exe true diff --git a/windows/src/engine/keyman/main.pas b/windows/src/engine/keyman/main.pas index 2c019150d7..79db9c8baa 100644 --- a/windows/src/engine/keyman/main.pas +++ b/windows/src/engine/keyman/main.pas @@ -40,9 +40,11 @@ uses System.Win.Registry, GetOsVersion, + Keyman.System.ExecutionHistory, Keyman.System.Security, Keyman.Winapi.VersionHelpers, KeymanVersion, + Klog, RegistryKeys, UfrmKeyman7Main, UserMessages; @@ -76,9 +78,10 @@ var hMutex: Cardinal; begin - if not ValidateParameters(FCommand) then Exit; + RecordKeymanStarted; + hProgramMutex := CreateMutex(nil, False, 'KeymanEXE70'); if hProgramMutex = 0 then begin diff --git a/windows/src/engine/kmcomapi/com/system/keymancontrol.pas b/windows/src/engine/kmcomapi/com/system/keymancontrol.pas index b3f8958596..6b429b985e 100644 --- a/windows/src/engine/kmcomapi/com/system/keymancontrol.pas +++ b/windows/src/engine/kmcomapi/com/system/keymancontrol.pas @@ -429,7 +429,7 @@ end; procedure TKeymanControl.OpenUpdateCheck; begin - RunKeymanConfiguration('-ouc'); + RunKeymanConfiguration('-buc'); end; procedure TKeymanControl.StartKeyman; diff --git a/windows/src/engine/kmcomapi/util/utilkeymanoption.pas b/windows/src/engine/kmcomapi/util/utilkeymanoption.pas index 906e121f07..8a18f3be26 100644 --- a/windows/src/engine/kmcomapi/util/utilkeymanoption.pas +++ b/windows/src/engine/kmcomapi/util/utilkeymanoption.pas @@ -121,7 +121,7 @@ type GroupName: string; end; -const KeymanOptionInfo: array[0..16] of TKeymanOptionInfo = ( // I3331 // I3620 // I4552 +const KeymanOptionInfo: array[0..17] of TKeymanOptionInfo = ( // I3331 // I3620 // I4552 // Global options (opt: koKeyboardHotkeysAreToggle; RegistryName: SRegValue_KeyboardHotkeysAreToggle; OptionType: kotBool; BoolValue: False; GroupName: 'kogGeneral'), @@ -129,7 +129,8 @@ const KeymanOptionInfo: array[0..16] of TKeymanOptionInfo = ( // I3331 // I36 (opt: koAltGrCtrlAlt; RegistryName: SRegValue_AltGrCtrlAlt; OptionType: kotBool; BoolValue: False; GroupName: 'kogGeneral'), (opt: koRightModifierHK; RegistryName: SRegValue_AllowRightModifierHotKey; OptionType: kotBool; BoolValue: False; GroupName: 'kogGeneral'), (opt: koShowHints; RegistryName: SRegValue_EnableHints; OptionType: kotBool; BoolValue: True; GroupName: 'kogGeneral'), - (opt: koBaseLayout; RegistryName: SRegValue_UnderlyingLayout; OptionType: kotLong; IntValue: 0; GroupName: 'kogGeneral'), + (opt: koBaseLayout; RegistryName: SRegValue_UnderlyingLayout; OptionType: kotLong; IntValue: 0; GroupName: 'kogGeneral'), + (opt: koAutomaticUpdate; RegistryName: SRegValue_AutomaticUpdates; OptionType: kotBool; BoolValue: True; GroupName: 'kogGeneral'), (opt: koAutomaticallyReportErrors; RegistryName: SRegValue_AutomaticallyReportErrors; OptionType: kotBool; BoolValue: True; GroupName: 'kogGeneral'), // I4393 (opt: koAutomaticallyReportUsage; RegistryName: SRegValue_AutomaticallyReportUsage; OptionType: kotBool; BoolValue: True; GroupName: 'kogGeneral'), // I4393 diff --git a/windows/src/engine/tsysinfo/si_language.xslt b/windows/src/engine/tsysinfo/si_language.xslt index c3015aec98..2b4973c962 100644 --- a/windows/src/engine/tsysinfo/si_language.xslt +++ b/windows/src/engine/tsysinfo/si_language.xslt @@ -34,6 +34,38 @@ +

    User Profile Options

    + + + + + + + + + + + + + + +
    LanguagesShowAutoCorrectionShowTextPredictionShowCasingShowShiftLockInputMethodOverride
    + +

    BCP47 Codes

    + + + + + + + + + + + + +
    BCP47 CodeTransient LangIdCached Language Name{LangID}:{Keyboard Layout ID}
    +

    Windows Keyboard Hotkeys

    @@ -88,7 +120,7 @@ - + @@ -96,9 +128,9 @@ - + - + @@ -114,6 +146,27 @@ + + + + + + + + + + + + + + + + 0000 @@ -125,7 +178,7 @@ - + @@ -166,7 +219,7 @@ No - + - + diff --git a/windows/src/global/delphi/general/KeymanOptionNames.pas b/windows/src/global/delphi/general/KeymanOptionNames.pas index 73356d19cb..2d4be34c0a 100644 --- a/windows/src/global/delphi/general/KeymanOptionNames.pas +++ b/windows/src/global/delphi/general/KeymanOptionNames.pas @@ -10,6 +10,7 @@ type koRightModifierHK, koReleaseShiftKeysAfterKeyPress, koShowHints, // I1256 + koAutomaticUpdate, // Startup options koTestKeymanFunctioning, koStartWithWindows,
    Keyman system shadow keyboard
    + + + , + +