diff --git a/HISTORY.md b/HISTORY.md index 0b303de9aa..cc860acc0b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,48 @@ # Keyman Version History +## 19.0.144 alpha 2025-10-23 + +* chore(deps-dev): bump playwright from 1.46.1 to 1.56.1 (#14984) +* chore(web): revert "chore(deps-dev): bump playwright from 1.46.1 to 1.56.1" (#15000) + +## 19.0.143 alpha 2025-10-20 + +* fix(android): Set the main app background white (#14935) + +## 19.0.142 alpha 2025-10-17 + +* chore(common): add .model_info schema (#14967) +* chore(web): update helpURL documentation and float ui link (#14960) +* fix(linux): use `ibus` command to restart ibus (#14971) +* chore(linux): replace deprecated `parse_version` (#14973) + +## 19.0.141 alpha 2025-10-16 + +* refactor(linux): extract functions (#14937) +* refactor(linux): remove no-longer-needed Python 3.7 code (#14956) +* chore(linux): don't report errors to Sentry when running autopkgtests (#14938) +* fix(linux): fix installing keyboard in shared area (#14953) +* fix(linux): install schema during `keyman-config/build.sh install` (#14962) +* test(linux): add regression tests for cli tools to autopkgtests (#14950) + +## 19.0.140 alpha 2025-10-15 + +* fix(mac): add Microsoft Word to non-compliant app list (#14940) + +## 19.0.139 alpha 2025-10-13 + +* fix(windows): osk: handle simulated left Control event when AltGr pressed (#14909) + +## 19.0.138 alpha 2025-10-11 + +* fix(android): Update Sample1 text field to resize (#14922) + +## 19.0.137 alpha 2025-10-10 + +* chore(common): Update crowdin strings for Vietnamese (#14915) +* maint(resources): fix watch bug for common files, and add more logging for triggering builds (#14893) +* docs(developer): Update keyboard-editor.md to clarify when entire row will be deleted (#14919) + ## 19.0.136 alpha 2025-10-09 * fix(android): Refactor bottom inset calculations for in-app and system keyboard (#14873) @@ -846,6 +889,15 @@ * refactor(windows): rename `TKeymanMutex.MutexOwned` to `TakeOwnership` and add `ReleaseOwnership` (#13168) * chore: increment to alpha 19.0 (#13187) +## 18.0.243 stable 2025-10-15 + +* chore(linux): Update debian changelog (#14855) +* fix(android): Convert longpress delay menu to ConstraintLayout (#14906) +* fix(linux): replace `dbus-x11` dependency (#14898) +* chore(ios): renew certificate (#14917) +* fix(android): Refactor bottom inset calculations for inapp and system keyboard (#14913) +* fix(mac): add Microsoft Word to non-compliant app list (#14951) + ## 18.0.242 stable 2025-09-29 * maint(linux): remove Oracular, add Questing (#14781) diff --git a/VERSION.md b/VERSION.md index 2114f1812f..d4ef205909 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.137 \ No newline at end of file +19.0.145 \ No newline at end of file diff --git a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/MainActivity.java b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/MainActivity.java index 1b0aff06eb..89ad50d046 100644 --- a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/MainActivity.java +++ b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/MainActivity.java @@ -619,11 +619,14 @@ public class MainActivity extends BaseActivity implements OnKeyboardEventListene getTheme().resolveAttribute(android.R.attr.actionBarSize, outValue, true); int actionBarHeight = getResources().getDimensionPixelSize(outValue.resourceId); - // *** TO DO: Try to check if status bar is visible, set statusBarHeight to 0 if it is not visible *** + // Status bar height only used for portrait orientation int statusBarHeight = 0; - int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android"); - if (resourceId > 0) { - statusBarHeight = getResources().getDimensionPixelSize(resourceId); + if (lastOrientation == Configuration.ORIENTATION_PORTRAIT) { + // *** TO DO: Try to check if status bar is visible, set statusBarHeight to 0 if it is not visible *** + int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android"); + if (resourceId > 0) { + statusBarHeight = getResources().getDimensionPixelSize(resourceId); + } } int navigationBarHeight = KMManager.getNavigationBarHeight(context, KeyboardType.KEYBOARD_TYPE_INAPP); diff --git a/android/KMAPro/kMAPro/src/main/res/layout/activity_main.xml b/android/KMAPro/kMAPro/src/main/res/layout/activity_main.xml index 5a468df05e..ffcd4a2421 100644 --- a/android/KMAPro/kMAPro/src/main/res/layout/activity_main.xml +++ b/android/KMAPro/kMAPro/src/main/res/layout/activity_main.xml @@ -8,7 +8,7 @@ android:fitsSystemWindows="true" android:layout_above="@+id/KMKeyboard" android:orientation="vertical" - android:background="@color/yellow_background" + android:background="@android:color/white" tools:context=".MainActivity"> 0) - statusBarHeight = getResources().getDimensionPixelSize(resourceId); - - // Navigation bar height - int navigationBarHeight = 0; - resourceId = getResources().getIdentifier("navigation_bar_height", "dimen", "android"); if (resourceId > 0) { - navigationBarHeight = getResources().getDimensionPixelSize(resourceId); + statusBarHeight = getResources().getDimensionPixelSize(resourceId); } + int navigationBarHeight = KMManager.getNavigationBarHeight(context, KeyboardType.KEYBOARD_TYPE_INAPP); Point size = KMManager.getWindowSize(context); int screenHeight = size.y; diff --git a/android/Samples/KMSample1/app/src/main/res/layout/activity_main.xml b/android/Samples/KMSample1/app/src/main/res/layout/activity_main.xml index 6ec32a3923..f87524457e 100644 --- a/android/Samples/KMSample1/app/src/main/res/layout/activity_main.xml +++ b/android/Samples/KMSample1/app/src/main/res/layout/activity_main.xml @@ -1,3 +1,4 @@ + use(main) + +group(main) using keys + ++ 'a' > 'aah' diff --git a/developer/src/kmc-kmn/test/kmw/kmw-messages.tests.ts b/developer/src/kmc-kmn/test/kmw/kmw-messages.tests.ts index 3a6fc5dd0f..a69db77020 100644 --- a/developer/src/kmc-kmn/test/kmw/kmw-messages.tests.ts +++ b/developer/src/kmc-kmn/test/kmw/kmw-messages.tests.ts @@ -45,6 +45,12 @@ describe('KmwCompilerMessages', function () { await testForMessage(this, ['kmw', 'validate_gesture.kmn'], KmwCompilerMessages.HINT_TouchLayoutUsesUnsupportedGesturesDownlevel); }); + // ERROR_InvalidBegin (from KmnCompilerMessages, but in KeymanWeb compiler context) + + it('should generate ERROR_InvalidBegin if the keyboard has no "begin unicode" statement', async function() { + await testForMessage(this, ['invalid-keyboards', 'ansi.kmn'], KmwCompilerMessages.ERROR_InvalidBegin); + }); + // TODO: other messages // WARN_ExtendedShiftFlagsNotSupportedInKeymanWeb: diff --git a/developer/src/tike/xml/app/editor/language.keyman.js b/developer/src/tike/xml/app/editor/language.keyman.js index e758af12cf..3fb1cf0aaf 100644 --- a/developer/src/tike/xml/app/editor/language.keyman.js +++ b/developer/src/tike/xml/app/editor/language.keyman.js @@ -53,8 +53,8 @@ define("language.keyman", ["require", "exports"], function (require, exports) { [/\[/, { token: 'tag', bracket: '@open', next: '@virtualKey' } ], // strings - [/"([^"]|\\.)*$/, 'string.invalid' ], // non-teminated string - [/'([^']|\\.)*$/, 'string.invalid' ], // non-teminated string + [/"([^"])*$/, 'string.invalid' ], // non-teminated string + [/'([^'])*$/, 'string.invalid' ], // non-teminated string [/"/, { token: 'string.quote', bracket: '@open', next: '@stringDouble' } ], [/'/, { token: 'string.quote', bracket: '@open', next: '@stringSingle' } ], ], diff --git a/docs/linux/keyman-config.md b/docs/linux/keyman-config.md index 1a039d0f5e..88939ea3e4 100644 --- a/docs/linux/keyman-config.md +++ b/docs/linux/keyman-config.md @@ -4,36 +4,49 @@ Code for `km-config` is in [linux/keyman-config](../../linux/keyman-config/). ## Preparing to run +### Install Dependencies + If you are running from the repo or installing keyman-config manually rather than from a package then you will need to: ```bash sudo apt install python3-lxml python3-magic python3-numpy python3-qrcode python3-pil \ python3-requests python3-requests-cache python3 python3-gi gir1.2-webkit2-4.1 dconf-cli \ - python3-setuptools python3-pip python3-dbus ibus libglib2.0-bin liblocale-gettext-perl + python3-setuptools python3-pip python3-dbus ibus libglib2.0-bin liblocale-gettext-perl \ + python3-build python3-fonttools python3-sentry-sdk ``` -Either `python3-raven` or `python3-sentry-sdk` (>= 1.4) is required as well. -On Ubuntu 22.04 and later run: +Newer Ubuntu versions (>= 24.04) don't allow to modify the environment and directly +install non-Debian-packaged Python packages. Instead you'll have to +run the commands in a virtual environment. + +This requires the installation of some additional dependencies: ```bash -sudo apt install python3-sentry-sdk +sudo apt install python3-venv libglib2.0-dev libdbus-1-dev pkg-config +libcairo2-dev libgirepository-2.0-dev ``` -To install it on Ubuntu 18.04 and earlier run: +Then create the virtual environment with `python3 -m venv km-venv` and activate +it with `source km-venv/bin/activate`. -```bash -sudo apt install python3-raven -``` +### Prepare code -For Ubuntu versions that don't provide `python3-raven` but instead provide -`python3-sentry-sdk` in a too old version (i.e. Ubuntu 20.04): -Install `python3-sentry-sdk` from `packages.sil.org`, -or install it with pip: +Run `linux/keyman-config/build.sh build` to prepare the code. -```bash -pip3 install sentry-sdk -``` +### Installation + +You can install the files with `linux/keyman-config/build.sh install`. +This will install the files into `/usr/local`. + +To uninstall you can run `linux/keyman-config/build.sh uninstall`. + +## Manually installing + +If you prefer to manually copy the files you'll still have to run the +build step above. In addition you'll have to copy the GSettings schema: + +### GSettings schema Copy and compile the GSettings schema: @@ -48,15 +61,6 @@ Running `km-config` requires a language tag mapping file `keyman_config/standards/lang_tags_map.py`. This file gets generated during a package build, and also when running `build.sh build`. -## Installing manually from the repo - -`linux/keyman-config/build.sh build && sudo linux/keyman-config/build.sh install` -will install locally to `/usr/local`. - -`pip3 help install` will give you more install options. - -To uninstall you can run `sudo linux/keyman-config/build.sh uninstall`. - ## Things to run from the command line ### km-config @@ -119,15 +123,17 @@ Secondary-click gives you a menu including 'Back' to go back a page. ### km-kvk2ldml -`km-kvk2ldml [-p] [-k] [-o LDMLFILE] ` Convert a Keyman kvk on-screen keyboard file to an LDML file. Optionally print the details of the kvk file (`-p`) optionally with all keys (`-k`). +`km-kvk2ldml [-p] [-k] [-o LDMLFILE] ` Convert a Keyman kvk on-screen +keyboard file to an LDML file. Optionally print the details of the kvk file +(`-p`) optionally with all keys (`-k`). + +This command is run automatically when installing a keyboard package to create +the necessary files for the onboard onscreen keyboard. ## Building the Debian package -You will need the build dependencies as well as the runtime dependencies above - -`sudo apt install dh-python python3-all debhelper help2man` - -Run `make deb`. This will build the Debian package in the `make_deb` directory. +See the [packaging.md](https://github.com/keymanapp/keyman/blob/master/docs/linux/packaging.md#L26) +document. ## Internationalization diff --git a/docs/linux/packaging.md b/docs/linux/packaging.md index 8e97f8e303..dc62cd5af7 100644 --- a/docs/linux/packaging.md +++ b/docs/linux/packaging.md @@ -8,8 +8,8 @@ We use different channels to build and distribute the Linux packages: - Launchpad repo for [stable](https://launchpad.net/~keymanapp/+archive/ubuntu/keyman), [beta](https://launchpad.net/~keymanapp/+archive/ubuntu/keyman-beta) and [alpha](https://launchpad.net/~keymanapp/+archive/ubuntu/keyman-alpha) versions -- [pso](http://packages.sil.org/) and [llso](http://linux.lsdev.sil.org/ubuntu/) - for stable, beta, and alpha versions +- [pso](http://packages.sil.org/) for stable versions +- [llso](http://linux.lsdev.sil.org/ubuntu/) for stable, beta, and alpha versions - artifacts on [GitHub](https://github.com/keymanapp/keyman/actions/workflows/deb-packaging.yml) for pull requests @@ -18,6 +18,84 @@ intended as a staging environment for testing. Packages on [pso](http://packages are uploaded manually after the packages received some testing. End users usually have only pso enabled. +## Building packages locally + +It is possible to build packages locally. This can be done either directly on the machine, +or in a Docker container which is more similar to the CI environment. + +### Building packages locally directly on the machine + +To build packages locally, you'll have to have the dependencies installed. + +The easiest way to achieve this is to use the `mk-build-deps` tool: + +```bash +sudo apt install dh-python python3-all debhelper help2man devscripts \ + equivs jq quilt +sudo mk-build-deps --install linux/debian/control +``` + +Then you can build the Debian package with: + +```bash +KEYMAN_TIER=$(cat TIER.md) +export KEYMAN_TIER +cd linux +./scripts/deb-packaging.sh source +mkdir -p make_deb +cd make_deb +dpkg-source --extract ../../keyman_*.dsc +cd keyman-* +dch -v$(cat VERSION.md)-1 "" +debuild -us -uc +``` + +### Local package builds with Docker + +It is possible to use the usual Debian/Ubuntu tools to create the package locally. +For someone who only occasionally deals with packaging it might be easier to use +Docker and the scripts that run on GitHub actions: + +#### Prerequisites for local package builds with Docker + +You'll have to create the docker image. + +- clone [gha-ubuntu-packaging](https://github.com/sillsdev/gha-ubuntu-packaging) + repo +- create the image: + + ```bash + cd /path/to/gha-ubuntu-packaging + docker build --build-arg DIST=jammy --build-arg PLATFORM=amd64 -t sillsdev/jammy . + ``` + +#### Building packages with Docker + +- create the source package + + ```bash + cd $KEYMAN_ROOT + KEYMAN_TIER=$(cat TIER.md) + export KEYMAN_TIER + cd linux + ./scripts/deb-packaging.sh source + ``` + + This will create the source package in $KEYMAN_ROOT directory. + +- Create the binary packages with Docker: + + ```bash + cd $KEYMAN_ROOT + DIST=jammy + docker run -v $(pwd):/source -i -t -w /source --platform=amd64 \ + --env INPUT_DIST=$DIST --env INPUT_SOURCE_DIR=. \ + --env INPUT_SOURCEPACKAGE=$(ls keyman_*.dsc | sort | tail -1) \ + sillsdev/$DIST + ``` + + This will create the binary packages in `$KEYMAN_ROOT/artifacts`. + ## Package builds Package builds happen on [Launchpad](#package-builds-on-launchpad) and @@ -73,52 +151,6 @@ and of course the source code for the packages: to the main section `jammy`) - if the build is successful the job archives the artifacts -### Local package builds with Docker - -It is possible to use the usual Debian/Ubuntu tools to create the package locally. -For someone who only occasionally deals with packaging it might be easier to use -Docker and the scripts that run on GitHub actions: - -#### Prerequisites for local package builds with Docker - -You'll have to create the docker image. - -- clone [gha-ubuntu-packaging](https://github.com/sillsdev/gha-ubuntu-packaging) - repo -- create the image: - - ```bash - cd /path/to/gha-ubuntu-packaging - docker build --build-arg DIST=jammy --build-arg PLATFORM=amd64 -t sillsdev/jammy . - ``` - -#### Building packages with Docker - -- create the source package - - ```bash - cd $KEYMAN_ROOT - KEYMAN_TIER=$(cat TIER.md) - export KEYMAN_TIER - cd linux - ./scripts/deb-packaging.sh source - ``` - - This will create the source package in $KEYMAN_ROOT directory. - -- Create the binary packages with Docker: - - ```bash - cd $KEYMAN_ROOT - DIST=jammy - docker run -v $(pwd):/source -i -t -w /source --platform=amd64 \ - --env INPUT_DIST=$DIST --env INPUT_SOURCE_DIR=. \ - --env INPUT_SOURCEPACKAGE=$(ls keyman_*.dsc | sort | tail -1) \ - sillsdev/$DIST - ``` - - This will create the binary packages in `$KEYMAN_ROOT/artifacts`. - ## Package builds on Launchpad Package builds on Launchpad are triggered manually by running the Keyman script diff --git a/linux/debian/source/include-binaries b/linux/debian/source/include-binaries new file mode 100644 index 0000000000..6858e3af25 --- /dev/null +++ b/linux/debian/source/include-binaries @@ -0,0 +1,2 @@ +debian/tests/data/alephwithbeth.kmp +debian/tests/data/khmer_angkor.kmp diff --git a/linux/debian/tests/README.md b/linux/debian/tests/README.md index 9327defb8b..60867a8681 100644 --- a/linux/debian/tests/README.md +++ b/linux/debian/tests/README.md @@ -24,11 +24,40 @@ lowercase ASCII letters, plus or minus signs, dots or slashes. For example: ```bash - autopkgtest-buildvm-ubuntu-cloud -v --release=jammy + autopkgtest-buildvm-ubuntu-cloud -v --release=noble ``` - Run tests ```bash - autopkgtest -B *.deb keyman_*.dsc -- qemu autopkgtest-jammy-amd64.img + autopkgtest --apt-upgrade --no-built-binaries *.deb keyman_*.dsc -- qemu autopkgtest-noble-amd64.img ``` + +## Developing tests + +While developing tests, it is possible to run the tests directly from +the source tree: + +```bash +cd linux +autopkgtest --apt-upgrade --no-built-binaries ../artifacts/*.deb . -- qemu /path/to/autopkgtest-noble-amd64.img +``` + +This requires packages to be available in `${KEYMAN_ROOT}/artifacts`. +These can be built from source with: + +```bash +cd linux +rm -rf debian/patches/ keyman-config/keyman_config/.pc +scripts/deb-packaging.sh source +DIST=noble +cd .. +docker run -v $(pwd):/source -i -t -w /source --platform=amd64 \ + --env INPUT_DIST=$DIST --env INPUT_SOURCE_DIR=. \ + --env INPUT_SOURCEPACKAGE=$(ls keyman_*.dsc | sort | tail -1) \ + sillsdev/$DIST +``` + +New packages have to be built for changes outside of `linux/debian`. +Changes in files in `linux/debian` are automatically considered when +running `autopkgtest`. diff --git a/linux/debian/tests/control b/linux/debian/tests/control index 71c228c089..9163c8c213 100644 --- a/linux/debian/tests/control +++ b/linux/debian/tests/control @@ -1,4 +1,4 @@ -Tests: test-build +Tests: test-build test-gnome-cli test-ibus-cli Depends: @, build-essential, pkg-config, diff --git a/linux/debian/tests/data/alephwithbeth.kmp b/linux/debian/tests/data/alephwithbeth.kmp new file mode 100644 index 0000000000..d432c48d21 Binary files /dev/null and b/linux/debian/tests/data/alephwithbeth.kmp differ diff --git a/linux/debian/tests/data/khmer_angkor.kmp b/linux/debian/tests/data/khmer_angkor.kmp new file mode 100644 index 0000000000..5c18f9d277 Binary files /dev/null and b/linux/debian/tests/data/khmer_angkor.kmp differ diff --git a/linux/debian/tests/test-cli.inc.sh b/linux/debian/tests/test-cli.inc.sh new file mode 100644 index 0000000000..233af94167 --- /dev/null +++ b/linux/debian/tests/test-cli.inc.sh @@ -0,0 +1,100 @@ +# shellcheck shell=bash + +verify_ibus_restarted() { + local ibus_count new_pid + + ibus_count=$(pgrep --count ibus-daemon) + if ! (( ibus_count == 1 )); then + echo "FAIL ${TEST_SCRIPT}/${FUNCNAME[1]}: ibus-daemon not running" + exit 1 + fi + new_pid=$(pgrep ibus-daemon) + if [[ "${new_pid}" == "${ibus_pid}" ]]; then + echo "FAIL ${TEST_SCRIPT}/${FUNCNAME[1]}: ibus-daemon was not restarted" + exit 1 + fi + ibus_pid="${new_pid}" +} + +verify_gnome_input_sources() { + local expected_sources="$1" + local sources + + sources=$(gsettings get org.gnome.desktop.input-sources sources) + if [[ "${sources}" != "${expected_sources}" ]]; then + echo "FAIL ${TEST_SCRIPT}/${FUNCNAME[1]}: input-sources wrong. Got ${sources} instead of ${expected_sources}" + exit 1 + fi +} + +verify_ibus_preload_engines() { + local expected="$1" + local sources + + sources=$(gsettings get org.freedesktop.ibus.general preload-engines) + if [[ "${sources}" != "${expected}" ]]; then + echo "FAIL ${TEST_SCRIPT}/${FUNCNAME[1]}: preload-engines wrong. Got ${sources} instead of ${expected}" + exit 1 + fi +} + +verify_custom_keyboards() { + local expected_custom_keyboards="$1" + local custom_keyboards + + custom_keyboards=$(gsettings get com.keyman.engine additional-keyboards) + if [[ "${custom_keyboards}" != "${expected_custom_keyboards}" ]]; then + echo "FAIL ${TEST_SCRIPT}/${FUNCNAME[1]}: custom keyboards wrong. Got ${custom_keyboards} instead of ${expected_custom_keyboards}" + exit 1 + fi +} + +verify_keyboard_file_exists() { + local expected_keyboard_file="$1" + + if [[ ! -f "${expected_keyboard_file}" ]]; then + echo "FAIL ${TEST_SCRIPT}/${FUNCNAME[1]}: keyboard file ${expected_keyboard_file} not found" + exit 1 + fi +} + +verify_keyboard_file_not_exists() { + local expected_keyboard_file="$1" + + if [[ -f "${expected_keyboard_file}" ]]; then + echo "FAIL ${FUNCNAME[1]}: keyboard file ${expected_keyboard_file} still exists" + exit 1 + fi +} + +verify_no_error() { + local errocode="$1" + + if [[ "${errocode}" != "0" ]]; then + echo "FAIL ${TEST_SCRIPT}/${FUNCNAME[1]}: non-zero exit code ${errocode}." + exit 1 + fi +} + +run_tests() { + export SLEEP_TIME=5 + export TEST_SCRIPT + TEST_SCRIPT="$(basename "${BASH_SOURCE[${#BASH_SOURCE[@]}-1]}")" + + gsettings reset com.keyman.engine additional-keyboards + + echo "Keyman Version: $(km-config --version)" + if ! pgrep --uid "$(id -u)" ibus-daemon > /dev/null; then + echo "Starting ibus" + ibus start -d + sleep "${SLEEP_TIME}" + else + echo "ibus-daemon already running" + fi + + km_package_install__user + km_package_install__shared + km_package_list_installed + km_package_uninstall__user + km_package_uninstall__shared +} diff --git a/linux/debian/tests/test-gnome-cli b/linux/debian/tests/test-gnome-cli new file mode 100755 index 0000000000..4b46f1fa97 --- /dev/null +++ b/linux/debian/tests/test-gnome-cli @@ -0,0 +1,104 @@ +#!/bin/bash + +# shellcheck disable=SC1091 +. "$(dirname "$0")/test-cli.inc.sh" + +#------------------------------------------------------------------------------- +# gnome-shell tests +#------------------------------------------------------------------------------- + +km_package_install__user() { + # Setup + gsettings set org.gnome.desktop.input-sources sources "[('xkb', 'us')]" + + # Run + km-package-install --file "$(dirname "$0")/data/khmer_angkor.kmp" + + # Verify + verify_no_error $? + verify_ibus_restarted + verify_keyboard_file_exists "${HOME}/.local/share/keyman/khmer_angkor/khmer_angkor.kmx" + verify_gnome_input_sources "[('xkb', 'us'), ('ibus', 'km:${HOME}/.local/share/keyman/khmer_angkor/khmer_angkor.kmx')]" + verify_custom_keyboards "@as []" + + echo "PASS ${TEST_SCRIPT}/${FUNCNAME[0]}" +} + +km_package_install__shared() { + # Setup - continue from previous tests + + # Run + sudo km-package-install --shared --bcp47 ady --file "$(dirname "$0")/data/alephwithbeth.kmp" + + # Verify + verify_no_error $? + verify_ibus_restarted + verify_keyboard_file_exists "/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx" + verify_gnome_input_sources "[('xkb', 'us'), ('ibus', 'km:${HOME}/.local/share/keyman/khmer_angkor/khmer_angkor.kmx'), ('ibus', 'ady:/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx')]" + verify_custom_keyboards "['ady:/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx']" + + echo "PASS ${TEST_SCRIPT}/${FUNCNAME[0]}" +} + +km_package_list_installed() { + # Setup - continue from previous tests + local result expected + + # Run + result=$(km-package-list-installed) + + # Verify + verify_no_error $? + expected="--- Installed user Keyman keyboard packages --- +Khmer Angkor, version: 1.5, id: khmer_angkor +--- Installed shared Keyman keyboard packages --- +Aleph With Beth, version: 1.0.6, id: alephwithbeth +--- Installed OS Keyman keyboard packages ---" + if [[ "${result}" != "${expected}" ]]; then + echo "FAIL ${TEST_SCRIPT}/${FUNCNAME[0]}: result wrong." + echo "EXPECTED: +${expected}" + echo "ACTUAL: +${result}" + exit 1 + fi + + echo "PASS ${FUNCNAME[0]}" +} + +km_package_uninstall__user() { + # Setup - continue from previous tests + + # Run + km-package-uninstall khmer_angkor + + # Verify + verify_no_error $? + verify_keyboard_file_not_exists "${HOME}/.local/share/keyman/khmer_angkor/khmer_angkor.kmx" + verify_gnome_input_sources "[('xkb', 'us'), ('ibus', 'ady:/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx')]" + verify_custom_keyboards "['ady:/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx']" + + echo "PASS ${TEST_SCRIPT}/${FUNCNAME[0]}" +} + +km_package_uninstall__shared() { + # Setup - continue from previous tests + + # Run + sudo km-package-uninstall --shared alephwithbeth + + # Verify + verify_no_error $? + verify_keyboard_file_not_exists "/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx" + verify_gnome_input_sources "[('xkb', 'us')]" + verify_custom_keyboards "@as []" + + echo "PASS ${TEST_SCRIPT}/${FUNCNAME[0]}" +} + +#------------------------------------------------------------------------------- + +# Simulate running gnome-shell +export XDG_CURRENT_DESKTOP=gnome + +run_tests diff --git a/linux/debian/tests/test-ibus-cli b/linux/debian/tests/test-ibus-cli new file mode 100755 index 0000000000..6a44bb8e33 --- /dev/null +++ b/linux/debian/tests/test-ibus-cli @@ -0,0 +1,97 @@ +#!/bin/bash + +# shellcheck disable=SC1091 +. "$(dirname "$0")/test-cli.inc.sh" + +km_package_install__user() { + # Setup + gsettings set org.freedesktop.ibus.general preload-engines "['xkb:us::eng']" + + # Run + km-package-install --file "$(dirname "$0")/data/khmer_angkor.kmp" + + # Verify + verify_no_error $? + verify_ibus_restarted + verify_keyboard_file_exists "${HOME}/.local/share/keyman/khmer_angkor/khmer_angkor.kmx" + verify_ibus_preload_engines "['xkb:us::eng', 'km:${HOME}/.local/share/keyman/khmer_angkor/khmer_angkor.kmx']" + verify_custom_keyboards "@as []" + + echo "PASS ${TEST_SCRIPT}/${FUNCNAME[0]}" +} + +km_package_install__shared() { + # Setup - continue from previous tests + + # Run + sudo km-package-install --shared --bcp47 ady --file "$(dirname "$0")/data/alephwithbeth.kmp" + + # Verify + verify_no_error $? + verify_ibus_restarted + verify_keyboard_file_exists "/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx" + verify_ibus_preload_engines "['xkb:us::eng', 'km:${HOME}/.local/share/keyman/khmer_angkor/khmer_angkor.kmx', 'ady:/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx']" + verify_custom_keyboards "['ady:/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx']" + + echo "PASS ${TEST_SCRIPT}/${FUNCNAME[0]}" +} + +km_package_list_installed() { + # Setup - continue from previous tests + local result expected + + # Run + result=$(km-package-list-installed) + + # Verify + verify_no_error $? + expected="--- Installed user Keyman keyboard packages --- +Khmer Angkor, version: 1.5, id: khmer_angkor +--- Installed shared Keyman keyboard packages --- +Aleph With Beth, version: 1.0.6, id: alephwithbeth +--- Installed OS Keyman keyboard packages ---" + if [[ "${result}" != "${expected}" ]]; then + echo "FAIL ${TEST_SCRIPT}/${FUNCNAME[0]}: result wrong." + echo "EXPECTED: +${expected}" + echo "ACTUAL: +${result}" + exit 1 + fi + + echo "PASS ${TEST_SCRIPT}/${FUNCNAME[0]}" +} + +km_package_uninstall__user() { + # Setup - continue from previous tests + + # Run + km-package-uninstall khmer_angkor + + # Verify + verify_no_error $? + verify_keyboard_file_not_exists "${HOME}/.local/share/keyman/khmer_angkor/khmer_angkor.kmx" + verify_ibus_preload_engines "['xkb:us::eng', 'ady:/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx']" + verify_custom_keyboards "['ady:/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx']" + + echo "PASS ${TEST_SCRIPT}/${FUNCNAME[0]}" +} + +km_package_uninstall__shared() { + # Setup - continue from previous tests + + # Run + sudo km-package-uninstall --shared alephwithbeth + + # Verify + verify_no_error $? + verify_keyboard_file_not_exists "/usr/local/share/keyman/alephwithbeth/alephwithbeth.kmx" + verify_ibus_preload_engines "['xkb:us::eng']" + verify_custom_keyboards "@as []" + + echo "PASS ${TEST_SCRIPT}/${FUNCNAME[0]}" +} + +#------------------------------------------------------------------------------- + +run_tests diff --git a/linux/keyman-config/Makefile b/linux/keyman-config/Makefile index b75c6b17ab..8176f0f144 100644 --- a/linux/keyman-config/Makefile +++ b/linux/keyman-config/Makefile @@ -1,47 +1,13 @@ #!/usr/bin/make default: - ./build.sh clean build - -install: - ./build.sh install - -uninstall: # run as sudo - ./build.sh uninstall - -clean: - ./build.sh clean - -check: - ./build.sh test - -dist: clean version - # TODO: remove this if when we no longer support Ubuntu 22.04 Jammy - if dpkg --compare-versions "$(lsb_release -r -s)" lt 24.04; then \ - python3 setup.py sdist ; \ - else \ - python3 -m build --outdir build --sdist ; \ - fi - -deb: dist - @KEYMAN_VERSION=$(shell echo `basename dist/*.gz .tar.gz|cut -d "-" -f2` > /tmp/keyman_version) - @echo KEYMAN_VERSION is $(shell cat /tmp/keyman_version) - @mkdir -p make_deb - cd make_deb && tar xf ../dist/keyman_config-$(shell cat /tmp/keyman_version).tar.gz && \ - mv keyman_config-$(shell cat /tmp/keyman_version) keyman-config-$(shell cat /tmp/keyman_version) && \ - tar cfz keyman-config_$(shell cat /tmp/keyman_version).orig.tar.gz keyman-config-$(shell cat /tmp/keyman_version) && \ - cd keyman-config-$(shell cat /tmp/keyman_version) && cp -a ../../../debian . && dch -v$(shell cat /tmp/keyman_version)-1 "" - cd make_deb/keyman-config-$(shell cat /tmp/keyman_version) && debuild -us -uc - rm /tmp/keyman_version - -man: - ./build-help.sh --man --no-reconf + echo "Use ./build.sh command instead of make!" version_reconf: cd .. && ./scripts/reconf.sh version: version_reconf - $(eval KEYMAN_VERSION := $(shell python3 -c "from keyman_config import __releaseversion__; print(__releaseversion__)")) + $(eval KEYMAN_VERSION := $(shell ./build.sh build && python3 -c "from keyman_config import __releaseversion__; print(__releaseversion__)")) # i18n diff --git a/linux/keyman-config/build.sh b/linux/keyman-config/build.sh index 32383ac83d..09061feda2 100755 --- a/linux/keyman-config/build.sh +++ b/linux/keyman-config/build.sh @@ -39,18 +39,25 @@ clean_action() { fi } +install_schema() { + local SCHEMA_DIR="$1" + mkdir -p "${SCHEMA_DIR}" + cp resources/com.keyman.gschema.xml "${SCHEMA_DIR}"/ + glib-compile-schemas "${SCHEMA_DIR}" +} + execute_with_temp_schema() { local TEMP_DATA_DIR SCHEMA_DIR TEMP_DATA_DIR=$(mktemp -d) SCHEMA_DIR="${TEMP_DATA_DIR}/glib-2.0/schemas" export XDG_DATA_DIRS="${TEMP_DATA_DIR}":${XDG_DATA_DIRS-} export GSETTINGS_SCHEMA_DIR="${SCHEMA_DIR}:/usr/share/glib-2.0/schemas/:${GSETTINGS_SCHEMA_DIR-}" - mkdir -p "${SCHEMA_DIR}" - cp resources/com.keyman.gschema.xml "${SCHEMA_DIR}"/ - glib-compile-schemas "${SCHEMA_DIR}" + + install_schema "${SCHEMA_DIR}" "$@" - export XDG_DATA_DIRS=${XDG_DATA_DIRS#*:} + unset GSETTINGS_SCHEMA_DIR + export XDG_DATA_DIRS=${XDG_DATA_DIRS#*:} rm -rf "${TEMP_DATA_DIR}" } @@ -105,25 +112,42 @@ test_action() { if builder_has_option --report; then builder_echo "Creating coverage report" - python3 -m coverage html --directory="$THIS_SCRIPT_PATH/build/coveragereport/" --data-file=build/.coverage + python3 -m coverage html --directory="${THIS_SCRIPT_PATH}/build/coveragereport/" --data-file=build/.coverage fi } install_action() { - if [ -n "${SUDO_USER:-}" ]; then - # with sudo install into /usr/local - pip3 install qrcode sentry-sdk + if [[ -v VIRTUAL_ENV ]]; then + # Running in a virtual environment + pip3 install qrcode sentry-sdk fonttools setuptools pip3 install . # install icons + mkdir -p "${VIRTUAL_ENV}/share/keyman/icons" + cp keyman_config/icons/* "${VIRTUAL_ENV}/share/keyman/icons" + # install man pages + mkdir -p "${VIRTUAL_ENV}/share/man/man1" + cp ../../debian/man/*.1 "${VIRTUAL_ENV}/share/man/man1" + # install schema + install_schema "${VIRTUAL_ENV}/share/glib-2.0/schemas" + elif [[ -n "${SUDO_USER:-}" ]] || [[ "$(id -u)" == "0" ]]; then + # as root install into /usr/local + pip3 install qrcode sentry-sdk + pip3 install --prefix /usr/local . + # install icons mkdir -p /usr/local/share/keyman/icons cp keyman_config/icons/* /usr/local/share/keyman/icons # install man pages mkdir -p /usr/local/share/man/man1 cp ../../debian/man/*.1 /usr/local/share/man/man1 + # install schema + install_schema "/usr/local/share/glib-2.0/schemas" else # without sudo install into /tmp/keyman (or $DESTDIR) - mkdir -p "/tmp/keyman/$(python3 -c 'import sys;import os;pythonver="python%d.%d" % (sys.version_info[0], sys.version_info[1]);sitedir = os.path.join("lib", pythonver, "site-packages");print(sitedir)')" + python_version=$(python3 -c 'import sys;import os;pythonver="python%d.%d" % (sys.version_info[0], sys.version_info[1]);sitedir = os.path.join("lib", pythonver, "site-packages");print(sitedir)') + mkdir -p "/tmp/keyman/${python_version}" pip3 install --prefix /tmp/keyman . + # install schema + install_schema "/tmp/keyman/local/share/glib-2.0/schemas" fi } @@ -132,6 +156,7 @@ uninstall_action() { clean_action rm -rf /usr/local/share/keyman/icons rm -f /usr/local/share/man/man1/km-*.1 + rm -f /usr/local/share/glib-2.0/schemas/com.keyman.gschema.xml pip3 uninstall keyman_config rm -f /usr/local/bin/km-config rm -f /usr/local/bin/km-kvk2ldml diff --git a/linux/keyman-config/keyman_config/__init__.py b/linux/keyman-config/keyman_config/__init__.py index da9cc8e0a6..937bcc7d08 100644 --- a/linux/keyman-config/keyman_config/__init__.py +++ b/linux/keyman-config/keyman_config/__init__.py @@ -111,6 +111,14 @@ def _set_dbus_started_for_session(value): def verify_dbus_running(): + if os.environ.get('SUDO_USER'): + # If running with sudo we won't try to start dbus since it will + # not always work. The main reason we need dbus is so that we can + # notify other km-config processes that the keyboard list has changed. + # If running with sudo we call dbus-send instead of instantiating the + # KeymanConfigServiceManager class. + return + if 'DBUS_SESSION_BUS_ADDRESS' in os.environ: # already running - nothing to do return diff --git a/linux/keyman-config/keyman_config/dbus_util.py b/linux/keyman-config/keyman_config/dbus_util.py index af64f2e517..f88143e55c 100644 --- a/linux/keyman-config/keyman_config/dbus_util.py +++ b/linux/keyman-config/keyman_config/dbus_util.py @@ -2,6 +2,7 @@ import contextlib import logging import os +import subprocess import dbus import dbus.bus @@ -12,6 +13,9 @@ from dbus import DBusException BUS_NAME = 'com.Keyman.Config' OBJECT_PATH = '/com/Keyman/Config' +DBusSessionBusAddressValue = f'unix:path=/run/user/{os.environ.get("SUDO_UID")}/bus' +DBusSessionBusAddress = f'DBUS_SESSION_BUS_ADDRESS={DBusSessionBusAddressValue}' +XdgRuntimeDir = f'XDG_RUNTIME_DIR=/run/user/{os.environ.get("SUDO_UID")}' __keyman_config_service = None @@ -36,6 +40,16 @@ class KeymanConfigService(dbus.service.Object): self.KeyboardListChangedSignal() +def keyboard_list_changed(): + if os.environ.get('SUDO_USER'): + args = ['sudo', '-u', os.environ.get('SUDO_USER'), 'dbus-send', + f'--bus={DBusSessionBusAddressValue}', + '/com/Keyman/Config', 'com.Keyman.Config.keyboard_list_changed'] + subprocess.run(args) + else: + get_keyman_config_service().keyboard_list_changed() + + class KeymanConfigServiceManager: def __init__(self, handler): self.__name_owner_watch = None diff --git a/linux/keyman-config/keyman_config/get_kmp.py b/linux/keyman-config/keyman_config/get_kmp.py index 104036c7fb..ed60d5b2e3 100755 --- a/linux/keyman-config/keyman_config/get_kmp.py +++ b/linux/keyman-config/keyman_config/get_kmp.py @@ -22,7 +22,12 @@ class InstallLocation(GObject.GEnum): User = 3 Unknown = 99 -can_use_cache = True + +# When running under sudo, we don't want to use the cache +# since that stores files with root permissions so that they +# can't be deleted/replaced by the user later. +can_use_cache = not os.environ.get('SUDO_USER') + def get_install_area_string(area): if area == InstallLocation.OS: diff --git a/linux/keyman-config/keyman_config/gsettings.py b/linux/keyman-config/keyman_config/gsettings.py index 8dd4e32333..2509988c0a 100644 --- a/linux/keyman-config/keyman_config/gsettings.py +++ b/linux/keyman-config/keyman_config/gsettings.py @@ -8,6 +8,8 @@ import sys from gi.repository import Gio # needs to come before gi.overrides.GLib! from gi.overrides.GLib import Variant +from keyman_config.dbus_util import DBusSessionBusAddress, XdgRuntimeDir + # The xdg module got renamed to xdg_base_dirs, but the package # (python3-xdg) on Ubuntu still ships xdg. So we support both. try: @@ -71,29 +73,19 @@ class GSettings(): def get(self, key): if self.is_sudo: args = ['sudo', '-H', '-u', os.environ.get('SUDO_USER'), + DBusSessionBusAddress, XdgRuntimeDir, 'gsettings', 'get', self.schema_id, key] - if sys.version_info.major <= 3 and sys.version_info.minor < 7: - # capture_output got added in Python 3.7 - try: - output = subprocess.check_output(args) - value = eval(output) - except subprocess.CalledProcessError: - value = None - logging.warning('Could not convert to sources') - else: - process = subprocess.run(args, capture_output=True) - if process.returncode == 0: - output = process.stdout - if output.decode('utf-8').startswith('['): - value = eval(output) - return value + process = subprocess.run(args, capture_output=True) + if process.returncode == 0: + output = process.stdout + if output.decode('utf-8').startswith('['): + return eval(output) - logging.debug('Could not convert to sources') - return None + logging.debug('Could not convert to sources') + return None else: variant = self.schema.get_value(key) - value = self._convert_variant_to_array(variant) - return value + return self._convert_variant_to_array(variant) def _get_schema_path(self): return self.schema_id.replace('.', '/') @@ -121,9 +113,10 @@ class GSettings(): def set(self, key, value, type_string) -> None: if self.is_sudo: variant = str(value) - subprocess.run( - ['sudo', '-H', '-u', os.environ.get('SUDO_USER'), - 'gsettings', 'set', self.schema_id, key, variant], check=False) + args = ['sudo', '-H', '-u', os.environ.get('SUDO_USER'), + DBusSessionBusAddress, XdgRuntimeDir, + 'gsettings', 'set', self.schema_id, key, variant] + subprocess.run(args, check=False) elif get_dbus_started_for_session(): self._set_key_file(key, value, type_string) else: diff --git a/linux/keyman-config/keyman_config/ibus_util.py b/linux/keyman-config/keyman_config/ibus_util.py index fba1ec5d3f..7eec49a373 100644 --- a/linux/keyman-config/keyman_config/ibus_util.py +++ b/linux/keyman-config/keyman_config/ibus_util.py @@ -10,9 +10,11 @@ import time import gi +from keyman_config.dbus_util import DBusSessionBusAddress, XdgRuntimeDir + gi.require_version('IBus', '1.0') from gi.repository import IBus -from pkg_resources import parse_version +from packaging import version from keyman_config.gnome_keyboards_util import is_gnome_desktop from keyman_config.gsettings import GSettings @@ -114,10 +116,8 @@ def verify_ibus_daemon(start): return IbusDaemon.ERROR_USER try: - ps_output = subprocess.run(('ps', '--user', user, '-o', 's=', '-o', 'cmd'), - check=False, stdout=subprocess.PIPE).stdout - ibus_daemons = re.findall(r'^[^ZT] (/[^ ]+/|)ibus-daemon( .*|$)', - ps_output.decode('utf-8'), re.MULTILINE) + ibus_daemons = subprocess.run(('pgrep', '-u', user, 'ibus-daemon'), + check=False, stdout=subprocess.PIPE).stdout.split() if len(ibus_daemons) <= 0: if start: _start_ibus_daemon(realuser) @@ -156,7 +156,7 @@ def _get_ibus_version(): def _start_ibus_daemon(realuser): try: - if parse_version(_get_ibus_version()) >= parse_version('1.5.28'): + if version.parse(_get_ibus_version()) >= version.parse('1.5.28'): # IBus ~1.5.28 added the `start` command, so we use that if possible # and let IBus deal with the necessary parameters args = ['ibus', 'start', '-d'] @@ -171,36 +171,28 @@ def _start_ibus_daemon(realuser): if realuser: # we have been called with `sudo`. Start ibus-daemon for the real user. - logging.info('starting ibus-daemon for user %s', realuser) - subprocess.run(['sudo', '-u', realuser].extend(args), check=True) + args = ['sudo', '-u', realuser, + DBusSessionBusAddress, XdgRuntimeDir, *args] + logging.info(f'starting ibus-daemon for user {realuser} with args: {args}') else: - logging.info('ibus-daemon not running. Starting it...') - subprocess.run(args, check=True) - except Exception: - logging.warning('Failed to start ibus-daemon') + logging.info(f'ibus-daemon not running. Starting it with args: {args}...') + subprocess.run(args, check=True) + logging.info('ibus-daemon started successfully') + except Exception as e: + logging.warning(f'Failed to start ibus-daemon: {e}') def restart_ibus(bus=None): verify_ibus_daemon(False) if realuser := os.environ.get('SUDO_USER'): # we have been called with `sudo`. Restart ibus for the real user. - logging.info('restarting IBus by subprocess for user %s', realuser) - subprocess.run(['sudo', '-u', realuser, 'ibus', 'restart'], check=False) + logging.info(f'restarting IBus by subprocess for user {realuser}') + subprocess.run(['sudo', '-u', realuser, + DBusSessionBusAddress, XdgRuntimeDir, + 'ibus', 'restart'], check=False) else: - logging.info('restarting IBus through API') - try: - if not bus: - bus = get_ibus_bus() - if bus: - logging.info("restarting IBus") - # we no longer try to restart since we sometimes ended up with more than one - # ibus-daemon process (#6237). Instead we only exit ibus here, and start - # ibus again below. - bus.exit(False) - bus.destroy() - except Exception as e: - logging.warning("Failed to restart IBus") - logging.warning(e) + logging.info('restarting IBus by subprocess') + subprocess.run(['ibus', 'restart'], check=False) # give ibus a chance to shutdown (#6237) time.sleep(1) # 1s verify_ibus_daemon(True) diff --git a/linux/keyman-config/keyman_config/install_kmp.py b/linux/keyman-config/keyman_config/install_kmp.py index 4848416a5b..55c638c8e8 100755 --- a/linux/keyman-config/keyman_config/install_kmp.py +++ b/linux/keyman-config/keyman_config/install_kmp.py @@ -12,7 +12,7 @@ from keyman_config import _, __version__, secure_lookup from keyman_config.canonical_language_code_utils import CanonicalLanguageCodeUtils from keyman_config.convertico import checkandsaveico, extractico from keyman_config.custom_keyboards import CustomKeyboards -from keyman_config.dbus_util import get_keyman_config_service +from keyman_config.dbus_util import keyboard_list_changed from keyman_config.fcitx_util import is_fcitx_running from keyman_config.get_kmp import (InstallLocation, get_keyboard_data, get_keyboard_dir, get_keyman_doc_dir, @@ -351,5 +351,5 @@ def install_kmp(inputfile, sharedarea=False, language=None): else: return_value = InstallKmp().install_kmp_user(inputfile, language) - get_keyman_config_service().keyboard_list_changed() + keyboard_list_changed() return return_value diff --git a/linux/keyman-config/keyman_config/sentry_handling.py b/linux/keyman-config/keyman_config/sentry_handling.py index b153066a1a..104296936a 100644 --- a/linux/keyman-config/keyman_config/sentry_handling.py +++ b/linux/keyman-config/keyman_config/sentry_handling.py @@ -40,7 +40,8 @@ class SentryErrorHandling: def initialize_sentry(self): (enabled, reason) = self.is_sentry_enabled() if not enabled: - print(reason, file=sys.stderr) + if not self._is_running_tests(): + print(reason, file=sys.stderr) logging.info(reason) return (enabled, reason) else: @@ -58,8 +59,8 @@ class SentryErrorHandling: return (True, '') def is_sentry_enabled(self): - if self._is_unit_test(): - return (False, 'Running unit tests, not reporting to Sentry') + if self._is_running_tests(): + return (False, 'Running tests, not reporting to Sentry') elif self._get_environ_nosentry(): return (False, 'Not reporting to Sentry because KEYMAN_NOSENTRY environment variable set') elif not __uploadsentry__: @@ -94,12 +95,16 @@ class SentryErrorHandling: keyman_nosentry = os.environ.get('KEYMAN_NOSENTRY') return keyman_nosentry and (int(keyman_nosentry) == 1) - def _is_unit_test(self): # sourcery skip: use-any, use-next + def _is_running_tests(self): # sourcery skip: use-any, use-next # The suggested refactorings (using any() or next()) don't work # when testing on Ubuntu 20.04 for line in traceback.format_stack(): if '/unittest/' in line: return True + + if platform.node() == 'autopkgtest': + return True + return False def _handle_enabled(self, enabled): diff --git a/linux/keyman-config/keyman_config/uninstall_kmp.py b/linux/keyman-config/keyman_config/uninstall_kmp.py index 9fc302cc5f..12a1bbee61 100755 --- a/linux/keyman-config/keyman_config/uninstall_kmp.py +++ b/linux/keyman-config/keyman_config/uninstall_kmp.py @@ -7,7 +7,7 @@ from shutil import rmtree from keyman_config import _ from keyman_config.custom_keyboards import CustomKeyboards -from keyman_config.dbus_util import get_keyman_config_service +from keyman_config.dbus_util import keyboard_list_changed from keyman_config.fcitx_util import is_fcitx_running from keyman_config.get_kmp import (InstallLocation, get_keyboard_dir, get_keyman_doc_dir, get_keyman_font_dir) @@ -152,5 +152,5 @@ def uninstall_kmp(packageID, sharedarea=False, removeLanguages=True): else: msg = _uninstall_kmp_common(InstallLocation.User, packageID, removeLanguages) - get_keyman_config_service().keyboard_list_changed() + keyboard_list_changed() return msg diff --git a/linux/keyman-config/km-package-install b/linux/keyman-config/km-package-install index 195ad1ad07..419be8c832 100755 --- a/linux/keyman-config/km-package-install +++ b/linux/keyman-config/km-package-install @@ -109,6 +109,78 @@ def list_languages_for_keyboard(packageId, packageDir): print(result) +def try_install_kmp(inputfile, arg, language=None, sharedarea=False): + try: + install_kmp(inputfile, sharedarea, language) + except InstallError as e: + if e.status == InstallStatus.Abort: + logging.error("km-package-install: error: Failed to install %s", arg) + logging.error(e.message) + sys.exit(3) + else: + logging.warning(e.message) + + +def install_package(args): + if args.shared: + if get_kmp_version_user(args.package): + logging.warning( + f'km-package-install: Warning: Keyboard {args.package} is also installed for the current user.' + ) + installed_kmp_ver = get_kmp_version_shared(args.package) + else: + if get_kmp_version_shared(args.package): + logging.warning( + f'km-package-install: Warning: Keyboard {args.package} is already installed in the shared area.' + ) + installed_kmp_ver = get_kmp_version_user(args.package) + pkgdata = get_keyboard_data(args.package) + if not pkgdata: + logging.error("km-package-install: error: Could not download keyboard data for %s", args.package) + sys.exit(3) + pkg_version = secure_lookup(pkgdata, 'version') + if installed_kmp_ver and pkg_version: + if packaging.version.parse(installed_kmp_ver) > packaging.version.parse(pkg_version): + if args.force: + logging.warning(f'km-package-install: warning: The {args.package} keyboard ' + + f'is already installed with a newer version {installed_kmp_ver}. ' + + f'Downgrading to version {pkg_version}.') + else: + logging.error(f'km-package-install: error: The {args.package} keyboard is ' + + f'already installed with a newer version {installed_kmp_ver}. ' + + f'Not installing version {pkg_version}. Use `--force` to downgrade.') + sys.exit(1) + if args.force: + uninstall_kmp(args.package, args.shared, False) + + if kmpfile := get_kmp(args.package): + try_install_kmp(kmpfile, f"keyboard package {args.package}", args.bcp47, args.shared) + else: + logging.error("km-package-install: error: Could not download keyboard package %s", args.package) + sys.exit(2) + + +def install_file(parser, args): + name, ext = os.path.splitext(args.file) + if ext != ".kmp" or not is_zipfile(args.file): + print_usage_and_exit( + parser, + f"km-package-install: Input file ${args.file} is not a kmp file." + ) + if not os.path.isfile(args.file): + print_usage_and_exit( + parser, + f"km-package-install: Keyman kmp file {args.file} not found." + ) + try_install_kmp(args.file, f"file {args.file}", args.bcp47, args.shared) + + +def print_usage_and_exit(parser, msg): + parser.print_usage() + logging.error(msg) + sys.exit(2) + + def main(): parser = argparse.ArgumentParser( description='Install a Keyman keyboard package, either a local .kmp file or specify a ' + @@ -134,80 +206,24 @@ def main(): sys.exit(4) if args.package and args.file: - parser.print_usage() - logging.error( - "km-package-install: error: too many arguments: either install a local kmp file or " + - "specify a keyboard id to download and install.") - sys.exit(2) - + print_usage_and_exit( + parser, + "km-package-install: error: too many arguments: either install a local kmp file or " + + "specify a keyboard id to download and install.", + ) if os.path.exists(os.path.join(keyman_cache_dir(), 'kmpdirlist')): os.remove(os.path.join(keyman_cache_dir(), 'kmpdirlist')) - def try_install_kmp(inputfile, arg, language=None, sharedarea=False): - try: - install_kmp(inputfile, sharedarea, language) - except InstallError as e: - if e.status == InstallStatus.Abort: - logging.error("km-package-install: error: Failed to install %s", arg) - logging.error(e.message) - sys.exit(3) - else: - logging.warning(e.message) - if args.file: - name, ext = os.path.splitext(args.file) - if ext != ".kmp" or not is_zipfile(args.file): - logging.error("km-package-install: Input file %s is not a kmp file.", args.file) - logging.error("km-package-install -f ") - sys.exit(2) - - if not os.path.isfile(args.file): - logging.error("km-package-install: Keyman kmp file %s not found.", args.file) - logging.error("km-package-install -f ") - sys.exit(2) - try_install_kmp(args.file, f"file {args.file}", args.bcp47, args.shared) + install_file(parser, args) elif args.package: - if args.shared: - if get_kmp_version_user(args.package): - logging.warning(f'km-package-install: Warning: Keyboard {args.package} is ' + - 'also installed for the current user.') - installed_kmp_ver = get_kmp_version_shared(args.package) - else: - if get_kmp_version_shared(args.package): - logging.warning(f'km-package-install: Warning: Keyboard {args.package} is ' + - 'already installed in the shared area.') - installed_kmp_ver = get_kmp_version_user(args.package) - pkgdata = get_keyboard_data(args.package) - if not pkgdata: - logging.error("km-package-install: error: Could not download keyboard data for %s", args.package) - sys.exit(3) - pkg_version = secure_lookup(pkgdata, 'version') - if installed_kmp_ver and pkg_version: - if packaging.version.parse(installed_kmp_ver) > packaging.version.parse(pkg_version): - if args.force: - logging.warning(f'km-package-install: warning: The {args.package} keyboard ' + - f'is already installed with a newer version {installed_kmp_ver}. ' + - f'Downgrading to version {pkg_version}.') - else: - logging.error(f'km-package-install: error: The {args.package} keyboard is ' + - f'already installed with a newer version {installed_kmp_ver}. ' + - f'Not installing version {pkg_version}. Use `--force` to downgrade.') - sys.exit(1) - if args.force: - uninstall_kmp(args.package, args.shared, False) - - kmpfile = get_kmp(args.package) - if kmpfile: - try_install_kmp(kmpfile, f"keyboard package {args.package}", args.bcp47, args.shared) - else: - logging.error("km-package-install: error: Could not download keyboard package %s", args.package) - sys.exit(2) + install_package(args) else: - parser.print_usage() - logging.error( - "km-package-install: error: no arguments: either install a local kmp file " + - "or specify a keyboard package id to download and install.") - sys.exit(2) + print_usage_and_exit( + parser, + "km-package-install: error: no arguments: either install a local kmp file " + + "or specify a keyboard package id to download and install.", + ) if __name__ == "__main__": diff --git a/linux/keyman-config/tests/uninstall_kmp_tests.py b/linux/keyman-config/tests/uninstall_kmp_tests.py index 0d1b0d53dc..5a0404ef77 100644 --- a/linux/keyman-config/tests/uninstall_kmp_tests.py +++ b/linux/keyman-config/tests/uninstall_kmp_tests.py @@ -325,9 +325,7 @@ class UninstallKmpTests(unittest.TestCase): self.mockUninstallDir = self._setupMock('keyman_config.uninstall_kmp._uninstall_dir') self.mockGetMetaData = self._setupMock('keyman_config.uninstall_kmp.get_metadata') self.mockCustomKeyboardsClass = self._setupMock('keyman_config.uninstall_kmp.CustomKeyboards') - self.mockGetKeymanConfigService = self._setupMock('keyman_config.uninstall_kmp.get_keyman_config_service') - self.mockKeymanConfigServiceManager = Mock() - self.mockGetKeymanConfigService.return_value = self.mockKeymanConfigServiceManager + self.mockKeyboardListChanged = self._setupMock('keyman_config.uninstall_kmp.keyboard_list_changed') def _setupMock(self, arg0): patcher = patch(arg0) @@ -341,9 +339,6 @@ class UninstallKmpTests(unittest.TestCase): keyboards = [{'id': 'foo1', 'languages': [{'id': 'en'}]}, {'id': 'bar2', 'languages': [{'id': 'fr'}]}] self.mockGetMetaData.return_value = (None, None, None, keyboards, None) - keyboardListChangedMock = Mock() - self.mockKeymanConfigServiceManager.keyboard_list_changed = keyboardListChangedMock - # Execute uninstall_kmp('foo', False, True) @@ -351,5 +346,4 @@ class UninstallKmpTests(unittest.TestCase): self.mockUninstallKeyboardsFromIbus.assert_called_once_with(keyboards, '/tmp/foo') self.mockUninstallDir.assert_called() self.assertEqual(mockCustomKeyboardsInstance.remove.call_count, 2) - self.assertEqual(keyboardListChangedMock.call_count, 1) - + self.mockKeyboardListChanged.assert_called_once() diff --git a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m index c93ff4d2ff..a5e04e6e09 100644 --- a/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m +++ b/mac/Keyman4MacIM/Keyman4MacIM/TextApiCompliance.m @@ -365,7 +365,8 @@ NSString *const kKMLegacyApps = @"KMLegacyApps"; [clientAppId isEqual: @"org.sil.app.builder.scripture.ScriptureAppBuilder"] || [clientAppId isEqual: @"org.sil.app.builder.reading.ReadingAppBuilder"] || [clientAppId isEqual: @"org.sil.app.builder.dictionary.DictionaryAppBuilder"] || - //[clientAppId isEqual: @"com.microsoft.Word"] || // 2020-11-24[mcd]: Appears to work well in Word 16.43, disable legacy by default + [clientAppId isEqual: @"com.microsoft.Word"] || + // Microsoft Word removed on 2020-11-24 but added back on 2025-10-10 for issue #14160 [clientAppId isEqual: @"org.openoffice.script"] || // 2023-12-13[sgs]: Adobe apps automatically detected as non-compliant //[clientAppId isEqual: @"com.adobe.illustrator"] || diff --git a/web/docs/engine/reference/core/helpURL.md b/web/docs/engine/reference/core/helpURL.md index 16f86eb3ca..242779ec6b 100644 --- a/web/docs/engine/reference/core/helpURL.md +++ b/web/docs/engine/reference/core/helpURL.md @@ -22,7 +22,7 @@ Read only ### Return Value -Presently set to [`"http://help.keymanweb.com/go"`](). +Presently set to [`"https://help.keyman.com/go"`](). ## Description diff --git a/web/src/app/ui/kmwuifloat.ts b/web/src/app/ui/kmwuifloat.ts index 38fabb5bd7..f67463cf48 100644 --- a/web/src/app/ui/kmwuifloat.ts +++ b/web/src/app/ui/kmwuifloat.ts @@ -130,7 +130,7 @@ if(!keymanweb) { this.outerDiv = util.createElement('div'); // Container for UI (visible when KeymanWeb is active) this.innerDiv = util.createElement('div'); // inner div for UI this.kbdIcon = util.createElement('img'); - this.outerDiv.innerHTML = "" + this.outerDiv.innerHTML = "" + "KeymanWeb"; /* I2081 */ let s=this.outerDiv.style; diff --git a/windows/src/engine/keyman/viskbd/UfrmOSKOnScreenKeyboard.pas b/windows/src/engine/keyman/viskbd/UfrmOSKOnScreenKeyboard.pas index 022d1d621c..db88b5c28b 100644 --- a/windows/src/engine/keyman/viskbd/UfrmOSKOnScreenKeyboard.pas +++ b/windows/src/engine/keyman/viskbd/UfrmOSKOnScreenKeyboard.pas @@ -1,18 +1,18 @@ (* Name: UfrmOSKOnScreenKeyboard Copyright: Copyright (C) SIL International. - Documentation: - Description: + Documentation: + Description: Create Date: 14 Sep 2006 Modified Date: 8 Apr 2015 Authors: mcdurdin - Related Files: - Dependencies: + Related Files: + Dependencies: - Bugs: - Todo: - Notes: + Bugs: + Todo: + Notes: History: 14 Sep 2006 - mcdurdin - Initial version 04 Dec 2006 - mcdurdin - Support exporting and printing 04 Dec 2006 - mcdurdin - Fix shift states when pressing keys @@ -72,6 +72,7 @@ type VKI: TVisualKeyboardInfo; VisualKeyboards: TVisualKeyboardInfoList; FCachedShiftState: TExtShiftState; + IsSimulatedLControlDown: Boolean; function GetAsyncShiftState: TExtShiftState; procedure UpdateKeyboard(FLoading: Boolean); @@ -89,6 +90,7 @@ type procedure PrintKeyboard; procedure ResetShiftStates; // I1144 + procedure OskModifierEvent(VKCode, Flags: DWORD); function HasVisualKeyboard: Boolean; overload; function HasVisualKeyboard(KeymanID: Integer): Boolean; overload; @@ -222,6 +224,58 @@ begin ShiftStateChange(fkcss, ass); end; +(** + * Handles OSK modifier events received from + * keyman32:PostVisualKeyboardModifierEvent in k32_visualkeyboardinterface.cpp. + * + * This takes the chiral VK_LCONTROL / VK_RCONTROL / VK_LMENU / VK_RMENU virtual + * key codes. + * + * This currently deals only with one situation: when Windows posts a simulated + * LControl key when AltGr is pressed on a European layout. See + * serialkeyeventserver.cpp:WndProc for a deeper discussion of the key events + * that are generated in this scenario. + * + * @param VKCode virtual key code of the modifier key, chiral for Alt and Ctrl + * @param Flags as follows: + * KEYEVENTF_EXTENDEDKEY 0x00000001 - extended bit is set on the scan code + * KEYEVENTF_KEYUP 0x00000002 - key is being released + * scan code mask 0x0FFF0000 - 12 bits for scan code + * all other bits reserved + *) +procedure TfrmOSKOnScreenKeyboard.OskModifierEvent(VKCode, Flags: DWORD); +var + isUp: Boolean; + scanCode: DWORD; +const + /// The normal scan code for left Control key + SCAN_LEFT_CONTROL = $1D; + /// The scan code generated by Windows when it simulates pressing left + /// Control, when AltGr is pressed on a European layout; this is generated by + /// Windows for compatibility reasons. + SCAN_LEFT_CONTROL_SIMULATED = $21D; +begin + // not used: extended := (Flags and KEYEVENTF_EXTENDEDKEY) <> 0; + isUp := (Flags and KEYEVENTF_KEYUP) <> 0; + + // In order to identify the Windows simulated left control scan code, we need + // to match on more bits than the normal 8 bit scan code. + scanCode := (Flags and $0FFF0000) shr 16; + + // If the Left Control key is simulated by Windows, then we want to ignore it + // because in reality, AltGr has been pressed on a European keyboard. However, + // if the Left Control key is being released, then we should reset the flag + // whether it comes from the Windows simulation or from another source (real + // or injected), so that we don't end up with a stuck left control key + if (scanCode = SCAN_LEFT_CONTROL_SIMULATED) and not isUp then + IsSimulatedLControlDown := True + else if ((scanCode = SCAN_LEFT_CONTROL) or (scanCode = SCAN_LEFT_CONTROL_SIMULATED)) and isUp then + IsSimulatedLControlDown := False; + + // TODO: in the future, we might be able to eliminate tmrCheck and make all + // modifier updates go through the WH_KEYBOARD_LL hook. +end; + procedure TfrmOSKOnScreenKeyboard.ShiftStateChange(kbdShift, asyncShift: TExtShiftState); procedure PrepState(fkcss, ass: TExtShiftState; shift: TExtShiftStateValue; vk: Integer); var @@ -317,10 +371,10 @@ begin FNewShiftState := FNewShiftState - [essLCtrl, essRCtrl]; FChanged := True; end; - + if FChanged then ShiftStateChange(FNewShiftState, FShiftState); - + FShiftState := FNewShiftState; kbd.ShiftState := FShiftState; UpdateKeyboard(False); @@ -357,36 +411,28 @@ begin end; function TfrmOSKOnScreenKeyboard.GetAsyncShiftState: TExtShiftState; +var + lctrl: SHORT; + ralt: SHORT; begin Result := []; if (GetAsyncKeyState(VK_SHIFT) and $8000) = $8000 then Result := Result + [essShift]; if kbd.LRShift then begin - if (GetAsyncKeyState(VK_LCONTROL) and $8000) = $8000 then Result := Result + [essLCtrl]; + lctrl := GetAsyncKeyState(VK_LCONTROL); + ralt := GetAsyncKeyState(VK_RMENU); + + if not IsSimulatedLControlDown and ((lctrl and $8000) = $8000) then + Result := Result + [essLCtrl]; if (GetAsyncKeyState(VK_RCONTROL) and $8000) = $8000 then Result := Result + [essRCtrl]; if (GetAsyncKeyState(VK_LMENU) and $8000) = $8000 then Result := Result + [essLAlt]; - if (GetAsyncKeyState(VK_RMENU) and $8000) = $8000 then Result := Result + [essRAlt]; + if (ralt and $8000) = $8000 then Result := Result + [essRAlt]; end else begin if (GetAsyncKeyState(VK_CONTROL) and $8000) = $8000 then Result := Result + [essCtrl]; if (GetAsyncKeyState(VK_MENU) and $8000) = $8000 then Result := Result + [essAlt]; end; - -(*Result := []; - if (GetAsyncKeyState(VK_SHIFT) and $8000) = $8000 then Result := Result + [essShift]; - if kbd.LRShift then - begin - if (GetAsyncKeyState(VK_LCONTROL) and $8000) = $8000 then Result := Result + [essLCtrl]; - if (GetAsyncKeyState(VK_RCONTROL) and $8000) = $8000 then Result := Result + [essRCtrl]; - if (GetAsyncKeyState(VK_LMENU) and $8000) = $8000 then Result := Result + [essLAlt]; - if (GetAsyncKeyState(VK_RMENU) and $8000) = $8000 then Result := Result + [essRAlt]; - end - else - begin - if (GetAsyncKeyState(VK_CONTROL) and $8000) = $8000 then Result := Result + [essCtrl]; - if (GetAsyncKeyState(VK_MENU) and $8000) = $8000 then Result := Result + [essAlt]; - end;*) end; function TfrmOSKOnScreenKeyboard.HasVisualKeyboard(KeymanID: Integer): Boolean; @@ -474,7 +520,7 @@ begin ss := ExtShiftStateToVkShiftState(kbd.ShiftState); ssi := ValidExtShiftStateIndex(kbd.ShiftState); end; - + for i := 0 to VKI.Keyboard.Keys.Count - 1 do begin if not FPositional then diff --git a/windows/src/engine/keyman/viskbd/UfrmVisualKeyboard.pas b/windows/src/engine/keyman/viskbd/UfrmVisualKeyboard.pas index c1d1dedaa0..5fc1d0ed9d 100644 --- a/windows/src/engine/keyman/viskbd/UfrmVisualKeyboard.pas +++ b/windows/src/engine/keyman/viskbd/UfrmVisualKeyboard.pas @@ -203,6 +203,7 @@ type procedure WMNCHitTest(var Message: TWMNCHitTest); message WM_NCHITTEST; procedure WMUserFormShown(var Message: TMessage); message WM_USER_FormShown; // I4225 + procedure WMKeymanOskModifierEvent(var Message: TMessage); message WM_KEYMAN_OSK_MODIFIER_EVENT; procedure UpdatePanels; procedure SetActivePage(Page: TOSKActivePage); @@ -998,6 +999,12 @@ begin inherited; end; +procedure TfrmVisualKeyboard.WMKeymanOskModifierEvent(var Message: TMessage); +begin + if Assigned(FOnScreenKeyboard) then + FOnScreenKeyboard.OskModifierEvent(Message.WParam, Message.LParam); +end; + procedure TfrmVisualKeyboard.AdjustToClosestMonitor(var x, y, cx, cy: Integer); var m: TMonitor; diff --git a/windows/src/engine/keyman32/k32_lowlevelkeyboardhook.cpp b/windows/src/engine/keyman32/k32_lowlevelkeyboardhook.cpp index ab1bb57985..47d77fc1ad 100644 --- a/windows/src/engine/keyman32/k32_lowlevelkeyboardhook.cpp +++ b/windows/src/engine/keyman32/k32_lowlevelkeyboardhook.cpp @@ -176,10 +176,15 @@ LRESULT _kmnLowLevelKeyboardProc( if (GetKeyState(VK_LSHIFT) < 0) { FHotkeyShiftState |= HK_SHIFT; } + if (GetKeyState(VK_RSHIFT) < 0) { FHotkeyShiftState |= AllowRightModifierHotkey ? HK_SHIFT : HK_RSHIFT_INVALID; } + if(isModifierKey(hs->vkCode)) { + PostVisualKeyboardModifierEvent(hs->vkCode, LLKHFFlagstoWMKeymanKeyEventFlags(hs)); + } + //TODO: #8064. Can remove debug message once issue #8064 is resolved SendDebugMessageFormat("[FHotkeyShiftState:%x]", FHotkeyShiftState); diff --git a/windows/src/engine/keyman32/k32_visualkeyboardinterface.cpp b/windows/src/engine/keyman32/k32_visualkeyboardinterface.cpp new file mode 100644 index 0000000000..bee8f75d62 --- /dev/null +++ b/windows/src/engine/keyman32/k32_visualkeyboardinterface.cpp @@ -0,0 +1,65 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by mcdurdin on 2025-10-08 + * + * Interface between keyman32.dll and the visual keyboard module hosted in + * keyman.exe + */ +#include "pch.h" + +// These messages should correspond to the UfrmOSKOnScreenKeyboard section in +// UserMessages.pas. Only messages used in keyman32 are currently defined here. +#define WM_KEYMAN_OSK_MODIFIER_EVENT (WM_USER + 141) + +/** + * Tell the visual keyboard module that modifier keys on the physical keyboard + * have been pressed or released, in order to update the display of the visual + * keyboard. This is sent from the low level keyboard hook, so is an in-process + * single thread event. + * + * This takes the chiral VK_LCONTROL / VK_RCONTROL / VK_LMENU / VK_RMENU virtual + * key codes. + * + * This function exists primarily to support workaround of the Windows hack of + * generating LCtrl+RAlt when AltGr is pressed on European layouts. The visual + * keyboard module includes special handling for the custom scan code of 0x21D + * for the fudged LCtrl key. + * + * @param vkCode virtual key code of the modifier key, chiral for Alt and Ctrl + * @param flags as follows: + * KEYEVENTF_EXTENDEDKEY 0x00000001 - extended bit is set on the scan code + * KEYEVENTF_KEYUP 0x00000002 - key is being released + * scan code mask 0x0FFF0000 - 12 bits for scan code + * all other bits reserved + */ +void PostVisualKeyboardModifierEvent(UINT vkCode, DWORD flags) { + PostMessageToVisualKeyboardWindowIfExists(WM_KEYMAN_OSK_MODIFIER_EVENT, vkCode, flags); +} + +/** + * Find the handle to the Visual Keyboard window in keyman.exe, if it is open, + * and returns NULL if not found. + * + * @return HWND Handle to the Visual Keyboard window + */ +HWND GetVisualKeyboardWindow() { + return FindWindow("TfrmVisualKeyboard", NULL); +} + +/** + * Post a message to the Visual Keyboard window in keyman.exe, if it is open. + * + * @param msg Windows message + * @param wParam WPARAM + * @param lParam LPARAM + * @return BOOL FALSE if window does exist or on PostMessage failure + */ +BOOL PostMessageToVisualKeyboardWindowIfExists(UINT msg, WPARAM wParam, LPARAM lParam) { + HWND hwnd = GetVisualKeyboardWindow(); + if(hwnd == NULL) { + return FALSE; + } + + return PostMessage(hwnd, msg, wParam, lParam); +} diff --git a/windows/src/engine/keyman32/k32_visualkeyboardinterface.h b/windows/src/engine/keyman32/k32_visualkeyboardinterface.h new file mode 100644 index 0000000000..4c7d39787f --- /dev/null +++ b/windows/src/engine/keyman32/k32_visualkeyboardinterface.h @@ -0,0 +1,14 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by mcdurdin on 2025-10-08 + * + * Interface between keyman32.dll and the visual keyboard module hosted in + * keyman.exe + */ + + #pragma once + +HWND GetVisualKeyboardWindow(); +void PostVisualKeyboardModifierEvent(UINT vkCode, DWORD flags); +BOOL PostMessageToVisualKeyboardWindowIfExists(UINT msg, WPARAM wParam, LPARAM lParam); diff --git a/windows/src/engine/keyman32/keyman32.vcxproj b/windows/src/engine/keyman32/keyman32.vcxproj index 8f75aeb126..71ae7ec501 100644 --- a/windows/src/engine/keyman32/keyman32.vcxproj +++ b/windows/src/engine/keyman32/keyman32.vcxproj @@ -349,6 +349,7 @@ + @@ -441,6 +442,7 @@ + diff --git a/windows/src/engine/keyman32/keymanengine.h b/windows/src/engine/keyman32/keymanengine.h index 058ea5cf17..c784b04897 100644 --- a/windows/src/engine/keyman32/keymanengine.h +++ b/windows/src/engine/keyman32/keymanengine.h @@ -262,6 +262,7 @@ void keybd_shift(LPINPUT pInputs, int* n, BOOL isReset, LPBYTE const kbd); #include "..\..\..\..\common\windows\cpp\include\crc32.h" #include "k32_tsf.h" +#include "k32_visualkeyboardinterface.h" void ReportActiveKeyboard(WORD wCommand); // I3933 // I3949 void SelectKeyboardHKL(DWORD hkl, BOOL foreground); // I3933 // I3949 // I4271 diff --git a/windows/src/engine/keyman32/kmhook_getmessage.cpp b/windows/src/engine/keyman32/kmhook_getmessage.cpp index f9aa15784c..5c7af7ce8a 100644 --- a/windows/src/engine/keyman32/kmhook_getmessage.cpp +++ b/windows/src/engine/keyman32/kmhook_getmessage.cpp @@ -170,10 +170,11 @@ LRESULT _kmnGetMessageProc(int nCode, WPARAM wParam, LPARAM lParam) } // I1337 - move mousewheel message checking before the PM_REMOVE handling - otherwise we can't pre-modify the message - // TODO: Move to WH_MOUSE_LL proc in main process + // TODO: Move to WH_MOUSE_LL proc in main process? if(mp->message == WM_MOUSEWHEEL) { - HWND hwndMouseWheel = FindWindow("TfrmVisualKeyboard", NULL); + // TODO: move most of this logic to k32_visualkeyboardinterface.cpp + HWND hwndMouseWheel = GetVisualKeyboardWindow(); if(hwndMouseWheel && GetWindowThreadProcessId(mp->hwnd, NULL) != GetWindowThreadProcessId(hwndMouseWheel, NULL)) { RECT rcMouseWheel; diff --git a/windows/src/engine/keyman32/serialkeyeventserver.cpp b/windows/src/engine/keyman32/serialkeyeventserver.cpp index e2c381bbd3..13e78f3836 100644 --- a/windows/src/engine/keyman32/serialkeyeventserver.cpp +++ b/windows/src/engine/keyman32/serialkeyeventserver.cpp @@ -475,6 +475,10 @@ private: There is a possibility that some apps may try and sniff that 0x21D scan code and get confused because Keyman doesn't emit it. Hopefully this is rare. + + See also: + * PostVisualKeyboardModifierEvent in k32_visualkeyboardinterface.cpp + * TfrmOSKOnScreenKeyboard.OskModifierEvent in UfrmOSKOnScreenKeyboard.cpp */ INPUT input[2]; input[0].type = INPUT_KEYBOARD; diff --git a/windows/src/test/manual-tests/keyboard_ll_identifier/README.md b/windows/src/test/manual-tests/keyboard_ll_identifier/README.md new file mode 100644 index 0000000000..5efc1cc91b --- /dev/null +++ b/windows/src/test/manual-tests/keyboard_ll_identifier/README.md @@ -0,0 +1,9 @@ +# keyboard_ll_identifier + +This test app allows us to view details of key events as they are received by +the low level keyboard hook. There are subtle differences between these key +events and those received in WM_KEY* messages, e.g. see discussions on +simulated left control in k32_visualkeyboardinterface.cpp. + +The app currently only reports on modifier keys, but it's easy to tweak for +other reporting as needed. \ No newline at end of file diff --git a/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier.dpr b/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier.dpr new file mode 100644 index 0000000000..b7261ee398 --- /dev/null +++ b/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier.dpr @@ -0,0 +1,14 @@ +program keyboard_ll_identifier; + +uses + Vcl.Forms, + keyboard_ll_identifier_unit in 'keyboard_ll_identifier_unit.pas' {Form1}; + +{$R *.res} + +begin + Application.Initialize; + Application.MainFormOnTaskbar := True; + Application.CreateForm(TForm1, Form1); + Application.Run; +end. diff --git a/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier.dproj b/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier.dproj new file mode 100644 index 0000000000..b169e40baf --- /dev/null +++ b/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier.dproj @@ -0,0 +1,970 @@ + + + {C945C654-1D70-4248-8513-09FBD3715ACE} + 18.8 + VCL + keyboard_ll_identifier.dpr + True + Debug + Win32 + 1 + Application + + + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Cfg_1 + true + true + + + true + Base + true + + + true + Cfg_2 + true + true + + + .\$(Platform)\$(Config) + .\$(Platform)\$(Config) + false + false + false + false + false + System;Xml;Data;Datasnap;Web;Soap;Vcl;Vcl.Imaging;Vcl.Touch;Vcl.Samples;Vcl.Shell;$(DCC_Namespace) + $(BDS)\bin\delphi_PROJECTICON.ico + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_44.png + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_150.png + keyboard_ll_identifier + + + DBXSqliteDriver;bindcompdbx;IndyIPCommon;RESTComponents;DBXInterBaseDriver;vcl;IndyIPServer;vclactnband;vclFireDAC;IndySystem;tethering;svnui;mbColorLibD10;dsnapcon;FireDACADSDriver;scFontCombo;FireDACMSAccDriver;fmxFireDAC;vclimg;TeeDB;FireDAC;Jcl;vcltouch;JvCore;vcldb;bindcompfmx;svn;FireDACSqliteDriver;FireDACPgDriver;inetdb;CEF4Delphi;FMXTee;soaprtl;DbxCommonDriver;FmxTeeUI;FireDACIBDriver;fmx;fmxdae;xmlrtl;soapmidas;Tee;fmxobj;vclwinx;common_components;rtl;DbxClientDriver;CustomIPTransport;vcldsnap;dbexpress;IndyCore;vclx;browse4folder;bindcomp;appanalytics;dsnap;FireDACCommon;IndyIPClient;bindcompvcl;RESTBackendComponents;TeeUI;VCLRESTComponents;soapserver;dbxcds;VclSmp;JvDocking;adortl;developer_components;JclVcl;vclie;bindengine;DBXMySQLDriver;CloudService;dsnapxml;FireDACMySQLDriver;dbrtl;inetdbxpress;IndyProtocols;keyman_components;FireDACCommonODBC;FireDACCommonDriver;inet;fmxase;$(DCC_UsePackage) + Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;Bde;$(DCC_Namespace) + Debug + true + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProgramID=com.embarcadero.$(MSBuildProjectName);ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments= + 1033 + $(BDS)\bin\default_app.manifest + + + DBXSqliteDriver;bindcompdbx;IndyIPCommon;RESTComponents;DBXInterBaseDriver;vcl;IndyIPServer;vclactnband;vclFireDAC;IndySystem;tethering;dsnapcon;FireDACADSDriver;FireDACMSAccDriver;fmxFireDAC;vclimg;TeeDB;FireDAC;Jcl;vcltouch;vcldb;bindcompfmx;FireDACSqliteDriver;FireDACPgDriver;inetdb;CEF4Delphi;FMXTee;soaprtl;DbxCommonDriver;FmxTeeUI;FireDACIBDriver;fmx;fmxdae;xmlrtl;soapmidas;Tee;fmxobj;vclwinx;rtl;DbxClientDriver;CustomIPTransport;vcldsnap;dbexpress;IndyCore;vclx;bindcomp;appanalytics;dsnap;FireDACCommon;IndyIPClient;bindcompvcl;RESTBackendComponents;TeeUI;VCLRESTComponents;soapserver;dbxcds;VclSmp;adortl;JclVcl;vclie;bindengine;DBXMySQLDriver;CloudService;dsnapxml;FireDACMySQLDriver;dbrtl;inetdbxpress;IndyProtocols;FireDACCommonODBC;FireDACCommonDriver;inet;fmxase;$(DCC_UsePackage) + + + DEBUG;$(DCC_Define) + true + false + true + true + true + + + false + true + PerMonitorV2 + + + false + RELEASE;$(DCC_Define) + 0 + 0 + + + true + PerMonitorV2 + + + + MainSource + + +
Form1
+ dfm +
+ + Cfg_2 + Base + + + Base + + + Cfg_1 + Base + +
+ + Delphi.Personality.12 + Application + + + + keyboard_ll_identifier.dpr + + + + + + keyboard_ll_identifier.exe + true + + + + + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + classes + 1 + + + classes + 1 + + + + + res\xml + 1 + + + res\xml + 1 + + + + + library\lib\armeabi-v7a + 1 + + + + + library\lib\armeabi + 1 + + + library\lib\armeabi + 1 + + + + + library\lib\armeabi-v7a + 1 + + + + + library\lib\mips + 1 + + + library\lib\mips + 1 + + + + + library\lib\armeabi-v7a + 1 + + + library\lib\arm64-v8a + 1 + + + + + library\lib\armeabi-v7a + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + res\values-v21 + 1 + + + res\values-v21 + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable-xxhdpi + 1 + + + res\drawable-xxhdpi + 1 + + + + + res\drawable-ldpi + 1 + + + res\drawable-ldpi + 1 + + + + + res\drawable-mdpi + 1 + + + res\drawable-mdpi + 1 + + + + + res\drawable-hdpi + 1 + + + res\drawable-hdpi + 1 + + + + + res\drawable-xhdpi + 1 + + + res\drawable-xhdpi + 1 + + + + + res\drawable-mdpi + 1 + + + res\drawable-mdpi + 1 + + + + + res\drawable-hdpi + 1 + + + res\drawable-hdpi + 1 + + + + + res\drawable-xhdpi + 1 + + + res\drawable-xhdpi + 1 + + + + + res\drawable-xxhdpi + 1 + + + res\drawable-xxhdpi + 1 + + + + + res\drawable-xxxhdpi + 1 + + + res\drawable-xxxhdpi + 1 + + + + + res\drawable-small + 1 + + + res\drawable-small + 1 + + + + + res\drawable-normal + 1 + + + res\drawable-normal + 1 + + + + + res\drawable-large + 1 + + + res\drawable-large + 1 + + + + + res\drawable-xlarge + 1 + + + res\drawable-xlarge + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + Contents\MacOS + 1 + .framework + + + Contents\MacOS + 1 + .framework + + + 0 + + + + + 1 + .dylib + + + 1 + .dylib + + + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + 0 + .dll;.bpl + + + + + 1 + .dylib + + + 1 + .dylib + + + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + 0 + .bpl + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + Contents\Resources\StartUp\ + 0 + + + Contents\Resources\StartUp\ + 0 + + + 0 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + + + 1 + + + 1 + + + + + ..\ + 1 + + + ..\ + 1 + + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + + + ..\ + 1 + + + ..\ + 1 + + + + + Contents + 1 + + + Contents + 1 + + + + + Contents\Resources + 1 + + + Contents\Resources + 1 + + + + + library\lib\armeabi-v7a + 1 + + + library\lib\arm64-v8a + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + Contents\MacOS + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + library\lib\armeabi-v7a + 1 + + + + + 1 + + + 1 + + + + + Assets + 1 + + + Assets + 1 + + + + + Assets + 1 + + + Assets + 1 + + + + + + + + + + + + + + + True + False + + + 12 + + + + +
diff --git a/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier_unit.dfm b/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier_unit.dfm new file mode 100644 index 0000000000..465d1801b2 --- /dev/null +++ b/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier_unit.dfm @@ -0,0 +1,27 @@ +object Form1: TForm1 + Left = 0 + Top = 0 + Caption = 'Form1' + ClientHeight = 347 + ClientWidth = 728 + Color = clBtnFace + Font.Charset = DEFAULT_CHARSET + Font.Color = clWindowText + Font.Height = -11 + Font.Name = 'Tahoma' + Font.Style = [] + OldCreateOrder = False + OnCreate = FormCreate + OnDestroy = FormDestroy + PixelsPerInch = 96 + TextHeight = 13 + object Memo1: TMemo + Left = 8 + Top = 8 + Width = 697 + Height = 321 + Lines.Strings = ( + 'Memo1') + TabOrder = 0 + end +end diff --git a/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier_unit.pas b/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier_unit.pas new file mode 100644 index 0000000000..ba897f415c --- /dev/null +++ b/windows/src/test/manual-tests/keyboard_ll_identifier/keyboard_ll_identifier_unit.pas @@ -0,0 +1,85 @@ +unit keyboard_ll_identifier_unit; + +interface + +uses + Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics, + Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls; + +type + TForm1 = class(TForm) + Memo1: TMemo; + procedure FormCreate(Sender: TObject); + procedure FormDestroy(Sender: TObject); + private + procedure AppOnMessage(var Msg: TMsg; var Handled: Boolean); + { Private declarations } + public + { Public declarations } + end; + +var + Form1: TForm1; + +implementation + +{$R *.dfm} + +var + hh: Cardinal; + +type + TKbdLlHookStruct = record + vkCode, scanCode, flags, time: DWORD; + dwExtraInfo: ULONG_PTR; + end; + PKbdLlHookStruct = ^TKbdLlHookStruct; + +function kbdll(nCode: Integer; wParam: WPARAM; lParam: LPARAM): LRESULT; stdcall; +var + s: PKbdllhookstruct; +begin + if nCode < 0 then + begin + Exit(CallNextHookEx(hh, nCode, wParam, lParam)); + end; + + s := PkbdllHookStruct(lParam); + + if s.vkCode in [VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU, VK_LSHIFT, VK_RSHIFT, VK_CONTROL, VK_SHIFT, VK_MENU] then + if Assigned(Form1) then + begin + Form1.memo1.Lines.Add(Format('%08.8x %08.8x %08.8x', [s.vkCode, s.scanCode, s.flags])); + end; + + Exit(CallNextHookEx(hh, nCode, wParam, lParam)); +end; + +procedure TForm1.FormCreate(Sender: TObject); +begin +// Uncomment to monitor WM_KEY* and WM_SYSKEY* messages +// Application.OnMessage := AppOnMessage; + + + hh := SetWindowsHookEx(WH_KEYBOARD_LL, kbdll, GetModuleHandle(nil), 0); +end; + +procedure TForm1.FormDestroy(Sender: TObject); +begin + Form1 := nil; + UnhookWindowsHookEx(hh); +end; + +procedure TForm1.AppOnMessage(var Msg: TMsg; var Handled: Boolean); +begin + if (Msg.message = WM_KEYDOWN) or (Msg.message = WM_SYSKEYDOWN) or (Msg.message = WM_KEYUP) or (Msg.message = WM_SYSKEYUP) then + begin + if Msg.wParam in [VK_CONTROL, VK_SHIFT, VK_MENU] then + if (Msg.LParam and $FFFF) = 1 then + begin + memo1.Lines.Add(Format('%08.8x %08.8x', [Msg.wParam, Msg.lParam])); + end; + end; +end; + +end.