From 153683cfb007c6066c9dcf71d25afa4c66efa17f Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 20 Aug 2024 15:10:14 +0200 Subject: [PATCH 001/254] fix(core): look for `emcc` instead of `emcc.py` on Linux and Mac `emcc.py` is not marked as executable in emcripten's git repo so the build failed when trying to locate emscripten. However, it turns out that `emcc` is marked as executable, so we use that instead. On Windows however, we still need to use `emcc.py` because otherwise Meson won't detect it as valid compiler. --- core/wasm.build.linux.in | 6 ++--- core/wasm.build.mac.in | 6 ++--- core/wasm.defs.build | 6 ++--- developer/src/kmcmplib/wasm.build.linux.in | 6 ++--- developer/src/kmcmplib/wasm.build.mac.in | 6 ++--- resources/locate_emscripten.inc.sh | 30 +++++++++++++--------- 6 files changed, 33 insertions(+), 27 deletions(-) diff --git a/core/wasm.build.linux.in b/core/wasm.build.linux.in index d449dcf94a0..27cb7ba68fe 100644 --- a/core/wasm.build.linux.in +++ b/core/wasm.build.linux.in @@ -1,4 +1,4 @@ [binaries] -c = ['$EMSCRIPTEN_BASE/emcc.py'] -cpp = ['$EMSCRIPTEN_BASE/em++.py'] -ar = ['$EMSCRIPTEN_BASE/emar.py'] \ No newline at end of file +c = ['$EMSCRIPTEN_BASE/emcc'] +cpp = ['$EMSCRIPTEN_BASE/em++'] +ar = ['$EMSCRIPTEN_BASE/emar'] diff --git a/core/wasm.build.mac.in b/core/wasm.build.mac.in index d449dcf94a0..27cb7ba68fe 100644 --- a/core/wasm.build.mac.in +++ b/core/wasm.build.mac.in @@ -1,4 +1,4 @@ [binaries] -c = ['$EMSCRIPTEN_BASE/emcc.py'] -cpp = ['$EMSCRIPTEN_BASE/em++.py'] -ar = ['$EMSCRIPTEN_BASE/emar.py'] \ No newline at end of file +c = ['$EMSCRIPTEN_BASE/emcc'] +cpp = ['$EMSCRIPTEN_BASE/em++'] +ar = ['$EMSCRIPTEN_BASE/emar'] diff --git a/core/wasm.defs.build b/core/wasm.defs.build index 4a598a1e1c1..f41af337a38 100644 --- a/core/wasm.defs.build +++ b/core/wasm.defs.build @@ -1,7 +1,7 @@ [binaries] -c = ['emcc.py'] -cpp = ['em++.py'] -ar = ['emar.py'] +c = ['emcc'] +cpp = ['em++'] +ar = ['emar'] exe_wrapper = 'node' [properties] diff --git a/developer/src/kmcmplib/wasm.build.linux.in b/developer/src/kmcmplib/wasm.build.linux.in index c5e61a9bda9..27cb7ba68fe 100644 --- a/developer/src/kmcmplib/wasm.build.linux.in +++ b/developer/src/kmcmplib/wasm.build.linux.in @@ -1,4 +1,4 @@ [binaries] -c = ['$EMSCRIPTEN_BASE/emcc.py'] -cpp = ['$EMSCRIPTEN_BASE/em++.py'] -ar = ['$EMSCRIPTEN_BASE/emar.py'] +c = ['$EMSCRIPTEN_BASE/emcc'] +cpp = ['$EMSCRIPTEN_BASE/em++'] +ar = ['$EMSCRIPTEN_BASE/emar'] diff --git a/developer/src/kmcmplib/wasm.build.mac.in b/developer/src/kmcmplib/wasm.build.mac.in index c5e61a9bda9..27cb7ba68fe 100644 --- a/developer/src/kmcmplib/wasm.build.mac.in +++ b/developer/src/kmcmplib/wasm.build.mac.in @@ -1,4 +1,4 @@ [binaries] -c = ['$EMSCRIPTEN_BASE/emcc.py'] -cpp = ['$EMSCRIPTEN_BASE/em++.py'] -ar = ['$EMSCRIPTEN_BASE/emar.py'] +c = ['$EMSCRIPTEN_BASE/emcc'] +cpp = ['$EMSCRIPTEN_BASE/em++'] +ar = ['$EMSCRIPTEN_BASE/emar'] diff --git a/resources/locate_emscripten.inc.sh b/resources/locate_emscripten.inc.sh index be66adfa32f..20923770e2a 100644 --- a/resources/locate_emscripten.inc.sh +++ b/resources/locate_emscripten.inc.sh @@ -2,30 +2,36 @@ # no hashbang for .inc.sh # -# We don't want to rely on emcc.py being on the path, because Emscripten puts far +# We don't want to rely on emcc being on the path, because Emscripten puts far # too many things onto the path (in particular for us, node). # -# The following comment suggests that we don't need emcc.py on the path. +# The following comment suggests that we don't need emcc on the path. # https://github.com/emscripten-core/emscripten/issues/4848#issuecomment-1097357775 # -# So we try and locate emcc.py in common locations ourselves. The search pattern +# So we try and locate emcc in common locations ourselves. The search pattern # is: # # 1. Look for $EMSCRIPTEN_BASE (our primary emscripten variable), which should -# point to the folder that emcc.py is located in -# 2. Look for $EMCC which should point to the emcc.py executable -# 3. Look for emcc.py on the path +# point to the folder that emcc is located in +# 2. Look for $EMCC which should point to the emcc executable +# 3. Look for emcc on the path # locate_emscripten() { + local EMCC_EXECUTABLE + if [[ "${BUILDER_OS}" == "win" ]]; then + EMCC_EXECUTABLE="emcc.py" + else + EMCC_EXECUTABLE="emcc" + fi if [[ -z ${EMSCRIPTEN_BASE+x} ]]; then if [[ -z ${EMCC+x} ]]; then - local EMCC=`which emcc.py` - [[ -z $EMCC ]] && builder_die "locate_emscripten: Could not locate emscripten (emcc.py) on the path or with \$EMCC or \$EMSCRIPTEN_BASE" + local EMCC=$(which ${EMCC_EXECUTABLE}) + [[ -z $EMCC ]] && builder_die "locate_emscripten: Could not locate emscripten (${EMCC_EXECUTABLE}) on the path or with \$EMCC or \$EMSCRIPTEN_BASE" fi - [[ -f $EMCC && ! -x $EMCC ]] && builder_die "locate_emscripten: Variable EMCC ($EMCC) points to emcc.py but it is not executable" - [[ -x $EMCC ]] || builder_die "locate_emscripten: Variable EMCC ($EMCC) does not point to a valid executable emcc.py" + [[ -f $EMCC && ! -x $EMCC ]] && builder_die "locate_emscripten: Variable EMCC ($EMCC) points to ${EMCC_EXECUTABLE} but it is not executable" + [[ -x $EMCC ]] || builder_die "locate_emscripten: Variable EMCC ($EMCC) does not point to a valid executable ${EMCC_EXECUTABLE}" EMSCRIPTEN_BASE="$(dirname "$EMCC")" fi - [[ -f ${EMSCRIPTEN_BASE}/emcc.py && ! -x ${EMSCRIPTEN_BASE}/emcc.py ]] && builder_die "locate_emscripten: Variable EMSCRIPTEN_BASE ($EMSCRIPTEN_BASE) contains emcc.py but it is not executable" - [[ -x ${EMSCRIPTEN_BASE}/emcc.py ]] || builder_die "locate_emscripten: Variable EMSCRIPTEN_BASE ($EMSCRIPTEN_BASE) does not point to emcc.py's folder" + [[ -f ${EMSCRIPTEN_BASE}/${EMCC_EXECUTABLE} && ! -x ${EMSCRIPTEN_BASE}/${EMCC_EXECUTABLE} ]] && builder_die "locate_emscripten: Variable EMSCRIPTEN_BASE ($EMSCRIPTEN_BASE) contains ${EMCC_EXECUTABLE} but it is not executable" + [[ -x ${EMSCRIPTEN_BASE}/${EMCC_EXECUTABLE} ]] || builder_die "locate_emscripten: Variable EMSCRIPTEN_BASE ($EMSCRIPTEN_BASE) does not point to ${EMCC_EXECUTABLE}'s folder" } From 0abc250af38a64b2a3aaa7113a2ca99067fe0bd3 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 20 Aug 2024 12:45:16 +0200 Subject: [PATCH 002/254] feat(web): POC of Core WASM integration into Keyman Web - add temporary function to Core for this POC - add new CoreProcessor to access Keyman Core WASM - add unit tests for new core processor - add code to KeymanEngine and InputProcessor to load the new CoreProcessor - add web server to manual tests and new action `start` to build script This change requires the manual tests to be loaded from a web server instead of loaded as file, because otherwise the wasm code won't be loaded. Currently we always load CoreProcessor. This should be improved in a future change to only load when it is actually needed. Part-of: #11293 --- core/src/meson.build | 38 ++++++++ core/src/wasm.cpp | 92 +++++++++++++++++++ package-lock.json | 2 + resources/build/minimum-versions.inc.sh | 2 +- web/README.md | 5 +- web/build.sh | 8 ++ web/package.json | 14 ++- web/src/app/browser/build.sh | 4 + web/src/app/webview/build.sh | 11 +++ web/src/engine/core-processor/.gitignore | 1 + web/src/engine/core-processor/build.sh | 64 +++++++++++++ .../core-processor/src/core-processor.ts | 30 ++++++ web/src/engine/core-processor/src/index.ts | 1 + web/src/engine/core-processor/tsconfig.json | 13 +++ .../interfaces/src/pathConfiguration.ts | 4 + web/src/engine/main/build.sh | 1 + .../main/src/headless/inputProcessor.ts | 9 +- web/src/engine/main/src/keymanEngine.ts | 2 + .../dom/cases/core-processor/basic.spec.ts | 21 +++++ .../test/auto/dom/web-test-runner.config.mjs | 13 ++- web/src/test/manual/build.sh | 2 +- web/src/tools/testing/test-server/index.cjs | 12 +++ 22 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 core/src/wasm.cpp create mode 100644 web/src/engine/core-processor/.gitignore create mode 100755 web/src/engine/core-processor/build.sh create mode 100644 web/src/engine/core-processor/src/core-processor.ts create mode 100644 web/src/engine/core-processor/src/index.ts create mode 100644 web/src/engine/core-processor/tsconfig.json create mode 100644 web/src/test/auto/dom/cases/core-processor/basic.spec.ts create mode 100644 web/src/tools/testing/test-server/index.cjs diff --git a/core/src/meson.build b/core/src/meson.build index 41c198543cb..564472b7980 100644 --- a/core/src/meson.build +++ b/core/src/meson.build @@ -118,6 +118,7 @@ api_files = files( 'km_core_state_api.cpp', 'km_core_debug_api.cpp', 'km_core_processevent_api.cpp', + 'wasm.cpp', ) core_files = files( @@ -133,6 +134,23 @@ mock_files = files( 'mock/mock_processor.cpp', ) +if cpp_compiler.get_id() == 'emscripten' + host_links = ['--whole-archive', '-sALLOW_MEMORY_GROWTH=1', '-sMODULARIZE=1', '-sEXPORT_ES6', '-sENVIRONMENT=webview', '--embind-emit-tsd', 'core-interface.d.ts', '-sERROR_ON_UNDEFINED_SYMBOLS=0'] + + if cpp_compiler.version().version_compare('>=3.1.44') + # emscripten 3.1.44 removes .asm object and so we need to export `wasmExports` + # #9375; https://github.com/emscripten-core/emscripten/blob/main/ChangeLog.md#3144---072523 + links += ['-sEXPORTED_RUNTIME_METHODS=[\'UTF8ToString\',\'stringToNewUTF8\',\'wasmExports\']'] + else + # emscripten < 3.1.44 does not include `wasmExports` + links += ['-sEXPORTED_RUNTIME_METHODS=[\'UTF8ToString\',\'stringToNewUTF8\']'] + endif + + links += [ + # Forcing inclusion of debug symbols + '-g', '-Wlimited-postlink-optimizations', '--bind'] +endif + lib = library('keymancore', api_files, core_files, @@ -160,3 +178,23 @@ pkg.generate( description: 'Keyman processor for KMN keyboards.', subdirs: headerdirs, libraries: lib) + +if cpp_compiler.get_id() == 'emscripten' + # Build an executable + host = executable('core', + cpp_args: defns, + include_directories: inc, + link_args: links + host_links, + objects: lib.extract_all_objects(recursive: false)) + + if get_option('buildtype') == 'release' + # Split debug symbols into separate wasm file for release builds only + # as the release symbols will be uploaded to sentry + # custom_target('kmcmplib.wasm', + # depends: host, + # input: host, + # output: 'kmcmplib.wasm', + # command: ['wasm-split', '@OUTDIR@/wasm-host.wasm', '-o', '@OUTPUT@', '--strip', '--debug-out=@OUTDIR@/kmcmplib.debug.wasm'], + # build_by_default: true) + endif +endif diff --git a/core/src/wasm.cpp b/core/src/wasm.cpp new file mode 100644 index 00000000000..d9c4277a96b --- /dev/null +++ b/core/src/wasm.cpp @@ -0,0 +1,92 @@ +#ifdef __EMSCRIPTEN__ +#ifdef __EMSCRIPTEN__ +#include +#include + +#else +#define EMSCRIPTEN_KEEPALIVE +#endif + +#ifdef __cplusplus +#define EXTERN extern "C" EMSCRIPTEN_KEEPALIVE +#else +#define EXTERN EMSCRIPTEN_KEEPALIVE +#endif + +#include + +constexpr km_core_attr const engine_attrs = { + 256, + KM_CORE_LIB_CURRENT, + KM_CORE_LIB_AGE, + KM_CORE_LIB_REVISION, + KM_CORE_TECH_KMX, + "SIL International" +}; + +EMSCRIPTEN_KEEPALIVE km_core_attr const & tmp_wasm_attributes() { + return engine_attrs; +} + +EMSCRIPTEN_BINDINGS(compiler_interface) { + + // emscripten::class_("WasmCallbackInterface") + // .function("message", &WasmCallbackInterface::message, emscripten::pure_virtual()) + // .function("loadFile", &WasmCallbackInterface::loadFile, emscripten::pure_virtual()) + // .allow_subclass("WasmCallbackInterfaceWrapper") + // ; + + // emscripten::class_("CompilerOptions") + // .constructor<>() + // .property("saveDebug", &KMCMP_COMPILER_OPTIONS::saveDebug) + // .property("compilerWarningsAsErrors", &KMCMP_COMPILER_OPTIONS::compilerWarningsAsErrors) + // .property("warnDeprecatedCode", &KMCMP_COMPILER_OPTIONS::warnDeprecatedCode) + // .property("shouldAddCompilerVersion", &KMCMP_COMPILER_OPTIONS::shouldAddCompilerVersion) + // .property("target", &KMCMP_COMPILER_OPTIONS::target) + // ; + + // emscripten::class_("CompilerResult") + // .constructor<>() + // .property("result", &WASM_COMPILER_RESULT::result) + // .property("kmx", &WASM_COMPILER_RESULT::kmx) + // .property("kmxSize", &WASM_COMPILER_RESULT::kmxSize) + // .property("extra", &WASM_COMPILER_RESULT::extra) + // ; + + // emscripten::class_("CompilerResultMessage") + // .constructor<>() + // .property("errorCode", &KMCMP_COMPILER_RESULT_MESSAGE::errorCode) + // .property("lineNumber", &KMCMP_COMPILER_RESULT_MESSAGE::lineNumber) + // .property("columnNumber", &KMCMP_COMPILER_RESULT_MESSAGE::columnNumber) + // .property("filename", &KMCMP_COMPILER_RESULT_MESSAGE::filename) + // .property("parameters", &KMCMP_COMPILER_RESULT_MESSAGE::parameters) + // ; + + // emscripten::class_("CompilerResultExtra") + // .constructor<>() + // .property("targets", &KMCMP_COMPILER_RESULT_EXTRA::targets) + // .property("kmnFilename", &KMCMP_COMPILER_RESULT_EXTRA::kmnFilename) + // .property("kvksFilename", &KMCMP_COMPILER_RESULT_EXTRA::kvksFilename) + // .property("displayMapFilename", &KMCMP_COMPILER_RESULT_EXTRA::displayMapFilename) + // .property("stores", &KMCMP_COMPILER_RESULT_EXTRA::stores) + // .property("groups", &KMCMP_COMPILER_RESULT_EXTRA::groups) + // ; + + // emscripten::value_object("CompilerResultExtraStore") + // .field("storeType", &KMCMP_COMPILER_RESULT_EXTRA_STORE::storeType) + // .field("name", &KMCMP_COMPILER_RESULT_EXTRA_STORE::name) + // .field("line", &KMCMP_COMPILER_RESULT_EXTRA_STORE::line) + // ; + + emscripten::value_object("km_core_attr") + .field("max_context", &km_core_attr::max_context) + .field("current", &km_core_attr::current) + .field("revision", &km_core_attr::revision) + .field("age", &km_core_attr::age) + .field("technology", &km_core_attr::technology) + //.field("vendor", &km_core_attr::vendor, emscripten::allow_raw_pointers()) + ; + + emscripten::function("tmp_wasm_attributes", &tmp_wasm_attributes); +} +#endif diff --git a/package-lock.json b/package-lock.json index cee7d8528b7..c8451a75191 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8514,6 +8514,7 @@ "version": "4.19.2", "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -14760,6 +14761,7 @@ "@sentry/cli": "^2.31.0", "@zip.js/zip.js": "^2.7.32", "c8": "^7.12.0", + "express": "^4.19.2", "jsdom": "^23.0.1", "mocha": "^10.0.0" } diff --git a/resources/build/minimum-versions.inc.sh b/resources/build/minimum-versions.inc.sh index c060d31c58f..385f1647544 100644 --- a/resources/build/minimum-versions.inc.sh +++ b/resources/build/minimum-versions.inc.sh @@ -20,7 +20,7 @@ KEYMAN_MIN_TARGET_VERSION_CHROME=95.0 # Final version that runs on Andro # Dependency versions KEYMAN_MIN_VERSION_NODE_MAJOR=20 # node version source of truth is /package.json:/engines/node KEYMAN_MIN_VERSION_NPM=10.5.1 # 10.5.0 has bug, discussed in #10350 -KEYMAN_MIN_VERSION_EMSCRIPTEN=3.1.44 # Warning: 3.1.45 is bad (#9529); newer versions work +KEYMAN_MIN_VERSION_EMSCRIPTEN=3.1.58 KEYMAN_MAX_VERSION_EMSCRIPTEN=3.1.58 # See #9529 KEYMAN_MIN_VERSION_VISUAL_STUDIO=2019 KEYMAN_MIN_VERSION_MESON=1.0.0 diff --git a/web/README.md b/web/README.md index f09fe519bbb..ef7344adffb 100644 --- a/web/README.md +++ b/web/README.md @@ -29,8 +29,9 @@ src/test/auto A Node-driven test suite for automated testing of Key ## Usage -Open **index.html** or **samples/index.html** in your browser. Be sure to -compile Keyman Engine for Web before viewing the pages. +Start the test server by running `./build.sh start`, then open +your browser to http://localhost:3000. Be sure to compile Keyman Engine +for Web before viewing the pages. Refer to the samples for usage details. diff --git a/web/build.sh b/web/build.sh index 16d3d112356..27243535940 100755 --- a/web/build.sh +++ b/web/build.sh @@ -17,12 +17,14 @@ builder_describe "Builds engine modules for Keyman Engine for Web (KMW)." \ "clean" \ "configure" \ "build" \ + "start Starts the test server" \ "test" \ "coverage Create an HTML page with code coverage" \ ":app/browser The form of Keyman Engine for Web for use on websites" \ ":app/webview A puppetable version of KMW designed for use in a host app's WebView" \ ":app/ui Builds KMW's desktop form-factor keyboard-selection UI modules" \ ":engine/attachment Subset used for detecting valid page contexts for use in text editing " \ + ":engine/core-processor Keyman Core WASM integration" \ ":engine/device-detect Subset used for device-detection " \ ":engine/dom-utils A common subset of function used for DOM calculations, layout, etc" \ ":engine/events Specialized classes utilized to support KMW API events" \ @@ -56,6 +58,7 @@ builder_describe_outputs \ build:app/webview "/web/build/app/webview/${config}/keymanweb-webview.js" \ build:app/ui "/web/build/app/ui/${config}/kmwuitoggle.js" \ build:engine/attachment "/web/build/engine/attachment/lib/index.mjs" \ + build:engine/core-processor "/web/build/engine/core-processor/lib/index.mjs" \ build:engine/device-detect "/web/build/engine/device-detect/lib/index.mjs" \ build:engine/dom-utils "/web/build/engine/dom-utils/obj/index.js" \ build:engine/events "/web/build/engine/events/lib/index.mjs" \ @@ -159,6 +162,8 @@ builder_run_child_actions build:engine/attachment # Uses engine/interfaces (due to resource-path config interface) builder_run_child_actions build:engine/keyboard-storage +builder_run_child_actions build:engine/core-processor + # Uses engine/interfaces, engine/device-detect, engine/keyboard-storage, & engine/osk builder_run_child_actions build:engine/main @@ -188,3 +193,6 @@ builder_run_action test test_action # Create coverage report builder_run_action coverage coverage_action + +# Start the test server +builder_run_action start node src/tools/testing/test-server/index.cjs diff --git a/web/package.json b/web/package.json index 96a11c0cd3e..8a170dedaf4 100644 --- a/web/package.json +++ b/web/package.json @@ -12,10 +12,10 @@ "types": "./build/engine/attachment/obj/index.d.ts", "import": "./build/engine/attachment/obj/index.js" }, - "./engine/interfaces": { - "es6-bundling": "./src/engine/interfaces/src/index.ts", - "types": "./build/engine/interfaces/obj/index.d.ts", - "import": "./build/engine/interfaces/obj/index.js" + "./engine/core-processor": { + "es6-bundling": "./src/engine/core-processor/src/index.ts", + "types": "./build/engine/core-processor/obj/index.d.ts", + "import": "./build/engine/core-processor/obj/index.js" }, "./engine/device-detect": { "es6-bundling": "./src/engine/device-detect/src/index.ts", @@ -37,6 +37,11 @@ "types": "./build/engine/events/obj/index.d.ts", "import": "./build/engine/events/obj/index.js" }, + "./engine/interfaces": { + "es6-bundling": "./src/engine/interfaces/src/index.ts", + "types": "./build/engine/interfaces/obj/index.d.ts", + "import": "./build/engine/interfaces/obj/index.js" + }, "./engine/js-processor": { "es6-bundling": "./src/engine/js-processor/src/index.ts", "types": "./build/engine/js-processor/obj/index.d.ts", @@ -112,6 +117,7 @@ "@sentry/cli": "^2.31.0", "@zip.js/zip.js": "^2.7.32", "c8": "^7.12.0", + "express": "^4.19.2", "jsdom": "^23.0.1", "mocha": "^10.0.0" }, diff --git a/web/src/app/browser/build.sh b/web/src/app/browser/build.sh index 1d757e264a2..bf135637d1c 100755 --- a/web/src/app/browser/build.sh +++ b/web/src/app/browser/build.sh @@ -73,6 +73,10 @@ compile_and_copy() { mkdir -p "$KEYMAN_ROOT/web/build/app/resources/osk" cp -R "$KEYMAN_ROOT/web/src/resources/osk/." "$KEYMAN_ROOT/web/build/app/resources/osk/" + # Copy the WASM host + cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/browser/debug/" + cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/browser/release/" + # Update the build/publish copy of our build artifacts prepare diff --git a/web/src/app/webview/build.sh b/web/src/app/webview/build.sh index e22e1b470ef..13e6fe2ddf1 100755 --- a/web/src/app/webview/build.sh +++ b/web/src/app/webview/build.sh @@ -58,8 +58,16 @@ compile_and_copy() { mkdir -p "$KEYMAN_ROOT/web/build/app/resources/osk" cp -R "$KEYMAN_ROOT/web/src/resources/osk/." "$KEYMAN_ROOT/web/build/app/resources/osk/" + # Copy the WASM host + cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/webview/debug/" + cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/webview/release/" + # Clean the sourcemaps of .. and . components for script in "$KEYMAN_ROOT/web/build/$SUBPROJECT_NAME/debug/"*.js; do + if [[ "${script}" == *"/core.js" ]]; then + continue + fi + sourcemap="$script.map" node "$KEYMAN_ROOT/web/build/tools/building/sourcemap-root/index.js" \ "$script" "$sourcemap" --clean --inline @@ -68,6 +76,9 @@ compile_and_copy() { # Do NOT inline sourcemaps for release builds - we don't want them to affect # load time. for script in "$KEYMAN_ROOT/web/build/$SUBPROJECT_NAME/release/"*.js; do + if [[ "${script}" == *"/core.js" ]]; then + continue + fi sourcemap="$script.map" node "$KEYMAN_ROOT/web/build/tools/building/sourcemap-root/index.js" \ "$script" "$sourcemap" --clean diff --git a/web/src/engine/core-processor/.gitignore b/web/src/engine/core-processor/.gitignore new file mode 100644 index 00000000000..282f91762fb --- /dev/null +++ b/web/src/engine/core-processor/.gitignore @@ -0,0 +1 @@ +src/import/ \ No newline at end of file diff --git a/web/src/engine/core-processor/build.sh b/web/src/engine/core-processor/build.sh new file mode 100755 index 00000000000..14379c87654 --- /dev/null +++ b/web/src/engine/core-processor/build.sh @@ -0,0 +1,64 @@ +#!/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 + +SUBPROJECT_NAME=engine/core-processor + +. "${KEYMAN_ROOT}/web/common.inc.sh" +. "${KEYMAN_ROOT}/resources/shellHelperFunctions.sh" + +# ################################ Main script ################################ + +builder_describe "Keyman Core WASM integration" \ + "@/core:wasm" \ + "clean" \ + "configure" \ + "build" \ + "test" \ + "--ci+ Set to utilize CI-based test configurations & reporting." + +builder_describe_outputs \ + configure "/web/src/engine/core-processor/src/import/core/core-interface.d.ts" \ + build "/web/build/${SUBPROJECT_NAME}/lib/index.mjs" + +builder_parse "$@" + +#### Build action definitions #### + +do_clean() { + rm -rf "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}" + rm -rf "src/import/" +} + +do_configure() { + verify_npm_setup + + mkdir -p "src/import/core/" + # we don't need this file here, but it's nice to have for reference and auto-completion + cp "${KEYMAN_ROOT}/core/build/wasm/${BUILDER_CONFIGURATION}/src/core-interface.d.ts" "src/import/core/" +} + +copy_deps() { + mkdir -p "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}/obj/import/core/" + cp "${KEYMAN_ROOT}/core/build/wasm/${BUILDER_CONFIGURATION}/src/"core{.js,.wasm,-interface.d.ts} "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}/obj/import/core/" +} + +do_build () { + copy_deps + compile "${SUBPROJECT_NAME}" + + ${BUNDLE_CMD} "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}/obj/index.js" \ + --out "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}/lib/index.mjs" \ + --format esm +} + +builder_run_action clean do_clean +builder_run_action configure do_configure +builder_run_action build do_build + +# No headless tests for this child project. Currently, DOM-based unit & +# integrated tests are run solely by the top-level $KEYMAN_ROOT/web project. diff --git a/web/src/engine/core-processor/src/core-processor.ts b/web/src/engine/core-processor/src/core-processor.ts new file mode 100644 index 00000000000..3b53a0ec838 --- /dev/null +++ b/web/src/engine/core-processor/src/core-processor.ts @@ -0,0 +1,30 @@ +type km_core_attr = import('./import/core/core-interface.js').km_core_attr; + +export class CoreProcessor { + private instance: any; + + /** + * Initialize Core Processor + * @param baseurl - The url where core.js is located + */ + public async init(baseurl: string): Promise { + + if (!this.instance) { + try { + const module = await import(baseurl + '/core.js'); + this.instance = await module.default({ + locateFile: function (path: string, scriptDirectory: string) { + return baseurl + '/' + path; + } + }); + } catch (e: any) { + return false; + } + } + return !!this.instance; + }; + + public tmp_wasm_attributes(): km_core_attr { + return this.instance.tmp_wasm_attributes(); + } +} diff --git a/web/src/engine/core-processor/src/index.ts b/web/src/engine/core-processor/src/index.ts new file mode 100644 index 00000000000..06f040ce538 --- /dev/null +++ b/web/src/engine/core-processor/src/index.ts @@ -0,0 +1 @@ +export * from './core-processor.js'; \ No newline at end of file diff --git a/web/src/engine/core-processor/tsconfig.json b/web/src/engine/core-processor/tsconfig.json new file mode 100644 index 00000000000..84996aaca16 --- /dev/null +++ b/web/src/engine/core-processor/tsconfig.json @@ -0,0 +1,13 @@ +{ + // While the actual references themselves are headless, it compiles against the DOM-reliant OSK module. + "extends": "../../tsconfig.dom.json", + + "compilerOptions": { + "baseUrl": "./", + "outDir": "../../../build/engine/core-processor/obj/", + "tsBuildInfoFile": "../../../build/engine/core-processor/obj/tsconfig.tsbuildinfo", + "rootDir": "./src" + }, + + "include": [ "**/*.ts", "src/import/core/core.js" ], +} diff --git a/web/src/engine/interfaces/src/pathConfiguration.ts b/web/src/engine/interfaces/src/pathConfiguration.ts index 0eff8f65f78..18080374e48 100644 --- a/web/src/engine/interfaces/src/pathConfiguration.ts +++ b/web/src/engine/interfaces/src/pathConfiguration.ts @@ -106,6 +106,10 @@ export default class PathConfiguration implements OSKResourcePathConfiguration { return this._root; } + get basePath(): string { + return this.sourcePath; + } + get resources(): string { return this._resources; } diff --git a/web/src/engine/main/build.sh b/web/src/engine/main/build.sh index 77673626e02..5abcb59008c 100755 --- a/web/src/engine/main/build.sh +++ b/web/src/engine/main/build.sh @@ -14,6 +14,7 @@ SUBPROJECT_NAME=engine/main builder_describe "Builds the Keyman Engine for Web's common top-level base classes." \ "@/common/web/keyman-version" \ + "@/web/src/engine/core-processor" \ "@/web/src/engine/keyboard" \ "@/web/src/engine/interfaces build" \ "@/web/src/engine/device-detect build" \ diff --git a/web/src/engine/main/src/headless/inputProcessor.ts b/web/src/engine/main/src/headless/inputProcessor.ts index 6d8904c563d..94c95f56012 100644 --- a/web/src/engine/main/src/headless/inputProcessor.ts +++ b/web/src/engine/main/src/headless/inputProcessor.ts @@ -4,9 +4,10 @@ import ContextWindow from "./contextWindow.js"; import { LanguageProcessor } from "./languageProcessor.js"; -import type { ModelSpec } from "keyman/engine/interfaces"; +import type { ModelSpec, PathConfiguration } from "keyman/engine/interfaces"; import { globalObject, DeviceSpec } from "@keymanapp/web-utils"; +import { CoreProcessor } from "keyman/engine/core-processor"; import { Codes, type Keyboard, type KeyEvent } from "keyman/engine/keyboard"; import { type Alternate, @@ -35,6 +36,7 @@ export class InputProcessor { private contextDevice: DeviceSpec; private kbdProcessor: KeyboardProcessor; private lngProcessor: LanguageProcessor; + private coreProcessor: CoreProcessor; private readonly contextCache = new TranscriptionCache(); @@ -50,6 +52,11 @@ export class InputProcessor { this.contextDevice = device; this.kbdProcessor = new KeyboardProcessor(device, options); this.lngProcessor = new LanguageProcessor(predictiveTextWorker, this.contextCache); + this.coreProcessor = new CoreProcessor(); + } + + public async init(paths: PathConfiguration) { + this.coreProcessor.init(paths.basePath); } public get languageProcessor(): LanguageProcessor { diff --git a/web/src/engine/main/src/keymanEngine.ts b/web/src/engine/main/src/keymanEngine.ts index 851134f258f..5901bbded3e 100644 --- a/web/src/engine/main/src/keymanEngine.ts +++ b/web/src/engine/main/src/keymanEngine.ts @@ -234,6 +234,8 @@ export default class KeymanEngine< // Initialize supplementary plane string extensions String.kmwEnableSupplementaryPlane(true); + await this.core.init(config.paths); + // Since we're not sandboxing keyboard loads yet, we just use `window` as the jsGlobal object. // All components initialized below require a properly-configured `config.paths` or similar. const keyboardLoader = new KeyboardLoader(this.interface, config.applyCacheBusting); diff --git a/web/src/test/auto/dom/cases/core-processor/basic.spec.ts b/web/src/test/auto/dom/cases/core-processor/basic.spec.ts new file mode 100644 index 00000000000..c24748e9c58 --- /dev/null +++ b/web/src/test/auto/dom/cases/core-processor/basic.spec.ts @@ -0,0 +1,21 @@ +import { assert } from 'chai'; +import { CoreProcessor } from 'keyman/engine/core-processor'; + +const coreurl = '/web/build/engine/core-processor/obj/import/core/'; + +// Test the CoreProcessor interface. +describe('CoreProcessor', function () { + it('can initialize without errors', async function () { + const kp = new CoreProcessor(); + assert.isTrue(await kp.init(coreurl)); + }); + + it('can call temp function', async function () { + const kp = new CoreProcessor(); + await kp.init(coreurl); + const a = kp.tmp_wasm_attributes(); + assert.isNotNull(a); + assert.isNumber(a.max_context); + console.dir(a); + }); +}); diff --git a/web/src/test/auto/dom/web-test-runner.config.mjs b/web/src/test/auto/dom/web-test-runner.config.mjs index 62edfc7dbf9..1d9534a77ba 100644 --- a/web/src/test/auto/dom/web-test-runner.config.mjs +++ b/web/src/test/auto/dom/web-test-runner.config.mjs @@ -40,6 +40,11 @@ export default { // Relative, from the containing package.json files: ['build/test/dom/cases/browser/**/*.spec.mjs'] }, + { + name: 'engine/core-processor', + // Relative, from the containing package.json + files: ['build/test/dom/cases/core-processor/**/*.spec.mjs'] + }, { name: 'engine/dom-utils', // Relative, from the containing package.json @@ -59,7 +64,7 @@ export default { name: 'engine/keyboard-storage', // Relative, from the containing package.json files: ['build/test/dom/cases/keyboard-storage/**/*.spec.mjs'] - } + }, ], middleware: [ // Rewrites short-hand paths for test resources, making them fully relative to the repo root. @@ -68,6 +73,12 @@ export default { context.url = '/web/src/test/auto' + context.url; } + return next(); + }, + function rewriteWasmContentType(context, next) { + if (context.url.endsWith('.wasm')) { + context.headers['content-type'] = 'application/wasm'; + } return next(); } ], diff --git a/web/src/test/manual/build.sh b/web/src/test/manual/build.sh index 046cefe1520..6edae31b08f 100755 --- a/web/src/test/manual/build.sh +++ b/web/src/test/manual/build.sh @@ -57,4 +57,4 @@ function do_copy() { } builder_run_action clean rm -rf "$KEYMAN_ROOT/$DEST" -builder_run_action build do_copy \ No newline at end of file +builder_run_action build do_copy diff --git a/web/src/tools/testing/test-server/index.cjs b/web/src/tools/testing/test-server/index.cjs new file mode 100644 index 00000000000..80237e9f756 --- /dev/null +++ b/web/src/tools/testing/test-server/index.cjs @@ -0,0 +1,12 @@ +const express = require('express') +const path = require('path') +const app = express() +const port = 3000 + +app.use(express.static(path.join(__dirname, '../../../../'))) + +app.listen(port, () => { + console.log(`Keyman test app listening on port ${port}`) +}) + + From f6c822906461db162da31840b8f5fba3f34708f1 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 21 Aug 2024 09:20:10 +0200 Subject: [PATCH 003/254] feat(web): don't use deprecated emcc parameter --- core/src/meson.build | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/meson.build b/core/src/meson.build index 564472b7980..25499ab600b 100644 --- a/core/src/meson.build +++ b/core/src/meson.build @@ -135,7 +135,7 @@ mock_files = files( ) if cpp_compiler.get_id() == 'emscripten' - host_links = ['--whole-archive', '-sALLOW_MEMORY_GROWTH=1', '-sMODULARIZE=1', '-sEXPORT_ES6', '-sENVIRONMENT=webview', '--embind-emit-tsd', 'core-interface.d.ts', '-sERROR_ON_UNDEFINED_SYMBOLS=0'] + host_links = ['--whole-archive', '-sALLOW_MEMORY_GROWTH=1', '-sMODULARIZE=1', '-sEXPORT_ES6', '-sENVIRONMENT=webview', '--emit-tsd', 'core-interface.d.ts', '-sERROR_ON_UNDEFINED_SYMBOLS=0'] if cpp_compiler.version().version_compare('>=3.1.44') # emscripten 3.1.44 removes .asm object and so we need to export `wasmExports` From 3912bf4c1390cc1dbee31c1a5edf0fd61c227362 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 21 Aug 2024 12:25:51 +0200 Subject: [PATCH 004/254] chore(developer): support for emscripten 3.1.64 --- developer/src/kmcmplib/src/CompilerInterfacesWasm.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/developer/src/kmcmplib/src/CompilerInterfacesWasm.cpp b/developer/src/kmcmplib/src/CompilerInterfacesWasm.cpp index a857b41dce4..3848d89c4d2 100644 --- a/developer/src/kmcmplib/src/CompilerInterfacesWasm.cpp +++ b/developer/src/kmcmplib/src/CompilerInterfacesWasm.cpp @@ -75,8 +75,8 @@ struct BindingType> { using ValBinding = BindingType; using WireType = ValBinding::WireType; - static WireType toWireType(const std::vector &vec) { - return ValBinding::toWireType(val::array(vec)); + static WireType toWireType(const std::vector &vec, rvp::default_tag) { + return ValBinding::toWireType(val::array(vec), rvp::default_tag{}); } static std::vector fromWireType(WireType value) { From e015072213a01c58482a15bfd19ee2c8563f0425 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 22 Aug 2024 15:58:53 +0200 Subject: [PATCH 005/254] chore(web): add utils dependency to core-processor (es-bundling) --- web/src/engine/core-processor/build.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/engine/core-processor/build.sh b/web/src/engine/core-processor/build.sh index 14379c87654..4d0818f578b 100755 --- a/web/src/engine/core-processor/build.sh +++ b/web/src/engine/core-processor/build.sh @@ -15,6 +15,7 @@ SUBPROJECT_NAME=engine/core-processor builder_describe "Keyman Core WASM integration" \ "@/core:wasm" \ + "@/common/web/utils" \ "clean" \ "configure" \ "build" \ From c021999f9e7a9a11aa07bbd5247f1eba4f7c879d Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Fri, 23 Aug 2024 07:11:41 +0200 Subject: [PATCH 006/254] chore(common): use `npm install` and set min ver for emsdk --- docs/minimum-versions.md | 3 +-- resources/build/minimum-versions.inc.sh | 2 +- resources/locate_emscripten.inc.sh | 2 ++ 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/minimum-versions.md b/docs/minimum-versions.md index a9a2ead26b9..4b308450690 100644 --- a/docs/minimum-versions.md +++ b/docs/minimum-versions.md @@ -49,7 +49,6 @@ https://help.keyman.com/developer/engine/android/latest-version/ | KEYMAN Variable | Value | |-----------------------------------|--------------| | KEYMAN_DEFAULT_VERSION_UBUNTU_CONTAINER | noble | -| KEYMAN_MAX_VERSION_EMSCRIPTEN | 3.1.58 | | KEYMAN_MIN_TARGET_VERSION_ANDROID | 5 | | KEYMAN_MIN_TARGET_VERSION_CHROME | 95.0 | | KEYMAN_MIN_TARGET_VERSION_IOS | 12.2 | @@ -58,7 +57,7 @@ https://help.keyman.com/developer/engine/android/latest-version/ | KEYMAN_MIN_TARGET_VERSION_WINDOWS | 10 | | KEYMAN_MIN_VERSION_ANDROID_SDK | 21 | | KEYMAN_MIN_VERSION_CPP | 17 | -| KEYMAN_MIN_VERSION_EMSCRIPTEN | 3.1.44 | +| KEYMAN_MIN_VERSION_EMSCRIPTEN | 3.1.64 | | KEYMAN_MIN_VERSION_MESON | 1.0.0 | | KEYMAN_MIN_VERSION_NODE_MAJOR | 20 | | KEYMAN_MIN_VERSION_NPM | 10.5.1 | diff --git a/resources/build/minimum-versions.inc.sh b/resources/build/minimum-versions.inc.sh index 97f25094067..4dcf5e64f1e 100644 --- a/resources/build/minimum-versions.inc.sh +++ b/resources/build/minimum-versions.inc.sh @@ -20,7 +20,7 @@ KEYMAN_MIN_TARGET_VERSION_CHROME=95.0 # Final version that runs on Andro # Dependency versions KEYMAN_MIN_VERSION_NODE_MAJOR=20 # node version source of truth is /package.json:/engines/node KEYMAN_MIN_VERSION_NPM=10.5.1 # 10.5.0 has bug, discussed in #10350 -KEYMAN_MIN_VERSION_EMSCRIPTEN=3.1.58 # Use KEYMAN_USE_EMSDK to automatically update to this version +KEYMAN_MIN_VERSION_EMSCRIPTEN=3.1.64 # Use KEYMAN_USE_EMSDK to automatically update to this version KEYMAN_MIN_VERSION_VISUAL_STUDIO=2019 KEYMAN_MIN_VERSION_MESON=1.0.0 diff --git a/resources/locate_emscripten.inc.sh b/resources/locate_emscripten.inc.sh index f678e8449e6..3d470f75772 100644 --- a/resources/locate_emscripten.inc.sh +++ b/resources/locate_emscripten.inc.sh @@ -69,5 +69,7 @@ _select_emscripten_version_with_emsdk() { git pull ./emsdk install "$KEYMAN_MIN_VERSION_EMSCRIPTEN" ./emsdk activate "$KEYMAN_MIN_VERSION_EMSCRIPTEN" + cd upstream/emscripten + npm install popd > /dev/null } From 13f052f766931db3c3f3225a76c41d5c22b7fcb1 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 26 Aug 2024 14:49:54 +0700 Subject: [PATCH 007/254] Apply suggestions from code review Co-authored-by: Marc Durdin --- web/src/app/browser/build.sh | 2 +- web/src/app/webview/build.sh | 2 +- web/src/engine/core-processor/build.sh | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/web/src/app/browser/build.sh b/web/src/app/browser/build.sh index bf135637d1c..b5402d29218 100755 --- a/web/src/app/browser/build.sh +++ b/web/src/app/browser/build.sh @@ -73,7 +73,7 @@ compile_and_copy() { mkdir -p "$KEYMAN_ROOT/web/build/app/resources/osk" cp -R "$KEYMAN_ROOT/web/src/resources/osk/." "$KEYMAN_ROOT/web/build/app/resources/osk/" - # Copy the WASM host + # Copy Keyman Core build artifacts for local reference cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/browser/debug/" cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/browser/release/" diff --git a/web/src/app/webview/build.sh b/web/src/app/webview/build.sh index 13e6fe2ddf1..4a2bc2fb544 100755 --- a/web/src/app/webview/build.sh +++ b/web/src/app/webview/build.sh @@ -58,7 +58,7 @@ compile_and_copy() { mkdir -p "$KEYMAN_ROOT/web/build/app/resources/osk" cp -R "$KEYMAN_ROOT/web/src/resources/osk/." "$KEYMAN_ROOT/web/build/app/resources/osk/" - # Copy the WASM host + # Copy Keyman Core build artifacts for local reference cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/webview/debug/" cp "${KEYMAN_ROOT}/web/build/engine/core-processor/obj/import/core/"core.{js,wasm} "${KEYMAN_ROOT}/web/build/app/webview/release/" diff --git a/web/src/engine/core-processor/build.sh b/web/src/engine/core-processor/build.sh index 4d0818f578b..cee72fea011 100755 --- a/web/src/engine/core-processor/build.sh +++ b/web/src/engine/core-processor/build.sh @@ -39,7 +39,8 @@ do_configure() { verify_npm_setup mkdir -p "src/import/core/" - # we don't need this file here, but it's nice to have for reference and auto-completion + # we don't need this file for release builds, but it's nice to have + # for reference and auto-completion cp "${KEYMAN_ROOT}/core/build/wasm/${BUILDER_CONFIGURATION}/src/core-interface.d.ts" "src/import/core/" } @@ -60,6 +61,3 @@ do_build () { builder_run_action clean do_clean builder_run_action configure do_configure builder_run_action build do_build - -# No headless tests for this child project. Currently, DOM-based unit & -# integrated tests are run solely by the top-level $KEYMAN_ROOT/web project. From eb33c53b9cb5dc3b7ea1828e75004a0f1bb55ec7 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 26 Aug 2024 15:10:43 +0700 Subject: [PATCH 008/254] chore(core): address code review comments --- core/src/meson.build | 22 +++++++------------ core/src/wasm.cpp | 50 +------------------------------------------- package-lock.json | 8 +++---- 3 files changed, 13 insertions(+), 67 deletions(-) diff --git a/core/src/meson.build b/core/src/meson.build index 25499ab600b..74acf6e54b9 100644 --- a/core/src/meson.build +++ b/core/src/meson.build @@ -135,18 +135,11 @@ mock_files = files( ) if cpp_compiler.get_id() == 'emscripten' - host_links = ['--whole-archive', '-sALLOW_MEMORY_GROWTH=1', '-sMODULARIZE=1', '-sEXPORT_ES6', '-sENVIRONMENT=webview', '--emit-tsd', 'core-interface.d.ts', '-sERROR_ON_UNDEFINED_SYMBOLS=0'] - - if cpp_compiler.version().version_compare('>=3.1.44') - # emscripten 3.1.44 removes .asm object and so we need to export `wasmExports` - # #9375; https://github.com/emscripten-core/emscripten/blob/main/ChangeLog.md#3144---072523 - links += ['-sEXPORTED_RUNTIME_METHODS=[\'UTF8ToString\',\'stringToNewUTF8\',\'wasmExports\']'] - else - # emscripten < 3.1.44 does not include `wasmExports` - links += ['-sEXPORTED_RUNTIME_METHODS=[\'UTF8ToString\',\'stringToNewUTF8\']'] - endif + host_links = ['--whole-archive', '-sALLOW_MEMORY_GROWTH=1', '-sMODULARIZE=1', + '-sEXPORT_ES6', '-sENVIRONMENT=webview', + '--emit-tsd', 'core-interface.d.ts', '-sERROR_ON_UNDEFINED_SYMBOLS=0'] - links += [ + links += ['-sEXPORTED_RUNTIME_METHODS=[\'UTF8ToString\',\'stringToNewUTF8\',\'wasmExports\']', # Forcing inclusion of debug symbols '-g', '-Wlimited-postlink-optimizations', '--bind'] endif @@ -188,13 +181,14 @@ if cpp_compiler.get_id() == 'emscripten' objects: lib.extract_all_objects(recursive: false)) if get_option('buildtype') == 'release' + # TODO: #12888 # Split debug symbols into separate wasm file for release builds only # as the release symbols will be uploaded to sentry - # custom_target('kmcmplib.wasm', + # custom_target('core.wasm', # depends: host, # input: host, - # output: 'kmcmplib.wasm', - # command: ['wasm-split', '@OUTDIR@/wasm-host.wasm', '-o', '@OUTPUT@', '--strip', '--debug-out=@OUTDIR@/kmcmplib.debug.wasm'], + # output: 'core.wasm', + # command: ['wasm-split', '@OUTDIR@/core.wasm', '-o', '@OUTPUT@', '--strip', '--debug-out=@OUTDIR@/core.debug.wasm'], # build_by_default: true) endif endif diff --git a/core/src/wasm.cpp b/core/src/wasm.cpp index d9c4277a96b..90fc85cda88 100644 --- a/core/src/wasm.cpp +++ b/core/src/wasm.cpp @@ -28,55 +28,7 @@ EMSCRIPTEN_KEEPALIVE km_core_attr const & tmp_wasm_attributes() { return engine_attrs; } -EMSCRIPTEN_BINDINGS(compiler_interface) { - - // emscripten::class_("WasmCallbackInterface") - // .function("message", &WasmCallbackInterface::message, emscripten::pure_virtual()) - // .function("loadFile", &WasmCallbackInterface::loadFile, emscripten::pure_virtual()) - // .allow_subclass("WasmCallbackInterfaceWrapper") - // ; - - // emscripten::class_("CompilerOptions") - // .constructor<>() - // .property("saveDebug", &KMCMP_COMPILER_OPTIONS::saveDebug) - // .property("compilerWarningsAsErrors", &KMCMP_COMPILER_OPTIONS::compilerWarningsAsErrors) - // .property("warnDeprecatedCode", &KMCMP_COMPILER_OPTIONS::warnDeprecatedCode) - // .property("shouldAddCompilerVersion", &KMCMP_COMPILER_OPTIONS::shouldAddCompilerVersion) - // .property("target", &KMCMP_COMPILER_OPTIONS::target) - // ; - - // emscripten::class_("CompilerResult") - // .constructor<>() - // .property("result", &WASM_COMPILER_RESULT::result) - // .property("kmx", &WASM_COMPILER_RESULT::kmx) - // .property("kmxSize", &WASM_COMPILER_RESULT::kmxSize) - // .property("extra", &WASM_COMPILER_RESULT::extra) - // ; - - // emscripten::class_("CompilerResultMessage") - // .constructor<>() - // .property("errorCode", &KMCMP_COMPILER_RESULT_MESSAGE::errorCode) - // .property("lineNumber", &KMCMP_COMPILER_RESULT_MESSAGE::lineNumber) - // .property("columnNumber", &KMCMP_COMPILER_RESULT_MESSAGE::columnNumber) - // .property("filename", &KMCMP_COMPILER_RESULT_MESSAGE::filename) - // .property("parameters", &KMCMP_COMPILER_RESULT_MESSAGE::parameters) - // ; - - // emscripten::class_("CompilerResultExtra") - // .constructor<>() - // .property("targets", &KMCMP_COMPILER_RESULT_EXTRA::targets) - // .property("kmnFilename", &KMCMP_COMPILER_RESULT_EXTRA::kmnFilename) - // .property("kvksFilename", &KMCMP_COMPILER_RESULT_EXTRA::kvksFilename) - // .property("displayMapFilename", &KMCMP_COMPILER_RESULT_EXTRA::displayMapFilename) - // .property("stores", &KMCMP_COMPILER_RESULT_EXTRA::stores) - // .property("groups", &KMCMP_COMPILER_RESULT_EXTRA::groups) - // ; - - // emscripten::value_object("CompilerResultExtraStore") - // .field("storeType", &KMCMP_COMPILER_RESULT_EXTRA_STORE::storeType) - // .field("name", &KMCMP_COMPILER_RESULT_EXTRA_STORE::name) - // .field("line", &KMCMP_COMPILER_RESULT_EXTRA_STORE::line) - // ; +EMSCRIPTEN_BINDINGS(core_interface) { emscripten::value_object("km_core_attr") .field("max_context", &km_core_attr::max_context) diff --git a/package-lock.json b/package-lock.json index c8451a75191..eaaffeea7e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -404,7 +404,7 @@ "eventemitter3": "^5.0.0", "restructure": "^3.0.1", "sax": ">=0.6.0", - "semver": "^7.5.2", + "semver": "^7.5.4", "xmlbuilder": "~11.0.0" }, "devDependencies": { @@ -1153,7 +1153,7 @@ "@keymanapp/keyman-version": "*", "@keymanapp/kmc-kmn": "*", "@keymanapp/ldml-keyboard-constants": "*", - "semver": "^7.5.2" + "semver": "^7.5.4" }, "devDependencies": { "@keymanapp/developer-test-helpers": "*", @@ -1942,7 +1942,7 @@ "open": "^8.4.0", "restructure": "^3.0.1", "sax": ">=0.6.0", - "semver": "^7.5.2", + "semver": "^7.5.4", "ws": "^8.17.1", "xmlbuilder": "~11.0.0" }, @@ -14631,7 +14631,7 @@ "devDependencies": { "@types/semver": "^7.1.0", "@types/yargs": "^17.0.26", - "semver": "^7.5.2" + "semver": "^7.5.4" } }, "resources/build/version/node_modules/ansi-regex": { From 1deaa323ada28a6212da06d76ef886abc9c1335c Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Fri, 30 Aug 2024 14:44:40 +0700 Subject: [PATCH 009/254] feat(core): spec out API extensions - loading a keyboard from a BLOB - getting the on-screen keyboard layout from Core. This is an internal- only API because of it's use of C++. Part-of: #11293 Part-of: #8093 --- core/include/keyman/keyman_core_api.h | 45 +++++++ core/src/layout.hpp | 185 ++++++++++++++++++++++++++ core/src/mock/mock_processor.cpp | 2 +- 3 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 core/src/layout.hpp diff --git a/core/include/keyman/keyman_core_api.h b/core/include/keyman/keyman_core_api.h index 328965e8761..341b5fa1fad 100644 --- a/core/include/keyman/keyman_core_api.h +++ b/core/include/keyman/keyman_core_api.h @@ -1140,6 +1140,51 @@ km_core_keyboard_load(km_core_path_name kb_path, ------------------------------------------------------------------------------- +# km_core_keyboard_load_from_blob() + +## Description + +Parse and load keyboard from the supplied blob and a pointer to the loaded keyboard +into the out paramter. + +## Specification + +```c */ +KMN_API +km_core_status km_core_keyboard_load_from_blob(void* blob, km_core_keyboard** keyboard); + +/* +``` + +## Parameters + +`blob` +: a byte array containing the content of a KMX/KMX+ file + +`keyboard` +: A pointer to result variable: A pointer to the opaque keyboard + object returned by the Processor. This memory must be freed with a + call to [km_core_keyboard_dispose]. + +## Returns + +`KM_CORE_STATUS_OK` +: On success. + +`KM_CORE_STATUS_NO_MEM` +: In the event an internal memory allocation fails. + +`KM_CORE_STATUS_IO_ERROR` +: In the event the keyboard file is unparseable for any reason + +`KM_CORE_STATUS_INVALID_ARGUMENT` +: In the event `keyboard` is null. + +`KM_CORE_STATUS_OS_ERROR` +: Bit 31 (high bit) set, bits 0-30 are an OS-specific error code. + +------------------------------------------------------------------------------- + # km_core_keyboard_dispose() ## Description diff --git a/core/src/layout.hpp b/core/src/layout.hpp new file mode 100644 index 00000000000..54577919be3 --- /dev/null +++ b/core/src/layout.hpp @@ -0,0 +1,185 @@ +/* + * Keyman is copyright (C) SIL International. MIT License. + * + * Keyman Keyboard Processor API - On-Screen Keyboard Layout Interfaces + */ + +#pragma once + +#include +#include +#include + +#include "keyman_core_api.h" + +#if defined(__cplusplus) +extern "C" { +#endif + +/** + * Possible directions of a flick + */ +enum keyboard_layout_flick_direction { + /** flick up (north) */ + n = 0, + /** flick down (south) */ + s = 1, + /** flick right (east) */ + e = 2, + /** flick left (west) */ + w = 3, + /** flick up-right (north-east) */ + ne = 4, + /** flick up-left (north-west) */ + nw = 5, + /** flick down-right (south-east) */ + se = 6, + /** flick down-left (south-west) */ + sw = 7 +}; + +/** + * key type like regular key, framekeys, deadkeys, blank, etc. + */ +enum keyboard_layout_key_type { + /** regular key */ + normal = 0, + /** A 'frame' key, such as Shift or Enter */ + special = 1, + /** A 'frame' key, such as Shift or Enter, which is is active, such as + * the shift key on a shift layer */ + specialActive = 2, + /** **KeymanWeb runtime private use:** a variant of `special` with the + * keyboard font rather than 'KeymanwebOsk' font */ + customSpecial = 3, + /** **KeymanWeb runtime private use:** a variant of `specialActive` with the + * keyboard font rather than 'KeymanwebOsk' font. */ + customSpecialActive = 4, + /** A deadkey */ + deadkey = 8, + /** A key which is rendered as a blank keycap, should block any interaction */ + blank = 9, + /** Renders the key only as a gap or spacer, should block any interaction */ + spacer = 10 +}; + +/** + * A key on a touch layout/on-screen keyboard + */ +struct keyboard_layout_key { + /** key id */ + std::u16string id; // ??? perhaps necessary for special keys, Enter, etc? or can we get that from virtualKey? + /** the virtual key code */ + int virtualKey; // ??? do we need this? both id and virtualKey? Or just one of them? + /** text to display on key cap */ + std::u16string display; + /** hint e.g. for longpress */ + std::u16string hint; + /** the type of key */ + keyboard_layout_key_type type; + + /** + * the modifier combination (not layer) that should be used in key events, + * for this key, overriding the layer that the key is a part of. + */ + int modifiersOverride; + /** the next layer to switch to after this key is pressed */ + std::u16string nextLayerId; + + // touch layouts only + + /** padding - space to the left of key (in what units?) */ + int gap; + /** width of the key (in what units?) */ + int width; + + /** longpress keys, also known as subkeys */ + std::vector longpresses; + /** multitaps */ + std::vector multiTaps; + /** flicks */ + std::map flicks; +}; + +/** + * a row of keys on a touch layout/on-screen keyboard + */ +struct keyboard_layout_row { + /** row id */ + int id; // ??? do we need this? Web has it (`TouchLayoutRow`) + /** keys in this row */ + std::vector keys; +}; + +/** + * a layer with rows of keys on a touch layout/on-screen keyboard + */ +struct keyboard_layout_layer { + /** layer id */ + std::u16string id; + /** layer modifiers */ + // ??? we added this during our discussion, but Web doesn't have it. + // Should be an enum if it's needed. + int modifiers; //? 0 = default, n = shift, etc. -1 = unspecified? + /** rows in this layer */ + std::vector rows; +}; + +/** + * layout specification for a specific platform like desktop, phone or tablet + */ +struct keyboard_layout_platform { + /** platform form factor, e.g. 'iso', 'touch', 'ansi', ... (see ldml spec) */ + std::u16string form; + /** width of screen for touch layout */ + int minWidthMm; // we don't have mobile vs tablet, instead use this + /** layers for this platform */ + std::vector layers; + + // ??? Do we need these: + // Web additionally has: + // - font (should be in CSS; we have it in `keyboard_layout`) + // - fontsize (should be in CSS; we have it in `keyboard_layout`) + // - displayUnderlying + // - defaultHint ("none"|"dot"|"longpress"|"multitap"|"flick"|"flick-n"|"flick-ne"| + // "flick-e"|"flick-se"|"flick-s"|"flick-sw"|"flick-w"|"flick-nw") +}; + +/** + * On screen keyboard description consisting of specific layouts for different + * form factors. + */ +struct keyboard_layout { + /** layouts for different form factors */ + std::vector platforms; + /** font face name to use for key caps*/ + std::string fontFacename; + /** font size to use for key caps */ + int fontSizeEm; // ??? em? px? something else? +}; + + +/** + * Get the on-screen keyboard layout for the specified keyboard. + * + * @param keyboard [in] The keyboard to get the layout for. + * @param layout [out] The on-screen keyboard layout. + * @return km_core_status `KM_CORE_STATUS_OK`: On success. + * `KM_CORE_STATUS_INVALID_ARGUMENT`: If `keyboard` is not a valid keyboard or `layout` is null. + */ +km_core_status +keyboard_get_layout( + km_core_keyboard const* keyboard, + keyboard_layout** layout +); + + +/** + * Dispose the on-screen keyboard layout. + */ +void +keyboard_layout_dispose(keyboard_layout* layout); + +#if defined(__cplusplus) +} +#endif diff --git a/core/src/mock/mock_processor.cpp b/core/src/mock/mock_processor.cpp index d78ab5ef0e0..e70d5ba4353 100644 --- a/core/src/mock/mock_processor.cpp +++ b/core/src/mock/mock_processor.cpp @@ -3,7 +3,7 @@ Description: This is a test implementation of the keyboard processor API to enable testing API clients against a basic keyboard and give them something to link against and load. - TODO: Add a mecahnism to trigger output of PERSIST_OPT & + TODO: Add a mechanism to trigger output of PERSIST_OPT & RESET_OPT actions items, options support and context matching. Create Date: 17 Oct 2018 Authors: Tim Eves (TSE) From f7f26e87f892887f7331b8fcf22e7c9ffa237c8f Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Sat, 31 Aug 2024 21:37:16 +0700 Subject: [PATCH 010/254] feat(web): refactor loading of .js keyboards - split keyboard loading into loading into blob and then loading the script - look at first four bytes to see if it's a .js or a .kmx keyboard - for domKeyboardLoader, use fetch to get the blob, then use indirect eval to load the script, instead of injecting a script element. This does not yet implement the loading of .kmx keyboards. --- web/src/engine/keyboard/src/index.ts | 8 +- .../src/keyboards/keyboardLoadError.ts | 63 +++++++++++++ .../src/keyboards/keyboardLoaderBase.ts | 92 ++++--------------- .../keyboards/loaders/domKeyboardLoader.ts | 72 ++++++--------- .../keyboards/loaders/nodeKeyboardLoader.ts | 18 +++- 5 files changed, 124 insertions(+), 129 deletions(-) create mode 100644 web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts diff --git a/web/src/engine/keyboard/src/index.ts b/web/src/engine/keyboard/src/index.ts index 28b6331c219..cc0e86db532 100644 --- a/web/src/engine/keyboard/src/index.ts +++ b/web/src/engine/keyboard/src/index.ts @@ -3,12 +3,8 @@ export * from "./keyboards/defaultLayouts.js"; export { default as Keyboard } from "./keyboards/keyboard.js"; export * from "./keyboards/keyboard.js"; export { KeyboardHarness, KeyboardKeymanGlobal, MinimalCodesInterface, MinimalKeymanGlobal } from "./keyboards/keyboardHarness.js"; -export { - default as KeyboardLoaderBase, - KeyboardLoadErrorBuilder, - KeyboardMissingError, - KeyboardScriptError -} from "./keyboards/keyboardLoaderBase.js"; +export { default as KeyboardLoaderBase, } from "./keyboards/keyboardLoaderBase.js"; +export { KeyboardLoadErrorBuilder, KeyboardMissingError, KeyboardScriptError } from './keyboards/keyboardLoadError.js' export { CloudKeyboardFont, internalizeFont, diff --git a/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts b/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts new file mode 100644 index 00000000000..9a95c24ef23 --- /dev/null +++ b/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts @@ -0,0 +1,63 @@ +import { type KeyboardStub } from './keyboardLoaderBase.js'; + +export interface KeyboardLoadErrorBuilder { + scriptError(err?: Error): void; + missingError(err: Error): void; +} + +export class KeyboardScriptError extends Error { + public readonly cause; + + constructor(msg: string, cause?: Error) { + super(msg); + this.cause = cause; + } +} + +export class KeyboardMissingError extends Error { + public readonly cause; + + constructor(msg: string, cause?: Error) { + super(msg); + this.cause = cause; + } +} + +export class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder { + readonly uri: string; + + constructor(uri: string) { + this.uri = uri; + } + + missingError(err: Error) { + const msg = `Cannot find the keyboard at ${this.uri}.`; + return new KeyboardMissingError(msg, err); + } + + scriptError(err: Error) { + const msg = `Error registering the keyboard script at ${this.uri}; it may contain an error.`; + return new KeyboardScriptError(msg, err); + } +} + +export class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder { + readonly stub: KeyboardStub; + + constructor(stub: KeyboardStub) { + this.stub = stub; + } + + missingError(err: Error) { + const stub = this.stub; + const msg = `Cannot find the ${stub.name} keyboard for ${stub.langName} at ${stub.filename}.`; + return new KeyboardMissingError(msg, err); + } + + scriptError(err: Error) { + const stub = this.stub; + const msg = `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.`; + return new KeyboardScriptError(msg, err); + } +} + diff --git a/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts b/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts index d894eeb20c7..8024609c883 100644 --- a/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts +++ b/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts @@ -1,69 +1,9 @@ import Keyboard from "./keyboard.js"; import { KeyboardHarness } from "./keyboardHarness.js"; import KeyboardProperties from "./keyboardProperties.js"; +import { KeyboardLoadErrorBuilder, StubBasedErrorBuilder, UriBasedErrorBuilder } from './keyboardLoadError.js'; -type KeyboardStub = KeyboardProperties & { filename: string }; - -export interface KeyboardLoadErrorBuilder { - scriptError(err?: Error): void; - missingError(err: Error): void; -} - -export class KeyboardScriptError extends Error { - public readonly cause; - - constructor(msg: string, cause?: Error) { - super(msg); - this.cause = cause; - } -} - -export class KeyboardMissingError extends Error { - public readonly cause; - - constructor(msg: string, cause?: Error) { - super(msg); - this.cause = cause; - } -} - -class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder { - readonly uri: string; - - constructor(uri: string) { - this.uri = uri; - } - - missingError(err: Error) { - const msg = `Cannot find the keyboard at ${this.uri}.`; - return new KeyboardMissingError(msg, err); - } - - scriptError(err: Error) { - const msg = `Error registering the keyboard script at ${this.uri}; it may contain an error.`; - return new KeyboardScriptError(msg, err); - } -} - -class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder { - readonly stub: KeyboardStub; - - constructor(stub: KeyboardStub) { - this.stub = stub; - } - - missingError(err: Error) { - const stub = this.stub; - const msg = `Cannot find the ${stub.name} keyboard for ${stub.langName} at ${stub.filename}.`; - return new KeyboardMissingError(msg, err); - } - - scriptError(err: Error) { - const stub = this.stub; - const msg = `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.`; - return new KeyboardScriptError(msg, err); - } -} +export type KeyboardStub = KeyboardProperties & { filename: string }; export default abstract class KeyboardLoaderBase { private _harness: KeyboardHarness; @@ -78,21 +18,29 @@ export default abstract class KeyboardLoaderBase { public loadKeyboardFromPath(uri: string): Promise { this.harness.install(); - const promise = this.loadKeyboardInternal(uri, new UriBasedErrorBuilder(uri)); - - return promise; + return this.loadKeyboardInternal(uri, new UriBasedErrorBuilder(uri)); } public loadKeyboardFromStub(stub: KeyboardStub) { this.harness.install(); - let promise = this.loadKeyboardInternal(stub.filename, new StubBasedErrorBuilder(stub), stub.id); + return this.loadKeyboardInternal(stub.filename, new StubBasedErrorBuilder(stub)); + } - return promise; + private async loadKeyboardInternal(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { + const blob = await this.loadKeyboardBlob(uri); + + const script = await blob.text(); + if (script.startsWith('KXTS', 0)) { + // KMX or LDML (KMX+) keyboard + console.error("KMX keyboard loading is not yet implemented!"); + return null; + } + + // .js keyboard + return await this.loadKeyboardFromScript(script, errorBuilder); } - protected abstract loadKeyboardInternal( - uri: string, - errorBuilder: KeyboardLoadErrorBuilder, - id?: string - ): Promise; + protected abstract loadKeyboardBlob(uri: string): Promise; + + protected abstract loadKeyboardFromScript(scriptSrc: string, errorBuilder: KeyboardLoadErrorBuilder): Promise; } \ No newline at end of file diff --git a/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts b/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts index fcb2c764cd6..abfea28a91c 100644 --- a/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts +++ b/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts @@ -2,9 +2,10 @@ /// -import { Keyboard, KeyboardHarness, KeyboardLoaderBase, KeyboardLoadErrorBuilder, MinimalKeymanGlobal } from 'keyman/engine/keyboard'; - -import { ManagedPromise } from '@keymanapp/web-utils'; +import { default as Keyboard } from '../keyboard.js'; +import { KeyboardHarness, MinimalKeymanGlobal } from '../keyboardHarness.js'; +import { default as KeyboardLoaderBase } from '../keyboardLoaderBase.js'; +import { KeyboardLoadErrorBuilder, KeyboardMissingError } from '../keyboardLoadError.js'; export class DOMKeyboardLoader extends KeyboardLoaderBase { public readonly element: HTMLIFrameElement; @@ -28,54 +29,23 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase { this.performCacheBusting = cacheBust || false; } - protected loadKeyboardInternal( - uri: string, - errorBuilder: KeyboardLoadErrorBuilder, - id?: string - ): Promise { - const promise = new ManagedPromise(); - - if(this.performCacheBusting) { + protected async loadKeyboardBlob(uri: string): Promise { + if (this.performCacheBusting) { uri = this.cacheBust(uri); } - try { - const document = this.harness._jsGlobal.document; - const script = document.createElement('script'); - if(id) { - script.id = id; - } - document.head.appendChild(script); - script.onerror = (err: any) => { - promise.reject(errorBuilder.missingError(err)); - } - script.onload = () => { - if(this.harness.loadedKeyboard) { - const keyboard = this.harness.loadedKeyboard; - this.harness.loadedKeyboard = null; - promise.resolve(keyboard); - } else { - promise.reject(errorBuilder.scriptError()); - } - } - - // On the oldest mobile devices we support, Promise.finally may not actually exist. - // Fortunately... it's not that hard of an issue to work around. - // Note: es6-shim doesn't polyfill Promise.finally! - promise.then(() => { - // It is safe to remove the script once it has been run (https://stackoverflow.com/a/37393041) - script.remove(); - }).catch(() => { - script.remove(); - }); - - // Now that EVERYTHING ELSE is ready, establish the link to the keyboard's script. - script.src = uri; - } catch (err) { - return Promise.reject(err); + const response = await fetch(uri); + if (!response.ok) { + throw new KeyboardMissingError(`Cannot find the keyboard at ${uri}.`, new Error(`HTTP ${response.status} ${response.statusText}`)); } + return response.blob(); + } - return promise.corePromise; + protected async loadKeyboardFromScript(script: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { + this.evalScriptInContext(script, this.harness._jsGlobal); + const keyboard = this.harness.loadedKeyboard; + this.harness.loadedKeyboard = null; + return keyboard; } private cacheBust(uri: string) { @@ -84,4 +54,14 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase { // being ignored. return uri + "?v=" + (new Date()).getTime(); /*cache buster*/ } + + private evalScriptInContext(script: string, context: any) { + const f = function (s: string) { + // use indirect eval (eval?.() notation doesn't work because of esbuild bundling) + const evalFunc = eval; + return evalFunc(s); + } + f.call(context, script); + } + } \ No newline at end of file diff --git a/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts b/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts index b9f6a7cdfef..ca0874dfd8f 100644 --- a/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts +++ b/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts @@ -1,7 +1,10 @@ -import { Keyboard, KeyboardHarness, KeyboardLoaderBase, KeyboardLoadErrorBuilder, MinimalKeymanGlobal } from 'keyman/engine/keyboard'; +import { default as Keyboard } from '../keyboard.js'; +import { KeyboardHarness, MinimalKeymanGlobal } from '../keyboardHarness.js'; +import { default as KeyboardLoaderBase } from '../keyboardLoaderBase.js'; +import { KeyboardLoadErrorBuilder } from '../keyboardLoadError.js'; import vm from 'vm'; -import fs from 'fs'; +import { readFile } from 'fs/promises'; import { globalObject } from '@keymanapp/web-utils'; export class NodeKeyboardLoader extends KeyboardLoaderBase { @@ -22,15 +25,20 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase { } } - protected loadKeyboardInternal(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { + protected async loadKeyboardBlob(uri: string): Promise { // `fs` does not like 'file:///'; it IS "File System" oriented, after all, and wants a path, not a URI. - if(uri.indexOf('file:///') == 0) { + if (uri.indexOf('file:///') == 0) { uri = uri.substring('file:///'.length); } + const buffer = await readFile(uri); + return new Blob([buffer]); + } + + protected async loadKeyboardFromScript(scriptSrc: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { let script; try { - script = new vm.Script(fs.readFileSync(uri).toString()); + script = new vm.Script(scriptSrc); } catch (err) { return Promise.reject(errorBuilder.missingError(err)); } From 59019cc8b79242de3c1e2a1703083f1b89d792d7 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Thu, 5 Sep 2024 18:11:32 +0200 Subject: [PATCH 011/254] feat(linux): add blob size parameter Addresses code review comments. --- core/include/keyman/keyman_core_api.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/include/keyman/keyman_core_api.h b/core/include/keyman/keyman_core_api.h index 341b5fa1fad..6769e22306d 100644 --- a/core/include/keyman/keyman_core_api.h +++ b/core/include/keyman/keyman_core_api.h @@ -1151,7 +1151,8 @@ into the out paramter. ```c */ KMN_API -km_core_status km_core_keyboard_load_from_blob(void* blob, km_core_keyboard** keyboard); +km_core_status km_core_keyboard_load_from_blob(void* blob, size_t blob_size, + km_core_keyboard** keyboard); /* ``` @@ -1161,6 +1162,9 @@ km_core_status km_core_keyboard_load_from_blob(void* blob, km_core_keyboard** ke `blob` : a byte array containing the content of a KMX/KMX+ file +`blob_size` +: A pointer to a size_t variable with the size of the blob in bytes. + `keyboard` : A pointer to result variable: A pointer to the opaque keyboard object returned by the Processor. This memory must be freed with a From bc4645836838e37e696f49fa6f342e548635a91a Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Fri, 6 Sep 2024 12:44:36 +0200 Subject: [PATCH 012/254] feat(web): address code review comments --- core/include/keyman/keyman_core_api.h | 7 ++----- core/src/layout.hpp | 12 ++++++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/core/include/keyman/keyman_core_api.h b/core/include/keyman/keyman_core_api.h index 6769e22306d..11affd9296b 100644 --- a/core/include/keyman/keyman_core_api.h +++ b/core/include/keyman/keyman_core_api.h @@ -1179,13 +1179,10 @@ km_core_status km_core_keyboard_load_from_blob(void* blob, size_t blob_size, : In the event an internal memory allocation fails. `KM_CORE_STATUS_IO_ERROR` -: In the event the keyboard file is unparseable for any reason +: In the event the keyboard blob is unparseable for any reason `KM_CORE_STATUS_INVALID_ARGUMENT` -: In the event `keyboard` is null. - -`KM_CORE_STATUS_OS_ERROR` -: Bit 31 (high bit) set, bits 0-30 are an OS-specific error code. +: In the event one of the required parameters is null. ------------------------------------------------------------------------------- diff --git a/core/src/layout.hpp b/core/src/layout.hpp index 54577919be3..2e3d2833b4a 100644 --- a/core/src/layout.hpp +++ b/core/src/layout.hpp @@ -68,9 +68,9 @@ enum keyboard_layout_key_type { */ struct keyboard_layout_key { /** key id */ - std::u16string id; // ??? perhaps necessary for special keys, Enter, etc? or can we get that from virtualKey? + std::u16string id; // TODO-WEB-CORE: perhaps necessary for special keys, Enter, etc? or can we get that from virtualKey? /** the virtual key code */ - int virtualKey; // ??? do we need this? both id and virtualKey? Or just one of them? + int virtualKey; // TODO-WEB-CORE: do we need this? both id and virtualKey? Or just one of them? /** text to display on key cap */ std::u16string display; /** hint e.g. for longpress */ @@ -106,7 +106,7 @@ struct keyboard_layout_key { */ struct keyboard_layout_row { /** row id */ - int id; // ??? do we need this? Web has it (`TouchLayoutRow`) + int id; // TODO-WEB-CORE: do we need this? Web has it (`TouchLayoutRow`) /** keys in this row */ std::vector keys; }; @@ -118,7 +118,7 @@ struct keyboard_layout_layer { /** layer id */ std::u16string id; /** layer modifiers */ - // ??? we added this during our discussion, but Web doesn't have it. + // TODO-WEB-CORE: we added this during our discussion, but Web doesn't have it. // Should be an enum if it's needed. int modifiers; //? 0 = default, n = shift, etc. -1 = unspecified? /** rows in this layer */ @@ -136,7 +136,7 @@ struct keyboard_layout_platform { /** layers for this platform */ std::vector layers; - // ??? Do we need these: + // TODO-WEB-CORE: Do we need these: // Web additionally has: // - font (should be in CSS; we have it in `keyboard_layout`) // - fontsize (should be in CSS; we have it in `keyboard_layout`) @@ -155,7 +155,7 @@ struct keyboard_layout { /** font face name to use for key caps*/ std::string fontFacename; /** font size to use for key caps */ - int fontSizeEm; // ??? em? px? something else? + int fontSizeEm; // TODO-WEB-CORE: em? px? something else? }; From cbbe971cb3ccb238c08b7af0b0f1d8290e03648d Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 11 Sep 2024 19:27:54 +0200 Subject: [PATCH 013/254] chore(web): allow to run unit tests in vscode test explorer #11746 removed the `ts-node` dependency. Unfortunately that broke running TypeScript test files with Mocha in VSCode Test Explorer because it no longer knows what to do with .ts files. This change adds the more modern `tsx` package as developer dependency, which allows to use Test Explorer again. See https://stackoverflow.com/a/77609121. --- package-lock.json | 493 +++++++++++++++++++++++++++++++++++++++++++++- web/.mocharc.json | 4 + web/package.json | 3 +- 3 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 web/.mocharc.json diff --git a/package-lock.json b/package-lock.json index e0864087710..c317ee79d48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2174,6 +2174,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/android-arm64": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", @@ -2318,6 +2335,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/linux-mips64el": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", @@ -2414,6 +2448,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", @@ -8981,10 +9032,11 @@ } }, "node_modules/get-tsconfig": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.4.tgz", - "integrity": "sha512-ofbkKj+0pjXjhejr007J/fLf+sW+8H7K5GCm+msC8q3IpvgjobpyPqSRFemNyIMxklC0zeJpi7VDFna19FacvQ==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.0.tgz", + "integrity": "sha512-Pgba6TExTZ0FJAn1qkJAjIeKoDJ3CsI2ChuLohJnZl/tTU8MVrq3b+2t5UOPfRa4RMsorClBjJALkJUMjG1PAw==", "dev": true, + "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -13790,6 +13842,438 @@ "node": ">=0.6.x" } }, + "node_modules/tsx": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.0.tgz", + "integrity": "sha512-bV30kM7bsLZKZIOCHeMNVMJ32/LuJzLVajkQI/qf92J2Qr08ueLQvW00PUZGiuLPP760UINwupgUj8qrSCPUKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.23.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/tunnel": { "version": "0.0.6", "license": "MIT", @@ -14753,7 +15237,8 @@ "c8": "^7.12.0", "express": "^4.19.2", "jsdom": "^23.0.1", - "mocha": "^10.0.0" + "mocha": "^10.0.0", + "tsx": "^4.19.0" } }, "web/src/engine/osk/gesture-processor": { diff --git a/web/.mocharc.json b/web/.mocharc.json new file mode 100644 index 00000000000..e8ecf2c60fd --- /dev/null +++ b/web/.mocharc.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/mocharc.json", + "require": "tsx" +} diff --git a/web/package.json b/web/package.json index 67ebb687438..f503e912f96 100644 --- a/web/package.json +++ b/web/package.json @@ -120,7 +120,8 @@ "c8": "^7.12.0", "express": "^4.19.2", "jsdom": "^23.0.1", - "mocha": "^10.0.0" + "mocha": "^10.0.0", + "tsx": "^4.19.0" }, "scripts": { "test": "gosh ./test.sh" From 1dd6989747723967faddf868cf4e0614ae7372ce Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 11 Sep 2024 18:26:13 +0200 Subject: [PATCH 014/254] feat(web): add error handling and tests Addresses code review comments. --- web/src/engine/keyboard/src/index.ts | 4 +- .../src/keyboards/keyboardLoadError.ts | 27 ++++ .../src/keyboards/keyboardLoaderBase.ts | 26 +++- .../keyboards/loaders/domKeyboardLoader.ts | 29 +++- .../keyboards/loaders/nodeKeyboardLoader.ts | 24 ++-- .../engine/keyboard/keyboard-loading.js | 94 ------------- .../engine/keyboard/keyboard-loading.tests.ts | 132 ++++++++++++++++++ 7 files changed, 220 insertions(+), 116 deletions(-) delete mode 100644 web/src/test/auto/headless/engine/keyboard/keyboard-loading.js create mode 100644 web/src/test/auto/headless/engine/keyboard/keyboard-loading.tests.ts diff --git a/web/src/engine/keyboard/src/index.ts b/web/src/engine/keyboard/src/index.ts index cc0e86db532..e430556ed62 100644 --- a/web/src/engine/keyboard/src/index.ts +++ b/web/src/engine/keyboard/src/index.ts @@ -3,8 +3,8 @@ export * from "./keyboards/defaultLayouts.js"; export { default as Keyboard } from "./keyboards/keyboard.js"; export * from "./keyboards/keyboard.js"; export { KeyboardHarness, KeyboardKeymanGlobal, MinimalCodesInterface, MinimalKeymanGlobal } from "./keyboards/keyboardHarness.js"; -export { default as KeyboardLoaderBase, } from "./keyboards/keyboardLoaderBase.js"; -export { KeyboardLoadErrorBuilder, KeyboardMissingError, KeyboardScriptError } from './keyboards/keyboardLoadError.js' +export { KeyboardLoaderBase } from "./keyboards/keyboardLoaderBase.js"; +export { KeyboardLoadErrorBuilder, KeyboardMissingError, KeyboardScriptError, KeyboardDownloadError } from './keyboards/keyboardLoadError.js' export { CloudKeyboardFont, internalizeFont, diff --git a/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts b/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts index 9a95c24ef23..d394be15bf0 100644 --- a/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts +++ b/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts @@ -3,6 +3,8 @@ import { type KeyboardStub } from './keyboardLoaderBase.js'; export interface KeyboardLoadErrorBuilder { scriptError(err?: Error): void; missingError(err: Error): void; + missingKeyboardError(msg: string, err: Error): void; + keyboardDownloadError(msg: string, err: Error): void; } export class KeyboardScriptError extends Error { @@ -23,6 +25,15 @@ export class KeyboardMissingError extends Error { } } +export class KeyboardDownloadError extends Error { + public readonly cause; + + constructor(message: string, cause?: Error) { + super(message); + this.cause = cause; + } +} + export class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder { readonly uri: string; @@ -35,10 +46,18 @@ export class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder { return new KeyboardMissingError(msg, err); } + missingKeyboardError(msg: string, err: Error) { + return new KeyboardMissingError(msg, err); + } + scriptError(err: Error) { const msg = `Error registering the keyboard script at ${this.uri}; it may contain an error.`; return new KeyboardScriptError(msg, err); } + + keyboardDownloadError(msg: string, err: Error) { + return new KeyboardDownloadError(msg, err); + } } export class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder { @@ -54,10 +73,18 @@ export class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder { return new KeyboardMissingError(msg, err); } + missingKeyboardError(msg: string, err: Error) { + return new KeyboardMissingError(msg, err); + } + scriptError(err: Error) { const stub = this.stub; const msg = `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.`; return new KeyboardScriptError(msg, err); } + + keyboardDownloadError(msg: string, err: Error) { + return new KeyboardDownloadError(msg, err); + } } diff --git a/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts b/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts index 8024609c883..4c718d3e888 100644 --- a/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts +++ b/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts @@ -5,7 +5,7 @@ import { KeyboardLoadErrorBuilder, StubBasedErrorBuilder, UriBasedErrorBuilder } export type KeyboardStub = KeyboardProperties & { filename: string }; -export default abstract class KeyboardLoaderBase { +export abstract class KeyboardLoaderBase { private _harness: KeyboardHarness; public get harness(): KeyboardHarness { @@ -16,20 +16,38 @@ export default abstract class KeyboardLoaderBase { this._harness = harness; } + /** + * Load a keyboard from a remote or local URI. + * + * @param uri The URI of the keyboard to load. + * @returns A Promise that resolves to the loaded keyboard. + */ public loadKeyboardFromPath(uri: string): Promise { this.harness.install(); return this.loadKeyboardInternal(uri, new UriBasedErrorBuilder(uri)); } + /** + * Load a keyboard from keyboard stub. + * + * @param stub The stub of the keyboard to load. + * @returns A Promise that resolves to the loaded keyboard. + */ public loadKeyboardFromStub(stub: KeyboardStub) { this.harness.install(); return this.loadKeyboardInternal(stub.filename, new StubBasedErrorBuilder(stub)); } private async loadKeyboardInternal(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { - const blob = await this.loadKeyboardBlob(uri); + const blob = await this.loadKeyboardBlob(uri, errorBuilder); + + let script: string; + try { + script = await blob.text(); + } catch (e) { + throw errorBuilder.missingKeyboardError('The keyboard has an invalid encoding.', e); + } - const script = await blob.text(); if (script.startsWith('KXTS', 0)) { // KMX or LDML (KMX+) keyboard console.error("KMX keyboard loading is not yet implemented!"); @@ -40,7 +58,7 @@ export default abstract class KeyboardLoaderBase { return await this.loadKeyboardFromScript(script, errorBuilder); } - protected abstract loadKeyboardBlob(uri: string): Promise; + protected abstract loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise; protected abstract loadKeyboardFromScript(scriptSrc: string, errorBuilder: KeyboardLoadErrorBuilder): Promise; } \ No newline at end of file diff --git a/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts b/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts index abfea28a91c..2fba52a80e0 100644 --- a/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts +++ b/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts @@ -4,8 +4,8 @@ import { default as Keyboard } from '../keyboard.js'; import { KeyboardHarness, MinimalKeymanGlobal } from '../keyboardHarness.js'; -import { default as KeyboardLoaderBase } from '../keyboardLoaderBase.js'; -import { KeyboardLoadErrorBuilder, KeyboardMissingError } from '../keyboardLoadError.js'; +import { KeyboardLoaderBase } from '../keyboardLoaderBase.js'; +import { KeyboardLoadErrorBuilder } from '../keyboardLoadError.js'; export class DOMKeyboardLoader extends KeyboardLoaderBase { public readonly element: HTMLIFrameElement; @@ -29,20 +29,35 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase { this.performCacheBusting = cacheBust || false; } - protected async loadKeyboardBlob(uri: string): Promise { + protected async loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { if (this.performCacheBusting) { uri = this.cacheBust(uri); } - const response = await fetch(uri); + let response: Response; + try { + response = await fetch(uri); + } catch (e) { + throw errorBuilder.keyboardDownloadError(`Unable to fetch fetch keyboard at ${uri}`, e); + } + if (!response.ok) { - throw new KeyboardMissingError(`Cannot find the keyboard at ${uri}.`, new Error(`HTTP ${response.status} ${response.statusText}`)); + throw errorBuilder.missingError(new Error(`HTTP ${response.status} ${response.statusText}`)); + } + + try { + return await response.blob(); + } catch (e) { + throw errorBuilder.keyboardDownloadError(`Unable to retrieve blob from keyboard at ${uri}`, e); } - return response.blob(); } protected async loadKeyboardFromScript(script: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { - this.evalScriptInContext(script, this.harness._jsGlobal); + try { + this.evalScriptInContext(script, this.harness._jsGlobal); + } catch (e) { + throw errorBuilder.scriptError(e); + } const keyboard = this.harness.loadedKeyboard; this.harness.loadedKeyboard = null; return keyboard; diff --git a/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts b/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts index ca0874dfd8f..a63181fe8bc 100644 --- a/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts +++ b/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts @@ -1,12 +1,13 @@ +import vm from 'node:vm'; +import { readFile } from 'node:fs/promises'; + +import { globalObject } from '@keymanapp/web-utils'; + import { default as Keyboard } from '../keyboard.js'; import { KeyboardHarness, MinimalKeymanGlobal } from '../keyboardHarness.js'; -import { default as KeyboardLoaderBase } from '../keyboardLoaderBase.js'; +import { KeyboardLoaderBase } from '../keyboardLoaderBase.js'; import { KeyboardLoadErrorBuilder } from '../keyboardLoadError.js'; -import vm from 'vm'; -import { readFile } from 'fs/promises'; -import { globalObject } from '@keymanapp/web-utils'; - export class NodeKeyboardLoader extends KeyboardLoaderBase { constructor() constructor(harness: KeyboardHarness); @@ -25,13 +26,18 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase { } } - protected async loadKeyboardBlob(uri: string): Promise { + protected async loadKeyboardBlob(uri: string, errorBuilder: KeyboardLoadErrorBuilder): Promise { // `fs` does not like 'file:///'; it IS "File System" oriented, after all, and wants a path, not a URI. if (uri.indexOf('file:///') == 0) { uri = uri.substring('file:///'.length); } - const buffer = await readFile(uri); + let buffer: Buffer; + try { + buffer = await readFile(uri); + } catch (err) { + throw errorBuilder.keyboardDownloadError(`Unable to read keyboard file at ${uri}`, err); + } return new Blob([buffer]); } @@ -40,12 +46,12 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase { try { script = new vm.Script(scriptSrc); } catch (err) { - return Promise.reject(errorBuilder.missingError(err)); + throw errorBuilder.missingError(err); } try { script.runInContext(this.harness._jsGlobal); } catch (err) { - return Promise.reject(errorBuilder.scriptError(err)); + throw errorBuilder.scriptError(err); } const keyboard = this.harness.loadedKeyboard; diff --git a/web/src/test/auto/headless/engine/keyboard/keyboard-loading.js b/web/src/test/auto/headless/engine/keyboard/keyboard-loading.js deleted file mode 100644 index 0dd89a36148..00000000000 --- a/web/src/test/auto/headless/engine/keyboard/keyboard-loading.js +++ /dev/null @@ -1,94 +0,0 @@ -import { assert } from 'chai'; - -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); - -import { KeyboardHarness, MinimalKeymanGlobal } from 'keyman/engine/keyboard'; -import { KeyboardInterface, Mock } from 'keyman/engine/js-processor'; -import { NodeKeyboardLoader } from 'keyman/engine/keyboard/node-keyboard-loader'; - -describe('Headless keyboard loading', function() { - const laoPath = require.resolve('@keymanapp/common-test-resources/keyboards/lao_2008_basic.js'); - const khmerPath = require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js'); - const nonKeyboardPath = require.resolve('@keymanapp/common-test-resources/index.mjs'); - const ipaPath = require.resolve('@keymanapp/common-test-resources/keyboards/sil_ipa.js'); - // Common test suite setup. - - let device = { - formFactor: 'desktop', - OS: 'windows', - browser: 'native' - } - - describe('Minimal harness loading', () => { - it('successfully loads', async function() { - // -- START: Standard Recorder-based unit test loading boilerplate -- - let harness = new KeyboardHarness({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); - let keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); - // -- END: Standard Recorder-based unit test loading boilerplate -- - - // Asserts that the harness's loading field is cleared once the load is complete. - assert.isNotOk(harness.loadedKeyboard); - - // Asserts that the `activeKeyboard` field was not set by the operation. - assert.isNotOk(harness.activeKeyboard); - - // This part provides assurance that the keyboard properly loaded. - assert.equal(keyboard.id, "Keyboard_lao_2008_basic"); - }); - - it('successfully loads (has variable stores)', async () => { - // -- START: Standard Recorder-based unit test loading boilerplate -- - let harness = new KeyboardHarness({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); - let keyboard = await keyboardLoader.loadKeyboardFromPath(ipaPath); - // -- END: Standard Recorder-based unit test loading boilerplate -- - - // This part provides extra assurance that the keyboard properly loaded. - assert.equal(keyboard.id, "Keyboard_sil_ipa"); - }); - - it('cannot evaluate rules', async function() { - // -- START: Standard Recorder-based unit test loading boilerplate -- - let harness = new KeyboardHarness({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); - let keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); - // -- END: Standard Recorder-based unit test loading boilerplate -- - - // Runs a blank KeyEvent through the keyboard's rule processing... - // but via separate harness configured with a different captured global. - let ruleHarness = new KeyboardInterface({}, MinimalKeymanGlobal); - ruleHarness.activeKeyboard = keyboard; - try { - ruleHarness.processKeystroke(new Mock(), keyboard.constructNullKeyEvent(device)); - assert.fail(); - } catch (err) { - // Drives home an important detail: the 'global' object is effectively - // closure-captured. (Similar constraints may occur when experimenting with - // 'sandboxed' keyboard loading in the DOM!) - assert.equal(err.message, 'k.KKM is not a function'); - } - }); - - it('accurately determines supported gesture types', async () => { - // -- START: Standard Recorder-based unit test loading boilerplate -- - let harness = new KeyboardHarness({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); - let km_keyboard = await keyboardLoader.loadKeyboardFromPath(khmerPath); - // -- END: Standard Recorder-based unit test loading boilerplate -- - - // `khmer_angkor` - supports longpresses, but not flicks or multitaps. - - const desktopLayout = km_keyboard.layout('desktop'); - assert.isFalse(desktopLayout.hasFlicks); - assert.isFalse(desktopLayout.hasLongpresses); - assert.isFalse(desktopLayout.hasMultitaps); - - const mobileLayout = km_keyboard.layout('phone'); - assert.isFalse(mobileLayout.hasFlicks); - assert.isTrue(mobileLayout.hasLongpresses); - assert.isFalse(mobileLayout.hasMultitaps); - }); - }); -}); diff --git a/web/src/test/auto/headless/engine/keyboard/keyboard-loading.tests.ts b/web/src/test/auto/headless/engine/keyboard/keyboard-loading.tests.ts new file mode 100644 index 00000000000..a01d469e470 --- /dev/null +++ b/web/src/test/auto/headless/engine/keyboard/keyboard-loading.tests.ts @@ -0,0 +1,132 @@ +import { assert } from 'chai'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { KeyboardHarness, MinimalKeymanGlobal, KeyboardDownloadError, DeviceSpec, KeyboardMissingError } from 'keyman/engine/keyboard'; +import { KeyboardInterface, Mock } from 'keyman/engine/js-processor'; +import { NodeKeyboardLoader } from 'keyman/engine/keyboard/node-keyboard-loader'; + +// async function assertThrowsAsync(fn: () => Promise, message?: string): Promise; +async function assertThrowsAsync(fn: () => Promise, type?: any, message?: string): Promise { + if (typeof (type) === 'string') { + message = type; + type = undefined; + } + try { + await fn(); + assert.fail('Expected function to throw an error, but it did not.'); + } catch (err) { + if (type) { + assert.isTrue(err instanceof type, `Expected error to be of type ${type.name}, but got ${err.constructor.name}`); + } + if (message) { + assert.equal((err as Error).message, message); + } + } +} + +function assertThrows(fn: () => any, message?: string): void; +function assertThrows(fn: () => any, type?: any, message?: string): void { + if (typeof (type) === 'string') { + message = type; + type = undefined; + } + assert.throws(fn, type, message); +} + +describe('Headless keyboard loading', function() { + const laoPath = require.resolve('@keymanapp/common-test-resources/keyboards/lao_2008_basic.js'); + const khmerPath = require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js'); + const nonKeyboardPath = require.resolve('@keymanapp/common-test-resources/index.mjs'); + const ipaPath = require.resolve('@keymanapp/common-test-resources/keyboards/sil_ipa.js'); + const nonExisting = '/does/not/exist.js'; + // Common test suite setup. + + const device = { + formFactor: DeviceSpec.FormFactor.Desktop, + OS: DeviceSpec.OperatingSystem.Windows, + browser: DeviceSpec.Browser.Native, + touchable: false + } + + describe('Minimal harness loading', () => { + it('successfully loads', async function() { + // -- START: Standard Recorder-based unit test loading boilerplate -- + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // Asserts that the harness's loading field is cleared once the load is complete. + assert.isNotOk(harness.loadedKeyboard); + + // Asserts that the `activeKeyboard` field was not set by the operation. + assert.isNotOk(harness.activeKeyboard); + + // This part provides assurance that the keyboard properly loaded. + assert.equal(keyboard.id, "Keyboard_lao_2008_basic"); + }); + + it('throws error when keyboard does not exist', async function () { + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + + await assertThrowsAsync(() => keyboardLoader.loadKeyboardFromPath(nonExisting), + KeyboardDownloadError, `Unable to read keyboard file at ${nonExisting}`); + }); + + it('throws error when keyboard is invalid', async function () { + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + + await assertThrowsAsync(() => keyboardLoader.loadKeyboardFromPath(nonKeyboardPath), + KeyboardMissingError, `Cannot find the keyboard at ${nonKeyboardPath}.`); + }); + + it('successfully loads (has variable stores)', async () => { + // -- START: Standard Recorder-based unit test loading boilerplate -- + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath(ipaPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // This part provides extra assurance that the keyboard properly loaded. + assert.equal(keyboard.id, "Keyboard_sil_ipa"); + }); + + it('cannot evaluate rules', async function() { + // -- START: Standard Recorder-based unit test loading boilerplate -- + const harness = new KeyboardHarness({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // Runs a blank KeyEvent through the keyboard's rule processing... + // but via separate harness configured with a different captured global. + const ruleHarness = new KeyboardInterface({}, MinimalKeymanGlobal); + ruleHarness.activeKeyboard = keyboard; + assertThrows(() => ruleHarness.processKeystroke(new Mock(), keyboard.constructNullKeyEvent(device)), 'k.KKM is not a function'); + }); + + it('accurately determines supported gesture types', async () => { + // -- START: Standard Recorder-based unit test loading boilerplate -- + const harness = new KeyboardHarness({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const km_keyboard = await keyboardLoader.loadKeyboardFromPath(khmerPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // `khmer_angkor` - supports longpresses, but not flicks or multitaps. + + const desktopLayout = km_keyboard.layout(DeviceSpec.FormFactor.Desktop); + assert.isFalse(desktopLayout.hasFlicks); + assert.isFalse(desktopLayout.hasLongpresses); + assert.isFalse(desktopLayout.hasMultitaps); + + const mobileLayout = km_keyboard.layout(DeviceSpec.FormFactor.Phone); + assert.isFalse(mobileLayout.hasFlicks); + assert.isTrue(mobileLayout.hasLongpresses); + assert.isFalse(mobileLayout.hasMultitaps); + }); + }); +}); From 5d92ff6635a6d01ff9730ab7ea861bad228daa76 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Fri, 13 Sep 2024 17:23:50 +0200 Subject: [PATCH 015/254] chore(web): move kbdInterface tests to typescript --- web/src/engine/js-processor/build.sh | 7 +++- ...terface.tests.js => kbdInterface.tests.ts} | 33 ++++++++++--------- 2 files changed, 23 insertions(+), 17 deletions(-) rename web/src/test/auto/headless/engine/js-processor/{kbdInterface.tests.js => kbdInterface.tests.ts} (72%) diff --git a/web/src/engine/js-processor/build.sh b/web/src/engine/js-processor/build.sh index db00593166a..34546a913bd 100755 --- a/web/src/engine/js-processor/build.sh +++ b/web/src/engine/js-processor/build.sh @@ -36,7 +36,12 @@ do_build () { --format esm } +do_test() { + test-headless "${SUBPROJECT_NAME}" "" + test-headless-typescript "${SUBPROJECT_NAME}" +} + builder_run_action configure verify_npm_setup builder_run_action clean rm -rf "${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}" builder_run_action build do_build -builder_run_action test test-headless "${SUBPROJECT_NAME}" "" +builder_run_action test do_test diff --git a/web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.js b/web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.ts similarity index 72% rename from web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.js rename to web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.ts index 0d084964425..c58a7d91910 100644 --- a/web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.js +++ b/web/src/test/auto/headless/engine/js-processor/kbdInterface.tests.ts @@ -3,7 +3,7 @@ import { assert } from 'chai'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); -import { MinimalKeymanGlobal } from 'keyman/engine/keyboard'; +import { DeviceSpec, MinimalKeymanGlobal } from 'keyman/engine/keyboard'; import { KeyboardInterface, Mock } from 'keyman/engine/js-processor'; import { NodeKeyboardLoader } from 'keyman/engine/keyboard/node-keyboard-loader'; @@ -11,21 +11,22 @@ describe('Headless keyboard loading', function () { const laoPath = require.resolve('@keymanapp/common-test-resources/keyboards/lao_2008_basic.js'); const khmerPath = require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js'); const nonKeyboardPath = require.resolve('@keymanapp/common-test-resources/index.mjs'); - const ipaPath = require.resolve('@keymanapp/common-test-resources/keyboards/sil_ipa.js'); + // const ipaPath = require.resolve('@keymanapp/common-test-resources/keyboards/sil_ipa.js'); // Common test suite setup. - let device = { - formFactor: 'desktop', - OS: 'windows', - browser: 'native' + const device = { + formFactor: DeviceSpec.FormFactor.Desktop, + OS: DeviceSpec.OperatingSystem.Windows, + browser: DeviceSpec.Browser.Native, + touchable: false } describe('Full harness loading', () => { it('successfully loads', async function () { // -- START: Standard Recorder-based unit test loading boilerplate -- - let harness = new KeyboardInterface({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); - let keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); harness.activeKeyboard = keyboard; // -- END: Standard Recorder-based unit test loading boilerplate -- @@ -35,9 +36,9 @@ describe('Headless keyboard loading', function () { it('can evaluate rules', async function () { // -- START: Standard Recorder-based unit test loading boilerplate -- - let harness = new KeyboardInterface({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); - let keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); harness.activeKeyboard = keyboard; // -- END: Standard Recorder-based unit test loading boilerplate -- @@ -46,8 +47,8 @@ describe('Headless keyboard loading', function () { }); it('does not change the active kehboard', async function () { - let harness = new KeyboardInterface({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); const lao_keyboard = await keyboardLoader.loadKeyboardFromPath(laoPath); assert.isNotOk(harness.activeKeyboard); assert.isOk(lao_keyboard); @@ -66,8 +67,8 @@ describe('Headless keyboard loading', function () { it('throws distinct errors', async function () { const invalidPath = 'totally_invalid_path.js'; - let harness = new KeyboardInterface({}, MinimalKeymanGlobal); - let keyboardLoader = new NodeKeyboardLoader(harness); + const harness = new KeyboardInterface({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); let missingError; try { await keyboardLoader.loadKeyboardFromPath(invalidPath); From 7c75211f303df0000707ff0c92ea2b8e233df601 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 16 Sep 2024 05:51:38 +0700 Subject: [PATCH 016/254] chore: update dependencies --- web/src/engine/core-processor/build.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/engine/core-processor/build.sh b/web/src/engine/core-processor/build.sh index cee72fea011..16cb5947d8e 100755 --- a/web/src/engine/core-processor/build.sh +++ b/web/src/engine/core-processor/build.sh @@ -15,7 +15,7 @@ SUBPROJECT_NAME=engine/core-processor builder_describe "Keyman Core WASM integration" \ "@/core:wasm" \ - "@/common/web/utils" \ + "@/web/src/engine/common/web-utils" \ "clean" \ "configure" \ "build" \ @@ -39,7 +39,7 @@ do_configure() { verify_npm_setup mkdir -p "src/import/core/" - # we don't need this file for release builds, but it's nice to have + # we don't need this file for release builds, but it's nice to have # for reference and auto-completion cp "${KEYMAN_ROOT}/core/build/wasm/${BUILDER_CONFIGURATION}/src/core-interface.d.ts" "src/import/core/" } From 94b06b7ed760c906028cb2bde0aa43d77cdf27ea Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Fri, 13 Sep 2024 17:17:42 +0200 Subject: [PATCH 017/254] feat(web): improve errors and tests Addresses code review comments. --- web/package.json | 4 + web/src/engine/keyboard/build.sh | 9 +- web/src/engine/keyboard/src/index.ts | 2 +- .../src/keyboards/keyboardLoadError.ts | 146 ++++++++++-------- .../src/keyboards/keyboardLoaderBase.ts | 6 +- .../keyboards/loaders/domKeyboardLoader.ts | 6 +- .../keyboards/loaders/nodeKeyboardLoader.ts | 4 +- .../cases/keyboard/domKeyboardLoader.spec.ts | 21 ++- .../engine/keyboard/keyboard.tests.ts | 34 ++++ ...g.tests.ts => keyboardLoaderBase.tests.ts} | 69 ++------- web/src/tools/build.sh | 3 +- web/src/tools/testing/test-utils/build.sh | 34 ++++ web/src/tools/testing/test-utils/index.ts | 31 ++++ .../tools/testing/test-utils/tsconfig.json | 11 ++ 14 files changed, 244 insertions(+), 136 deletions(-) create mode 100644 web/src/test/auto/headless/engine/keyboard/keyboard.tests.ts rename web/src/test/auto/headless/engine/keyboard/{keyboard-loading.tests.ts => keyboardLoaderBase.tests.ts} (57%) create mode 100755 web/src/tools/testing/test-utils/build.sh create mode 100644 web/src/tools/testing/test-utils/index.ts create mode 100644 web/src/tools/testing/test-utils/tsconfig.json diff --git a/web/package.json b/web/package.json index f503e912f96..5dee2b63221 100644 --- a/web/package.json +++ b/web/package.json @@ -91,6 +91,10 @@ "./engine/osk/internals": { "types": "./build/engine/osk/obj/test-index.d.ts", "import": "./build/engine/osk/obj/test-index.js" + }, + "./tools/testing/test-utils": { + "types": "./build/tools/testing/test-utils/obj/index.d.ts", + "import": "./build/tools/testing/test-utils/obj/index.js" } }, "imports": { diff --git a/web/src/engine/keyboard/build.sh b/web/src/engine/keyboard/build.sh index 7b04e478a09..7222ba7680b 100755 --- a/web/src/engine/keyboard/build.sh +++ b/web/src/engine/keyboard/build.sh @@ -44,7 +44,7 @@ function do_configure() { BUILD_DIR="${KEYMAN_ROOT}/web/build/${SUBPROJECT_NAME}" -function do_build() { +do_build() { tsc --build "${THIS_SCRIPT_PATH}/tsconfig.all.json" # Base product - the main keyboard processor @@ -73,7 +73,12 @@ function do_build() { tsc --emitDeclarationOnly --outFile "${BUILD_DIR}/lib/node-keyboard-loader.d.ts" -p src/keyboards/loaders/tsconfig.node.json } +do_test() { + test-headless "${SUBPROJECT_NAME}" "" + test-headless-typescript "${SUBPROJECT_NAME}" +} + builder_run_action configure do_configure builder_run_action clean rm -rf "${BUILD_DIR}" builder_run_action build do_build -builder_run_action test test-headless "${SUBPROJECT_NAME}" "" +builder_run_action test do_test diff --git a/web/src/engine/keyboard/src/index.ts b/web/src/engine/keyboard/src/index.ts index e430556ed62..6047426af9f 100644 --- a/web/src/engine/keyboard/src/index.ts +++ b/web/src/engine/keyboard/src/index.ts @@ -4,7 +4,7 @@ export { default as Keyboard } from "./keyboards/keyboard.js"; export * from "./keyboards/keyboard.js"; export { KeyboardHarness, KeyboardKeymanGlobal, MinimalCodesInterface, MinimalKeymanGlobal } from "./keyboards/keyboardHarness.js"; export { KeyboardLoaderBase } from "./keyboards/keyboardLoaderBase.js"; -export { KeyboardLoadErrorBuilder, KeyboardMissingError, KeyboardScriptError, KeyboardDownloadError } from './keyboards/keyboardLoadError.js' +export { KeyboardLoadErrorBuilder, KeyboardMissingError, KeyboardScriptError, KeyboardDownloadError, InvalidKeyboardError } from './keyboards/keyboardLoadError.js' export { CloudKeyboardFont, internalizeFont, diff --git a/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts b/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts index d394be15bf0..b68f03d3a9c 100644 --- a/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts +++ b/web/src/engine/keyboard/src/keyboards/keyboardLoadError.ts @@ -1,90 +1,104 @@ import { type KeyboardStub } from './keyboardLoaderBase.js'; export interface KeyboardLoadErrorBuilder { - scriptError(err?: Error): void; - missingError(err: Error): void; - missingKeyboardError(msg: string, err: Error): void; - keyboardDownloadError(msg: string, err: Error): void; + scriptError(err?: Error): void; + missingError(err: Error): void; + keyboardDownloadError(err: Error): void; + + invalidKeyboard(err: Error): void; } export class KeyboardScriptError extends Error { - public readonly cause; + public readonly cause; - constructor(msg: string, cause?: Error) { - super(msg); - this.cause = cause; - } + constructor(msg: string, cause?: Error) { + super(msg); + this.cause = cause; + } } export class KeyboardMissingError extends Error { - public readonly cause; + public readonly cause; - constructor(msg: string, cause?: Error) { - super(msg); - this.cause = cause; - } + constructor(msg: string, cause?: Error) { + super(msg); + this.cause = cause; + } } export class KeyboardDownloadError extends Error { - public readonly cause; + public readonly cause; - constructor(message: string, cause?: Error) { - super(message); - this.cause = cause; - } + constructor(message: string, cause?: Error) { + super(message); + this.cause = cause; + } } -export class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder { - readonly uri: string; - - constructor(uri: string) { - this.uri = uri; - } - - missingError(err: Error) { - const msg = `Cannot find the keyboard at ${this.uri}.`; - return new KeyboardMissingError(msg, err); - } +export class InvalidKeyboardError extends Error { + public readonly cause; - missingKeyboardError(msg: string, err: Error) { - return new KeyboardMissingError(msg, err); - } - - scriptError(err: Error) { - const msg = `Error registering the keyboard script at ${this.uri}; it may contain an error.`; - return new KeyboardScriptError(msg, err); - } + constructor(message: string, cause?: Error) { + super(message); + this.cause = cause; + } +} - keyboardDownloadError(msg: string, err: Error) { - return new KeyboardDownloadError(msg, err); - } +export class UriBasedErrorBuilder implements KeyboardLoadErrorBuilder { + readonly uri: string; + + constructor(uri: string) { + this.uri = uri; + } + + missingError(err: Error) { + const msg = `Cannot find the keyboard at ${this.uri}.`; + return new KeyboardMissingError(msg, err); + } + + scriptError(err: Error) { + const msg = `Error registering the keyboard script at ${this.uri}; it may contain an error.`; + return new KeyboardScriptError(msg, err); + } + + keyboardDownloadError(err: Error) { + const msg = `Unable to download keyboard at ${this.uri}`; + return new KeyboardDownloadError(msg, err); + } + + invalidKeyboard(err: Error) { + const msg = `${this.uri} is not a valid keyboard file`; + return new InvalidKeyboardError(msg, err); + } } export class StubBasedErrorBuilder implements KeyboardLoadErrorBuilder { - readonly stub: KeyboardStub; - - constructor(stub: KeyboardStub) { - this.stub = stub; - } - - missingError(err: Error) { - const stub = this.stub; - const msg = `Cannot find the ${stub.name} keyboard for ${stub.langName} at ${stub.filename}.`; - return new KeyboardMissingError(msg, err); - } - - missingKeyboardError(msg: string, err: Error) { - return new KeyboardMissingError(msg, err); - } - - scriptError(err: Error) { - const stub = this.stub; - const msg = `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.`; - return new KeyboardScriptError(msg, err); - } - - keyboardDownloadError(msg: string, err: Error) { - return new KeyboardDownloadError(msg, err); - } + readonly stub: KeyboardStub; + + constructor(stub: KeyboardStub) { + this.stub = stub; + } + + missingError(err: Error) { + const stub = this.stub; + const msg = `Cannot find the ${stub.name} keyboard for ${stub.langName} at ${stub.filename}.`; + return new KeyboardMissingError(msg, err); + } + + scriptError(err: Error) { + const stub = this.stub; + const msg = `Error registering the ${stub.name} keyboard for ${stub.langName}; keyboard script at ${stub.filename} may contain an error.`; + return new KeyboardScriptError(msg, err); + } + + keyboardDownloadError(err: Error) { + const msg = `Unable to download ${this.stub.name} keyboard for ${this.stub.langName}`; + return new KeyboardDownloadError(msg, err); + } + + invalidKeyboard(err: Error) { + const msg = `${this.stub.name} is not a valid keyboard`; + return new InvalidKeyboardError(msg, err); + } } diff --git a/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts b/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts index 4c718d3e888..eb12472dd8a 100644 --- a/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts +++ b/web/src/engine/keyboard/src/keyboards/keyboardLoaderBase.ts @@ -22,7 +22,7 @@ export abstract class KeyboardLoaderBase { * @param uri The URI of the keyboard to load. * @returns A Promise that resolves to the loaded keyboard. */ - public loadKeyboardFromPath(uri: string): Promise { + public async loadKeyboardFromPath(uri: string): Promise { this.harness.install(); return this.loadKeyboardInternal(uri, new UriBasedErrorBuilder(uri)); } @@ -33,7 +33,7 @@ export abstract class KeyboardLoaderBase { * @param stub The stub of the keyboard to load. * @returns A Promise that resolves to the loaded keyboard. */ - public loadKeyboardFromStub(stub: KeyboardStub) { + public async loadKeyboardFromStub(stub: KeyboardStub): Promise { this.harness.install(); return this.loadKeyboardInternal(stub.filename, new StubBasedErrorBuilder(stub)); } @@ -45,7 +45,7 @@ export abstract class KeyboardLoaderBase { try { script = await blob.text(); } catch (e) { - throw errorBuilder.missingKeyboardError('The keyboard has an invalid encoding.', e); + throw errorBuilder.invalidKeyboard(e); } if (script.startsWith('KXTS', 0)) { diff --git a/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts b/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts index 2fba52a80e0..96d00bf4fda 100644 --- a/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts +++ b/web/src/engine/keyboard/src/keyboards/loaders/domKeyboardLoader.ts @@ -38,17 +38,17 @@ export class DOMKeyboardLoader extends KeyboardLoaderBase { try { response = await fetch(uri); } catch (e) { - throw errorBuilder.keyboardDownloadError(`Unable to fetch fetch keyboard at ${uri}`, e); + throw errorBuilder.keyboardDownloadError(e); } if (!response.ok) { - throw errorBuilder.missingError(new Error(`HTTP ${response.status} ${response.statusText}`)); + throw errorBuilder.keyboardDownloadError(new Error(`HTTP ${response.status} ${response.statusText}`)); } try { return await response.blob(); } catch (e) { - throw errorBuilder.keyboardDownloadError(`Unable to retrieve blob from keyboard at ${uri}`, e); + throw errorBuilder.invalidKeyboard(e); } } diff --git a/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts b/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts index a63181fe8bc..8f568840ffd 100644 --- a/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts +++ b/web/src/engine/keyboard/src/keyboards/loaders/nodeKeyboardLoader.ts @@ -36,7 +36,7 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase { try { buffer = await readFile(uri); } catch (err) { - throw errorBuilder.keyboardDownloadError(`Unable to read keyboard file at ${uri}`, err); + throw errorBuilder.keyboardDownloadError(err); } return new Blob([buffer]); } @@ -46,7 +46,7 @@ export class NodeKeyboardLoader extends KeyboardLoaderBase { try { script = new vm.Script(scriptSrc); } catch (err) { - throw errorBuilder.missingError(err); + throw errorBuilder.invalidKeyboard(err); } try { script.runInContext(this.harness._jsGlobal); diff --git a/web/src/test/auto/dom/cases/keyboard/domKeyboardLoader.spec.ts b/web/src/test/auto/dom/cases/keyboard/domKeyboardLoader.spec.ts index 95db91b0f8f..7f2f48e2c12 100644 --- a/web/src/test/auto/dom/cases/keyboard/domKeyboardLoader.spec.ts +++ b/web/src/test/auto/dom/cases/keyboard/domKeyboardLoader.spec.ts @@ -1,8 +1,9 @@ import { assert } from 'chai'; import { DOMKeyboardLoader } from 'keyman/engine/keyboard/dom-keyboard-loader'; -import { extendString, KeyboardHarness, Keyboard, MinimalKeymanGlobal, DeviceSpec, KeyboardKeymanGlobal } from 'keyman/engine/keyboard'; +import { extendString, KeyboardHarness, Keyboard, MinimalKeymanGlobal, DeviceSpec, KeyboardKeymanGlobal, KeyboardDownloadError, KeyboardScriptError } from 'keyman/engine/keyboard'; import { KeyboardInterface, Mock } from 'keyman/engine/js-processor'; +import { assertThrowsAsync } from 'keyman/tools/testing/test-utils'; declare let window: typeof globalThis; // KeymanEngine from the web/ folder... when available. @@ -27,6 +28,24 @@ describe('Keyboard loading in DOM', function() { } }) + it('throws error when keyboard does not exist', async () => { + const harness = new KeyboardInterface(window, MinimalKeymanGlobal); + const keyboardLoader = new DOMKeyboardLoader(harness); + const nonExisting = '/does/not/exist.js'; + + await assertThrowsAsync(async () => await keyboardLoader.loadKeyboardFromPath(nonExisting), + KeyboardDownloadError, `Unable to download keyboard at ${nonExisting}`); + }); + + it('throws error when keyboard is invalid', async () => { + const harness = new KeyboardInterface(window, MinimalKeymanGlobal); + const keyboardLoader = new DOMKeyboardLoader(harness); + const nonKeyboardPath = '/common/test/resources/index.mjs'; + + await assertThrowsAsync(async () => await keyboardLoader.loadKeyboardFromPath(nonKeyboardPath), + KeyboardScriptError, `Error registering the keyboard script at ${nonKeyboardPath}; it may contain an error.`); + }); + it('`window`, disabled rule processing', async () => { const harness = new KeyboardHarness(window, MinimalKeymanGlobal); let keyboardLoader = new DOMKeyboardLoader(harness); diff --git a/web/src/test/auto/headless/engine/keyboard/keyboard.tests.ts b/web/src/test/auto/headless/engine/keyboard/keyboard.tests.ts new file mode 100644 index 00000000000..e089b64fe7b --- /dev/null +++ b/web/src/test/auto/headless/engine/keyboard/keyboard.tests.ts @@ -0,0 +1,34 @@ +import { assert } from 'chai'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +import { KeyboardHarness, MinimalKeymanGlobal, DeviceSpec } from 'keyman/engine/keyboard'; +import { NodeKeyboardLoader } from 'keyman/engine/keyboard/node-keyboard-loader'; + + +describe('Keyboard tests', function () { + const khmerPath = require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js'); + + it('accurately determines layout properties', async () => { + // -- START: Standard Recorder-based unit test loading boilerplate -- + const harness = new KeyboardHarness({}, MinimalKeymanGlobal); + const keyboardLoader = new NodeKeyboardLoader(harness); + const km_keyboard = await keyboardLoader.loadKeyboardFromPath(khmerPath); + // -- END: Standard Recorder-based unit test loading boilerplate -- + + // `khmer_angkor` - supports longpresses, but not flicks or multitaps. + + // Phone supports longpress if the keyboard supports it. + const mobileLayout = km_keyboard.layout(DeviceSpec.FormFactor.Phone); + assert.isTrue(mobileLayout.hasLongpresses); + assert.isFalse(mobileLayout.hasFlicks); + assert.isFalse(mobileLayout.hasMultitaps); + + // Desktop doesn't support longpress even if the keyboard supports it. + const desktopLayout = km_keyboard.layout(DeviceSpec.FormFactor.Desktop); + assert.isFalse(desktopLayout.hasLongpresses); + assert.isFalse(desktopLayout.hasFlicks); + assert.isFalse(desktopLayout.hasMultitaps); + }); +}); diff --git a/web/src/test/auto/headless/engine/keyboard/keyboard-loading.tests.ts b/web/src/test/auto/headless/engine/keyboard/keyboardLoaderBase.tests.ts similarity index 57% rename from web/src/test/auto/headless/engine/keyboard/keyboard-loading.tests.ts rename to web/src/test/auto/headless/engine/keyboard/keyboardLoaderBase.tests.ts index a01d469e470..80fd0a21c2f 100644 --- a/web/src/test/auto/headless/engine/keyboard/keyboard-loading.tests.ts +++ b/web/src/test/auto/headless/engine/keyboard/keyboardLoaderBase.tests.ts @@ -3,41 +3,13 @@ import { assert } from 'chai'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); -import { KeyboardHarness, MinimalKeymanGlobal, KeyboardDownloadError, DeviceSpec, KeyboardMissingError } from 'keyman/engine/keyboard'; +import { KeyboardHarness, MinimalKeymanGlobal, KeyboardDownloadError, DeviceSpec, InvalidKeyboardError } from 'keyman/engine/keyboard'; import { KeyboardInterface, Mock } from 'keyman/engine/js-processor'; import { NodeKeyboardLoader } from 'keyman/engine/keyboard/node-keyboard-loader'; - -// async function assertThrowsAsync(fn: () => Promise, message?: string): Promise; -async function assertThrowsAsync(fn: () => Promise, type?: any, message?: string): Promise { - if (typeof (type) === 'string') { - message = type; - type = undefined; - } - try { - await fn(); - assert.fail('Expected function to throw an error, but it did not.'); - } catch (err) { - if (type) { - assert.isTrue(err instanceof type, `Expected error to be of type ${type.name}, but got ${err.constructor.name}`); - } - if (message) { - assert.equal((err as Error).message, message); - } - } -} - -function assertThrows(fn: () => any, message?: string): void; -function assertThrows(fn: () => any, type?: any, message?: string): void { - if (typeof (type) === 'string') { - message = type; - type = undefined; - } - assert.throws(fn, type, message); -} +import { assertThrowsAsync, assertThrows } from 'keyman/tools/testing/test-utils'; describe('Headless keyboard loading', function() { const laoPath = require.resolve('@keymanapp/common-test-resources/keyboards/lao_2008_basic.js'); - const khmerPath = require.resolve('@keymanapp/common-test-resources/keyboards/khmer_angkor.js'); const nonKeyboardPath = require.resolve('@keymanapp/common-test-resources/index.mjs'); const ipaPath = require.resolve('@keymanapp/common-test-resources/keyboards/sil_ipa.js'); const nonExisting = '/does/not/exist.js'; @@ -51,7 +23,7 @@ describe('Headless keyboard loading', function() { } describe('Minimal harness loading', () => { - it('successfully loads', async function() { + it('successfully loads a single keyboard from filesystem', async () => { // -- START: Standard Recorder-based unit test loading boilerplate -- const harness = new KeyboardInterface({}, MinimalKeymanGlobal); const keyboardLoader = new NodeKeyboardLoader(harness); @@ -68,20 +40,20 @@ describe('Headless keyboard loading', function() { assert.equal(keyboard.id, "Keyboard_lao_2008_basic"); }); - it('throws error when keyboard does not exist', async function () { + it('throws error when keyboard does not exist', async () => { const harness = new KeyboardInterface({}, MinimalKeymanGlobal); const keyboardLoader = new NodeKeyboardLoader(harness); - await assertThrowsAsync(() => keyboardLoader.loadKeyboardFromPath(nonExisting), - KeyboardDownloadError, `Unable to read keyboard file at ${nonExisting}`); + await assertThrowsAsync(async () => await keyboardLoader.loadKeyboardFromPath(nonExisting), + KeyboardDownloadError, `Unable to download keyboard at ${nonExisting}`); }); - it('throws error when keyboard is invalid', async function () { + it('throws error when keyboard is invalid', async () => { const harness = new KeyboardInterface({}, MinimalKeymanGlobal); const keyboardLoader = new NodeKeyboardLoader(harness); - await assertThrowsAsync(() => keyboardLoader.loadKeyboardFromPath(nonKeyboardPath), - KeyboardMissingError, `Cannot find the keyboard at ${nonKeyboardPath}.`); + await assertThrowsAsync(async () => await keyboardLoader.loadKeyboardFromPath(nonKeyboardPath), + InvalidKeyboardError, `${nonKeyboardPath} is not a valid keyboard file`); }); it('successfully loads (has variable stores)', async () => { @@ -104,29 +76,12 @@ describe('Headless keyboard loading', function() { // Runs a blank KeyEvent through the keyboard's rule processing... // but via separate harness configured with a different captured global. + // This shows an important detail: the 'global' object is effectively + // closure-captured. (Similar constraints may occur when experimenting with + // 'sandboxed' keyboard loading in the DOM!) const ruleHarness = new KeyboardInterface({}, MinimalKeymanGlobal); ruleHarness.activeKeyboard = keyboard; assertThrows(() => ruleHarness.processKeystroke(new Mock(), keyboard.constructNullKeyEvent(device)), 'k.KKM is not a function'); }); - - it('accurately determines supported gesture types', async () => { - // -- START: Standard Recorder-based unit test loading boilerplate -- - const harness = new KeyboardHarness({}, MinimalKeymanGlobal); - const keyboardLoader = new NodeKeyboardLoader(harness); - const km_keyboard = await keyboardLoader.loadKeyboardFromPath(khmerPath); - // -- END: Standard Recorder-based unit test loading boilerplate -- - - // `khmer_angkor` - supports longpresses, but not flicks or multitaps. - - const desktopLayout = km_keyboard.layout(DeviceSpec.FormFactor.Desktop); - assert.isFalse(desktopLayout.hasFlicks); - assert.isFalse(desktopLayout.hasLongpresses); - assert.isFalse(desktopLayout.hasMultitaps); - - const mobileLayout = km_keyboard.layout(DeviceSpec.FormFactor.Phone); - assert.isFalse(mobileLayout.hasFlicks); - assert.isTrue(mobileLayout.hasLongpresses); - assert.isFalse(mobileLayout.hasMultitaps); - }); }); }); diff --git a/web/src/tools/build.sh b/web/src/tools/build.sh index b7670418ab3..a2a95c8e512 100755 --- a/web/src/tools/build.sh +++ b/web/src/tools/build.sh @@ -22,7 +22,8 @@ builder_describe "Builds the Keyman Engine for Web's development & unit-testing "--ci Does nothing for this script" \ ":bulk_rendering=testing/bulk_rendering Builds the bulk-rendering tool used to validate changes to OSK display code" \ ":recorder=testing/recorder Builds the KMW recorder tool used for development of unit-test resources" \ - ":sourcemap-root=building/sourcemap-root Builds the sourcemap-cleaning tool used during minification of app/ builds" + ":sourcemap-root=building/sourcemap-root Builds the sourcemap-cleaning tool used during minification of app/ builds" \ + ":test-utils=testing/test-utils Builds the test-utils module" builder_parse "$@" diff --git a/web/src/tools/testing/test-utils/build.sh b/web/src/tools/testing/test-utils/build.sh new file mode 100755 index 00000000000..0ab8dc5a6e6 --- /dev/null +++ b/web/src/tools/testing/test-utils/build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Compile KeymanWeb's test-utils module + +## 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 + +SUBPROJECT_NAME=tools/testing/test-utils +. "$KEYMAN_ROOT/web/common.inc.sh" +. "$KEYMAN_ROOT/resources/shellHelperFunctions.sh" + +################################ Main script ################################ + +builder_describe "Builds the Keyman Engine for Web's test-utils module" \ + "clean" \ + "configure" \ + "build" + +builder_describe_outputs \ + configure /node_modules \ + build "/web/build/${SUBPROJECT_NAME}/obj/index.js" + +builder_parse "$@" + +do_build ( ) { + compile "${SUBPROJECT_NAME}" +} + +builder_run_action configure verify_npm_setup +builder_run_action clean rm -rf "../../../../build/${SUBPROJECT_NAME}/" +builder_run_action build do_build \ No newline at end of file diff --git a/web/src/tools/testing/test-utils/index.ts b/web/src/tools/testing/test-utils/index.ts new file mode 100644 index 00000000000..79f98d1a3be --- /dev/null +++ b/web/src/tools/testing/test-utils/index.ts @@ -0,0 +1,31 @@ +import { assert } from 'chai'; + +// export async function assertThrowsAsync(fn: () => Promise, message?: string): Promise; +export async function assertThrowsAsync(fn: () => Promise, type?: any, message?: string): Promise { + assert(!!type || !!message, 'at least one of type or message must be specified'); + if (typeof(type) === 'string') { + message = type; + type = undefined; + } + try { + await fn(); + assert.fail('Expected function to throw an error, but it did not.'); + } catch (err) { + if (type) { + assert.isTrue(err instanceof type, `Expected error to be of type ${type.name}, but got ${err.constructor.name}`); + } + if (message) { + assert.equal((err as Error).message, message); + } + } +} + +export function assertThrows(fn: () => any, message?: string): void; +export function assertThrows(fn: () => any, type?: any, message?: string): void { + assert(!!type || !!message, 'at least one of type or message must be specified'); + if (typeof(type) === 'string') { + message = type; + type = undefined; + } + assert.throws(fn, type, message); +} diff --git a/web/src/tools/testing/test-utils/tsconfig.json b/web/src/tools/testing/test-utils/tsconfig.json new file mode 100644 index 00000000000..65dfe3a9bfd --- /dev/null +++ b/web/src/tools/testing/test-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.dom.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "../../../../build/tools/testing/test-utils/obj/", + "rootDir": ".", + "tsBuildInfoFile": "../../../../build/tools/testing/test-utils/obj/tsconfig.tsbuildinfo" + }, + + "include": [ "*.ts" ], +} From d06aa29956227dd3277dee32fe36a94e0828f102 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 3 Sep 2024 15:53:23 +0200 Subject: [PATCH 018/254] feat(core): implement loading KMX from blob - split keyboard loading into loading KMX file into blob and then loading the keyboard processor from the blob. - deprecate `km_core_keyboard_load` - move file access next to deprecated method. This is now the only place that loads a file in Core; unit tests have some more places that load files. - introduce GTest and add unit tests for loading from blob Part-of: #11293 --- core/include/keyman/keyman_core_api.h | 66 +++++++++- core/include/keyman/keyman_core_api_bits.h | 5 +- core/src/keyboard.cpp | 7 +- core/src/keyboard.hpp | 5 +- core/src/km_core_keyboard_api.cpp | 117 +++++++++++++++--- core/src/kmx/kmx_file.cpp | 90 +++++--------- core/src/kmx/kmx_processevent.h | 4 +- core/src/kmx/kmx_processor.cpp | 9 +- core/src/kmx/kmx_processor.hpp | 2 +- core/src/ldml/ldml_processor.cpp | 41 ++---- core/src/ldml/ldml_processor.hpp | 5 +- core/src/mock/mock_processor.cpp | 2 +- core/src/mock/mock_processor.hpp | 2 +- core/src/util_normalize_table_generator.cpp | 1 - core/subprojects/.gitignore | 1 + core/subprojects/gtest.wrap | 16 +++ .../tests/unit/km_core_keyboard_api.tests.cpp | 71 +++++++++++ core/tests/unit/kmnkbd/action_set_api.cpp | 5 +- core/tests/unit/kmnkbd/debug_api.cpp | 5 +- core/tests/unit/kmnkbd/keyboard_api.cpp | 8 +- core/tests/unit/kmnkbd/state_api.cpp | 5 +- core/tests/unit/kmnkbd/state_context_api.cpp | 11 +- .../unit/kmnkbd/test_actions_get_api.cpp | 4 +- .../unit/kmnkbd/test_actions_normalize.cpp | 4 +- core/tests/unit/kmx/kmx.cpp | 4 +- core/tests/unit/kmx/kmx_external_event.cpp | 4 +- core/tests/unit/kmx/kmx_imx.cpp | 7 +- core/tests/unit/kmx/kmx_key_list.cpp | 4 +- core/tests/unit/ldml/core_ldml_min.cpp | 4 +- core/tests/unit/ldml/ldml.cpp | 4 +- core/tests/unit/ldml/ldml_test_source.cpp | 6 +- core/tests/unit/ldml/meson.build | 5 +- .../unit/ldml/test_context_normalization.cpp | 4 +- core/tests/unit/load_kmx_file.cpp | 34 +++++ core/tests/unit/load_kmx_file.hpp | 14 +++ core/tests/unit/meson.build | 30 ++++- linux/debian/libkeymancore2.symbols | 2 + 37 files changed, 440 insertions(+), 168 deletions(-) create mode 100644 core/subprojects/gtest.wrap create mode 100644 core/tests/unit/km_core_keyboard_api.tests.cpp create mode 100644 core/tests/unit/load_kmx_file.cpp create mode 100644 core/tests/unit/load_kmx_file.hpp diff --git a/core/include/keyman/keyman_core_api.h b/core/include/keyman/keyman_core_api.h index 328965e8761..588b98085fe 100644 --- a/core/include/keyman/keyman_core_api.h +++ b/core/include/keyman/keyman_core_api.h @@ -1007,7 +1007,11 @@ Provides read-only information about a keyboard. typedef struct { km_core_cu const * version_string; km_core_cu const * id; + + // TODO-web-core: Deprecate this field + // KMN_DEPRECATED km_core_path_name folder_path; + km_core_option_item const * default_options; } km_core_keyboard_attrs; @@ -1022,7 +1026,7 @@ typedef struct { : Keyman keyboard ID string. `folder_path` -: Path to the unpacked folder containing the keyboard and associated resources. +: Path to the unpacked folder containing the keyboard and associated resources (deprecated). `default_options` : Set of default values for any options included in the keyboard. @@ -1096,12 +1100,16 @@ typedef struct { ## Description +DEPRECATED: use [km_core_keyboard_load_from_blob] instead. + Parse and load keyboard from the supplied path and a pointer to the loaded keyboard -into the out paramter. +into the out parameter. ## Specification ```c */ +// TODO-web-core: Deprecate this function +// KMN_DEPRECATED_API KMN_API km_core_status km_core_keyboard_load(km_core_path_name kb_path, @@ -1140,6 +1148,60 @@ km_core_keyboard_load(km_core_path_name kb_path, ------------------------------------------------------------------------------- +# km_core_keyboard_load_from_blob() + +## Description + +Parse and load keyboard from the supplied blob and a pointer to the loaded keyboard +into the out paramter. + +## Specification + +```c */ +KMN_API +km_core_status km_core_keyboard_load_from_blob(km_core_path_name kb_name, + void* blob, + size_t blob_size, + km_core_keyboard** keyboard); + +/* +``` + +## Parameters + +`kb_name` +: a string with the name of the keyboard. + +`blob` +: a byte array containing the content of a KMX/KMX+ file. + +`blob_size` +: A pointer to a size_t variable with the size of the blob in bytes. + +`keyboard` +: A pointer to result variable: A pointer to the opaque keyboard + object returned by the Processor. This memory must be freed with a + call to [km_core_keyboard_dispose]. + +## Returns + +`KM_CORE_STATUS_OK` +: On success. + +`KM_CORE_STATUS_NO_MEM` +: In the event an internal memory allocation fails. + +`KM_CORE_STATUS_IO_ERROR` +: In the event the keyboard file is unparseable for any reason + +`KM_CORE_STATUS_INVALID_ARGUMENT` +: In the event `keyboard` is null. + +`KM_CORE_STATUS_OS_ERROR` +: Bit 31 (high bit) set, bits 0-30 are an OS-specific error code. + +------------------------------------------------------------------------------- + # km_core_keyboard_dispose() ## Description diff --git a/core/include/keyman/keyman_core_api_bits.h b/core/include/keyman/keyman_core_api_bits.h index e00f4698a8c..bd1f519bffe 100644 --- a/core/include/keyman/keyman_core_api_bits.h +++ b/core/include/keyman/keyman_core_api_bits.h @@ -23,7 +23,6 @@ #define _kmn_unused(x) UNUSED_ ## x __attribute__((__unused__)) #else #define _kmn_unused(x) UNUSED_ ## x - #endif #if defined _WIN32 || defined __CYGWIN__ @@ -36,7 +35,7 @@ #undef _kmn_static_flag #else // How MSVC sepcifies function level attributes adn deprecation #define _kmn_and - #define _kmn_tag_fn(a) __declspec(a) + #define _kmn_tag_fn(a) __declspec(a) #define _kmn_deprecated_flag deprecated #endif #define _kmn_export_flag dllexport @@ -48,6 +47,8 @@ #define _KM_CORE_EXT_SEPARATOR ('.') #endif +#define KMN_DEPRECATED _kmn_tag_fn(_kmn_deprecated_flag) + #if defined KM_CORE_LIBRARY_STATIC #define KMN_API _kmn_tag_fn(_kmn_static_flag) #define KMN_DEPRECATED_API _kmn_tag_fn(_kmn_deprecated_flag _kmn_and _kmn_static_flag) diff --git a/core/src/keyboard.cpp b/core/src/keyboard.cpp index 2c3a0c11b2c..0f104f9422a 100644 --- a/core/src/keyboard.cpp +++ b/core/src/keyboard.cpp @@ -17,18 +17,16 @@ void keyboard_attributes::render() // Make attributes point to the stored values above. id = _keyboard_id.c_str(); version_string = _version_string.c_str(); - folder_path = _folder_path.c_str(); default_options = _default_opts.data(); } keyboard_attributes::keyboard_attributes(std::u16string const & kbid, std::u16string const & version, - path_type const & path, options_store const &opts) : _keyboard_id(kbid), _version_string(version), - _folder_path(path), + _folder_path(""), _default_opts(opts) { // Ensure that the default_options array will be properly terminated. @@ -40,7 +38,7 @@ keyboard_attributes::keyboard_attributes(std::u16string const & kbid, keyboard_attributes::keyboard_attributes(keyboard_attributes &&rhs) : _keyboard_id(std::move(rhs._keyboard_id)), _version_string(std::move(rhs._version_string)), - _folder_path(std::move(rhs._folder_path)), + _folder_path(""), _default_opts(std::move(rhs._default_opts)) { rhs.id = rhs.version_string = nullptr; @@ -58,7 +56,6 @@ json & km::core::operator << (json & j, km::core::keyboard_attributes const & kb { j << json::object << "id" << kb.id - << "folder" << kb._folder_path << "version" << kb.version_string << "rules" << json::array << json::close; diff --git a/core/src/keyboard.hpp b/core/src/keyboard.hpp index 142bf40e860..2ca7118d870 100644 --- a/core/src/keyboard.hpp +++ b/core/src/keyboard.hpp @@ -26,6 +26,7 @@ namespace core { std::u16string _keyboard_id; std::u16string _version_string; + // unused and deprecated core::path _folder_path; std::vector