From bda4b50e2a7e7e60e8d8f232b482571eeacb310f Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Fri, 29 May 2026 19:31:00 +0200 Subject: [PATCH 01/36] test(web): add e2e tests for examples from guide Build-bot: skip build:web Test-bot: skip --- web/build.sh | 15 + .../engine/guide/examples/__auto-control.html | 4 +- .../guide/examples/__full-manual-control.html | 8 +- .../guide/examples/__manual-control.html | 6 +- .../guide/examples/full-manual-control.md | 8 +- .../engine/guide/examples/manual-control.md | 7 +- web/src/test/auto/e2e/e2eUtils.ts | 64 +++++ web/src/test/auto/e2e/guide-examples.tests.ts | 256 ++++++++++++++++++ 8 files changed, 353 insertions(+), 15 deletions(-) create mode 100644 web/src/test/auto/e2e/e2eUtils.ts create mode 100644 web/src/test/auto/e2e/guide-examples.tests.ts diff --git a/web/build.sh b/web/build.sh index ca253ab82c..4154c6203c 100755 --- a/web/build.sh +++ b/web/build.sh @@ -114,6 +114,21 @@ build_tests_action() { cp "${KEYMAN_ROOT}/web/src/test/auto/dom/cases/attachment/textStoreForElement.tests.html" \ "${KEYMAN_ROOT}/web/build/test/dom/cases/attachment/" + + # Copy and update guide examples - for local, PR, and test builds we + # replace the CDN URL with the local build path, so that we can test + # against the current build + mkdir -p "${KEYMAN_ROOT}/web/build/docs/engine/guide" + cp -r "${KEYMAN_ROOT}/web/docs/engine/guide/examples" \ + "${KEYMAN_ROOT}/web/build/docs/engine/guide/" + + # shellcheck disable=SC2310 + if ! builder_is_ci_release_build; then + for f in "${KEYMAN_ROOT}/web/build/docs/engine/guide/examples"/*.html; do + sed "s|https://s\.keyman\.com/kmw/engine/[0-9]*\.[0-9]*\.[0-9]*/|/build/publish/${config}/|g" \ + "${f}" > "${f}.tmp" && mv "${f}.tmp" "${f}" + done + fi } coverage_action() { diff --git a/web/docs/engine/guide/examples/__auto-control.html b/web/docs/engine/guide/examples/__auto-control.html index c2a8b472b0..76ff91cf98 100644 --- a/web/docs/engine/guide/examples/__auto-control.html +++ b/web/docs/engine/guide/examples/__auto-control.html @@ -19,8 +19,8 @@

Automatic Mode Example

-

-

+

+

Back to Document diff --git a/web/docs/engine/guide/examples/__full-manual-control.html b/web/docs/engine/guide/examples/__full-manual-control.html index 6313c13518..b862184ff0 100644 --- a/web/docs/engine/guide/examples/__full-manual-control.html +++ b/web/docs/engine/guide/examples/__full-manual-control.html @@ -24,13 +24,13 @@ keyman.init().then(async function() { } document.f.multilingual.focus(); - keyman.setActiveKeyboard('', ''); + await keyman.setActiveKeyboard('', ''); }); -function KWControlChange() { +async function KWControlChange() { var name = KWControl.value.substr(0, KWControl.value.indexOf("$$")); var languageCode = KWControl.value.substr(KWControl.value.indexOf("$$") + 2); - keyman.setActiveKeyboard(name, languageCode); + await keyman.setActiveKeyboard(name, languageCode); document.f.multilingual.focus(); } @@ -40,7 +40,7 @@ function KWControlChange() {

Manual Control - Custom Interface

-

Keyboard:

+

diff --git a/web/docs/engine/guide/examples/__manual-control.html b/web/docs/engine/guide/examples/__manual-control.html index a1cfa72c83..f3c1b7a80e 100644 --- a/web/docs/engine/guide/examples/__manual-control.html +++ b/web/docs/engine/guide/examples/__manual-control.html @@ -11,7 +11,7 @@ languages: { id: 'lo', name: 'Lao' }, filename: "./js/laokeys.js" }); - keyman.setActiveKeyboard('laokeys'); + await keyman.setActiveKeyboard('laokeys'); keyman.osk.hide(); }); @@ -30,8 +30,8 @@

KeymanWeb

-

-

+

+

Back to Document diff --git a/web/docs/engine/guide/examples/full-manual-control.md b/web/docs/engine/guide/examples/full-manual-control.md index 39be4d34b3..98d4236f4d 100644 --- a/web/docs/engine/guide/examples/full-manual-control.md +++ b/web/docs/engine/guide/examples/full-manual-control.md @@ -28,15 +28,15 @@ Include the following script in the HEAD of your page: } document.f.multilingual.focus(); - keyman.setActiveKeyboard('', ''); + await keyman.setActiveKeyboard('', ''); }); /* KWControlChange: Called when user selects an item in the KWControl SELECT */ - function KWControlChange() { + async function KWControlChange() { /* Select the keyboard in KeymanWeb */ var name = KWControl.value.substr(0, KWControl.value.indexOf("$$")); - var languageCode = KWControl.value.substr(KWControl.value.indexOf("$$"+2)); - keyman.setActiveKeyboard(name, languageCode); + var languageCode = KWControl.value.substr(KWControl.value.indexOf("$$") + 2); + await keyman.setActiveKeyboard(name, languageCode); /* Focus onto the multilingual field in the form */ document.f.multilingual.focus(); } diff --git a/web/docs/engine/guide/examples/manual-control.md b/web/docs/engine/guide/examples/manual-control.md index d67db736e9..8675949936 100644 --- a/web/docs/engine/guide/examples/manual-control.md +++ b/web/docs/engine/guide/examples/manual-control.md @@ -2,7 +2,10 @@ title: Manual Mode Example --- -In this example, the web page designer specifies when KeymanWeb's on-screen keyboard may be displayed on non-mobile devices. They have also specified that the LaoKeys keyboard should be activated by default. This example continues to use the KeymanWeb default interface. Please click [this link](__manual-control.html) to open the test page. +In this example, the web page designer specifies when KeymanWeb's on-screen keyboard may be +displayed on non-mobile devices. They have also specified that the LaoKeys keyboard should be +activated by default. This example continues to use the KeymanWeb default interface. Please click +[this link](__manual-control.html) to open the test page. ## Code Walkthrough @@ -17,7 +20,7 @@ Include the following script in the HEAD of your page: languages:{id:'lo',name:'Lao'}, filename: "./js/laokeys.js" }); - keyman.setActiveKeyboard('laokeys'); + await keyman.setActiveKeyboard('laokeys'); keyman.osk.hide(); }); diff --git a/web/src/test/auto/e2e/e2eUtils.ts b/web/src/test/auto/e2e/e2eUtils.ts new file mode 100644 index 0000000000..0ef45ffec0 --- /dev/null +++ b/web/src/test/auto/e2e/e2eUtils.ts @@ -0,0 +1,64 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ + +import { type Locator, type Page } from "@playwright/test"; + +/** + * Expands the keyboard selection menu and returns the text content of the + * currently selected keyboard. + */ +export async function getSelectedKeyboardMenuText(page: Page): Promise { + const watchDog = page.waitForFunction(() => !!document.getElementById('KeymanWeb_KbdList')); + await page.getByRole('img', { name: 'Use Web Keyboard' }).click(); + await watchDog; + return page.evaluate(() => { + const selectedKbd = document.querySelector('#kmwico .selected'); + return selectedKbd?.textContent; + }); +}; + +/** + * Expands the keyboard selection menu and returns the menu items as an array + */ +export async function getAllKeyboardMenuText(page: Page): Promise<(string|undefined)[]> { + const watchDog = page.waitForFunction(() => !!document.getElementById('KeymanWeb_KbdList')); + await page.getByRole('img', { name: 'Use Web Keyboard' }).hover(); + await watchDog; + return page.evaluate(() => { + const menuItems = []; + const menuDiv = document.querySelector('#kmwico'); + const kbdList = menuDiv?.lastElementChild; + for (let i = 0; i < (kbdList ? kbdList.children.length : 0); i++) { + const item = kbdList?.children[i]; + menuItems.push(item?.textContent); + } + return menuItems; + }); +} + +/** + * Loads the specified URL and waits for the page load event. + */ +export async function loadPage(page: Page, url: string): Promise { + const loadPromise = page.waitForEvent('load'); + await page.goto(url); + return loadPromise; +} + +/** + * Clicks the specified field and waits for the OSK to be shown, returning a + * locator for the OSK title bar. + */ +export async function clickFieldAndWaitForOSK(page: Page, fieldLocator: Locator): Promise { + const keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await fieldLocator.click(); + await keyboardchangePromise; + return page.locator('#keymanweb_title_bar'); +} diff --git a/web/src/test/auto/e2e/guide-examples.tests.ts b/web/src/test/auto/e2e/guide-examples.tests.ts new file mode 100644 index 0000000000..19f2fb4bf8 --- /dev/null +++ b/web/src/test/auto/e2e/guide-examples.tests.ts @@ -0,0 +1,256 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ +import { test, expect, type Page } from '@playwright/test'; +import { clickFieldAndWaitForOSK, getAllKeyboardMenuText, getSelectedKeyboardMenuText, loadPage } from './e2eUtils'; + +async function setTimeoutAndLoadPage(page: Page, url: string): Promise { + test.setTimeout(5000); + await loadPage(page, url); +} + +test.describe('First example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__first-example.html'); + } + + test('Input field shows US keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + const oskTitleBar = await clickFieldAndWaitForOSK(page, page.getByPlaceholder('Hello World')); + + // Verify OSK shows US keyboard + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).toBeVisible(); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(oskTitleBar).toContainText('US'); + + await expect(await getSelectedKeyboardMenuText(page)).toBe('English - US'); + }); + + test('Keyman menu has expected keyboards', async ({ page }) => { + // Setup + await beforeEach(page); + await clickFieldAndWaitForOSK(page, page.getByPlaceholder('Hello World')); + + // Verify OSK menu has expected entries + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).toBeVisible(); + await expect(await getAllKeyboardMenuText(page)).toEqual(['(System keyboard)', 'English - US', 'Thai - Thai Kedmanee Basic']) + }); +}); + +test.describe('Auto-control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__auto-control.html'); + } + + test('Input field shows Lao keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByTestId('multilingual' ).click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao (Phonetic)'); + }); + + test('Textarea shows Lao keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByTestId('textarea').click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao (Phonetic)'); + }); +}); + +test.describe('Control-by-control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__control-by-control.html'); + } + + test('address field does not have KeymanWeb enabled', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByPlaceholder('id = address').click(); + + // Verify OSK is not shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeFalsy(); + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).not.toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).not.toBeVisible(); + }); + + // TODO: #16080 + test.skip('subject field does not show keyboard and defaults to system keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByPlaceholder('id = subject').click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).not.toBeVisible(); + + await expect(await getSelectedKeyboardMenuText(page)).toBe('(System keyboard)'); + }); + + test('message body field shows Lao keyboard', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByPlaceholder('id = text').click(); + + // Verify OSK is shown + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.getByRole('img', { name: 'Use Web Keyboard' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Show On Screen Keyboard' })).toBeVisible(); + + // Verify Lao (Phonetic) keyboard is active + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao (Phonetic)'); + // Verify "Lao - Lao (Phonetic)" is selected (bold) in the menu + await expect(await getSelectedKeyboardMenuText(page)).toBe('Lao - Lao (Phonetic)'); + }); +}); + +test.describe('Full manual control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__full-manual-control.html'); + } + + test('Shows English and no OSK after loading page', async ({ page }) => { + // Setup + await beforeEach(page); + + // Verify 'English' selected (which has the value '') and no OSK showing + await expect(page.getByLabel('Keyboard')).toHaveValue(''); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + }); + + test('Selecting English keyboard shows no OSK', async ({ page }) => { + // Setup + await beforeEach(page); + // first switch to Hebrew + let keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('Hebrew'); + await keyboardchangePromise; + + // then back to English + keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('English'); + await keyboardchangePromise; + + // Verify no OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + }); + + test('Selecting Devanagari keyboard shows Devanagari OSK', async ({ page }) => { + // Setup + await beforeEach(page); + const keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('Devanagari (INSCRIPT)'); + await keyboardchangePromise; + + // Verify Devanagari OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Devanagari (INSCRIPT)'); + }); + + test('Selecting Hebrew shows Hebrew OSK', async ({ page }) => { + // Setup + await beforeEach(page); + const keyboardchangePromise = page.evaluate(async () => { + return new Promise((resolve) => { + keyman.addEventListener('keyboardchange', function (kbd) { + resolve(kbd); + }); + }); + }); + await page.getByLabel('Keyboard').selectOption('Hebrew'); + await keyboardchangePromise; + + // Verify Hebrew OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Hebrew'); + }); +}); + +test.describe('Manual control example from the guide', function () { + const beforeEach = async (page: Page) => { + await setTimeoutAndLoadPage(page, 'http://localhost:3000/build/docs/engine/guide/examples/__manual-control.html'); + } + + test('Does not show OSK after loading', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByTestId('multilingual').click(); + + // Verify no OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).not.toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + }); + + test('Shows Lao OSK after clicking button', async ({ page }) => { + // Setup + await beforeEach(page); + await page.getByAltText('KeymanWeb').click(); + await page.getByTestId('multilingual').click(); + + // Verify Lao OSK showing + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao'); + }); + + test('Hides Lao OSK after clicking button', async ({ page }) => { + // Setup + await beforeEach(page); + + // click button + await page.getByAltText('KeymanWeb').click(); + + // Verify Lao OSK showing + await page.getByTestId('multilingual').click(); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).toBeTruthy(); + await expect(page.locator('#keymanweb_title_bar')).toContainText('Lao'); + + // Click button again to hide OSK + await page.getByAltText('KeymanWeb').click(); + + // Verify Lao OSK not showing + await page.getByTestId('multilingual').click(); + await expect(await page.evaluate(() => keyman.osk.isEnabled())).not.toBeTruthy(); + await expect(await page.evaluate(() => keyman.osk.isVisible())).not.toBeTruthy(); + + }); +}); + From b614411045fb2aa62e882ce3e719c5cf3e46c032 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 22 Jun 2026 15:48:19 +0200 Subject: [PATCH 02/36] fix(developer): map shift key nextlayer property when importing OSK Fixes: #14227 Test-bot: skip --- ...tem.VisualKeyboardToTouchLayoutConverter.pas | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas b/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas index 5f19d98a2a..4f3b9d4135 100644 --- a/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas +++ b/developer/src/kmconvert/Keyman.Developer.System.VisualKeyboardToTouchLayoutConverter.pas @@ -319,6 +319,23 @@ function TVisualKeyboardToTouchLayoutConverter.SetupModifierKeysForImportedLayou for l in p.Layers do begin + // Find the Shift key and assign the next layer + k := l.FindKeyById('K_SHIFT'); + if Assigned(k) then + begin + if l.id = 'default' then + begin + k.NextLayer := 'shift'; + end + else + begin + // All layers other than default will return to default layer + // when shift is pressed, because we do not currently map + // shift+other mod layers with use of the Shift key in the import + k.NextLayer := 'default'; + end; + end; + // Find the Ctrl key for the layer k := l.FindKeyById('K_LCONTROL'); if not Assigned(k) then From 647a9ebc98fb3da1276ff338f427256706d59132 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 22 Jun 2026 17:17:18 +0200 Subject: [PATCH 03/36] fix(developer): prevent clone of legacy keyboards with no source If a Keyman Cloud keyboard is legacy and has no source available, make this more obvious to the author when they attempt to clone it. Add also messages to help the author complete the other required steps to clone a keyboard. Fixes: #15606 Test-bot: skip --- .../delphi/general/Upload_Settings.pas | 9 + ....UfrmCloneKeymanCloudProjectParameters.dfm | 246 +++++++++--------- ....UfrmCloneKeymanCloudProjectParameters.pas | 134 +++++++++- 3 files changed, 269 insertions(+), 120 deletions(-) diff --git a/common/windows/delphi/general/Upload_Settings.pas b/common/windows/delphi/general/Upload_Settings.pas index 8811ec6f10..2763a042be 100644 --- a/common/windows/delphi/general/Upload_Settings.pas +++ b/common/windows/delphi/general/Upload_Settings.pas @@ -86,6 +86,8 @@ function API_UserAgent: string; // = 'Keyman for Windows/...' function API_UserAgent_Developer: string; // = 'Keyman Developer/...' function API_UserAgent_Diagnostics: string; +function API_Path_Keyboard(const id: string): string; + function KeymanCom_Protocol_Server: string; // = 'https://keyman.com'; function MakeAPIURL(path: string): string; @@ -121,6 +123,8 @@ const S_KeymanCom_Staging = 'https://keyman.com'; // #7227 disabling: 'https://keyman-staging.com'; S_APIServer_Staging = 'api.keyman.com'; // #7227 disabling: 'api.keyman-staging.com'; + S_API_Path_Keyboard = '/keyboard/%0:s'; + const URLPath_PackageDownload_Format = '/go/package/download/%0:s?platform=windows&tier=%1:s&bcp47=%2:s&update=%3:d'; URL_KeymanDeveloper_HelpKmcMessage_Format = S_Host_KmnSh+'/%0:s'; @@ -187,4 +191,9 @@ begin Result := Format(URL_KeymanDeveloper_HelpKmcMessage_Format, [id.ToLower]); end; +function API_Path_Keyboard(const id: string): string; +begin + Result := Format(S_API_Path_Keyboard, [id]); +end; + end. diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm index fb286bccc3..e06066a2e1 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.dfm @@ -1,119 +1,127 @@ -inherited frmCloneKeymanCloudProjectParameters: TfrmCloneKeymanCloudProjectParameters - BorderIcons = [biSystemMenu, biMaximize] - Caption = 'Clone Project from Keyman Cloud' - ClientHeight = 506 - ClientWidth = 840 - Position = poScreenCenter - ExplicitWidth = 856 - ExplicitHeight = 545 - PixelsPerInch = 96 - TextHeight = 13 - object cmdOK: TButton - Left = 680 - Top = 473 - Width = 73 - Height = 25 - Anchors = [akRight, akBottom] - Caption = 'OK' - Default = True - TabOrder = 2 - OnClick = cmdOKClick - end - object cmdCancel: TButton - Left = 759 - Top = 473 - Width = 73 - Height = 25 - Anchors = [akRight, akBottom] - Cancel = True - Caption = 'Cancel' - ModalResult = 2 - TabOrder = 3 - end - object panWebHost: TPanel - Left = 0 - Top = 0 - Width = 842 - Height = 334 - Anchors = [akLeft, akTop, akRight, akBottom] - BevelOuter = bvNone - TabOrder = 0 - end - object gbNewProjectDetails: TGroupBox - Left = 8 - Top = 340 - Width = 823 - Height = 129 - Anchors = [akLeft, akRight, akBottom] - Caption = 'New Project Details' - TabOrder = 1 - object lblFileName: TLabel - Left = 16 - Top = 24 - Width = 76 - Height = 13 - Caption = '&New project ID:' - FocusControl = editKeyboardID - end - object lblProjectFilename: TLabel - Left = 16 - Top = 102 - Width = 101 - Height = 13 - Caption = 'New project &filename' - FocusControl = editProjectFilename - end - object lblPath: TLabel - Left = 16 - Top = 75 - Width = 83 - Height = 13 - Caption = 'Destination &path:' - FocusControl = editPath - end - object editKeyboardID: TEdit - Left = 148 - Top = 21 - Width = 205 - Height = 21 - TabOrder = 0 - OnChange = editKeyboardIDChange - end - object editProjectFilename: TEdit - Left = 148 - Top = 99 - Width = 669 - Height = 21 - TabStop = False - ParentColor = True - ReadOnly = True - TabOrder = 4 - OnChange = editKeyboardIDChange - end - object cmdBrowse: TButton - Left = 744 - Top = 72 - Width = 73 - Height = 21 - Caption = '&Browse...' - TabOrder = 3 - OnClick = cmdBrowseClick - end - object editPath: TEdit - Left = 148 - Top = 72 - Width = 590 - Height = 21 - TabOrder = 2 - OnChange = editPathChange - end - object chkRelocateExternal: TCheckBox - Left = 148 - Top = 48 - Width = 257 - Height = 17 - Caption = 'Relocate &external files into new project folder' - TabOrder = 1 - end - end -end +inherited frmCloneKeymanCloudProjectParameters: TfrmCloneKeymanCloudProjectParameters + BorderIcons = [biSystemMenu, biMaximize] + Caption = 'Clone Project from Keyman Cloud' + ClientHeight = 506 + ClientWidth = 840 + Position = poScreenCenter + ExplicitWidth = 856 + ExplicitHeight = 545 + PixelsPerInch = 96 + TextHeight = 13 + object lblMessage: TLabel + Left = 8 + Top = 478 + Width = 314 + Height = 13 + Caption = 'The keyboard %0:s has no source available. It cannot be cloned.' + FocusControl = editPath + end + object cmdOK: TButton + Left = 680 + Top = 473 + Width = 73 + Height = 25 + Anchors = [akRight, akBottom] + Caption = 'OK' + Default = True + TabOrder = 2 + OnClick = cmdOKClick + end + object cmdCancel: TButton + Left = 759 + Top = 473 + Width = 73 + Height = 25 + Anchors = [akRight, akBottom] + Cancel = True + Caption = 'Cancel' + ModalResult = 2 + TabOrder = 3 + end + object panWebHost: TPanel + Left = 0 + Top = 0 + Width = 842 + Height = 334 + Anchors = [akLeft, akTop, akRight, akBottom] + BevelOuter = bvNone + TabOrder = 0 + end + object gbNewProjectDetails: TGroupBox + Left = 8 + Top = 340 + Width = 823 + Height = 129 + Anchors = [akLeft, akRight, akBottom] + Caption = 'New Project Details' + TabOrder = 1 + object lblFileName: TLabel + Left = 16 + Top = 24 + Width = 76 + Height = 13 + Caption = '&New project ID:' + FocusControl = editKeyboardID + end + object lblProjectFilename: TLabel + Left = 16 + Top = 102 + Width = 101 + Height = 13 + Caption = 'New project &filename' + FocusControl = editProjectFilename + end + object lblPath: TLabel + Left = 16 + Top = 75 + Width = 83 + Height = 13 + Caption = 'Destination &path:' + FocusControl = editPath + end + object editKeyboardID: TEdit + Left = 148 + Top = 21 + Width = 205 + Height = 21 + TabOrder = 0 + OnChange = editKeyboardIDChange + end + object editProjectFilename: TEdit + Left = 148 + Top = 99 + Width = 669 + Height = 21 + TabStop = False + ParentColor = True + ReadOnly = True + TabOrder = 4 + OnChange = editKeyboardIDChange + end + object cmdBrowse: TButton + Left = 744 + Top = 72 + Width = 73 + Height = 21 + Caption = '&Browse...' + TabOrder = 3 + OnClick = cmdBrowseClick + end + object editPath: TEdit + Left = 148 + Top = 72 + Width = 590 + Height = 21 + TabOrder = 2 + OnChange = editPathChange + end + object chkRelocateExternal: TCheckBox + Left = 148 + Top = 48 + Width = 257 + Height = 17 + Caption = 'Relocate &external files into new project folder' + TabOrder = 1 + end + end +end diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas index c3f09a6406..36014446b0 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas @@ -46,6 +46,7 @@ type cmdBrowse: TButton; editPath: TEdit; chkRelocateExternal: TCheckBox; + lblMessage: TLabel; procedure cmdOKClick(Sender: TObject); procedure editSourceProjectFilenameChange(Sender: TObject); procedure FormCreate(Sender: TObject); @@ -57,6 +58,7 @@ type cef: TframeCEFHost; dlgBrowse: TBrowse4Folder; FKeymanID: string; + FSourceAvailable: Boolean; frmDownloadProgress: TfrmDownloadProgress; function GetBasePath: string; function GetKeyboardID: string; @@ -64,6 +66,7 @@ type procedure EnableControls; procedure SetKeyboardID(const Value: string); procedure UpdateProjectFilename; + procedure UpdateMessage; function GetProjectFilename: string; function GetSourceProjectFilename: string; function GetRelocateExternal: Boolean; @@ -71,6 +74,7 @@ type procedure cefLoadEnd(Sender: TObject); procedure DownloadCallback(Owner: TfrmDownloadProgress; var Result: Boolean); procedure DownloadWrapperCallback(var Cancelled: Boolean); + function IsKeyboardSourceAvailable(const id: string): Boolean; protected function GetHelpTopic: string; override; public @@ -86,9 +90,11 @@ function ShowCloneKeymanCloudProjectParameters(Owner: TComponent): Boolean; implementation uses + System.JSON, System.Net.UrlClient, Vcl.ComCtrls, + HttpUploader, KeymanDeveloperOptions, Keyman.Developer.System.KmcWrapper, Keyman.Developer.System.HelpTopics, @@ -182,6 +188,7 @@ begin dlgBrowse.Root := Desktop; dlgBrowse.Title := 'Select folder to save project to'; + UpdateMessage; EnableControls; end; @@ -201,9 +208,97 @@ begin not u.Path.StartsWith(URLSubPath_KeymanDeveloper_Clone_Keyboards_Custom) then FKeymanID := u.Path.Substring(URLSubPath_KeymanDeveloper_Clone_Keyboards.Length) else FKeymanID := ''; + + FSourceAvailable := IsKeyboardSourceAvailable(FKeymanID); + UpdateMessage; EnableControls; end; +function TfrmCloneKeymanCloudProjectParameters.IsKeyboardSourceAvailable(const id: string): Boolean; + + function GetKeyboardDataFromApiServer(const id: string): string; + var + http: THTTPUploader; + begin + http := THTTPUploader.Create(nil); + try + http.Request.HostName := API_Server; + http.Request.Protocol := API_Protocol; + http.Request.UrlPath := API_Path_Keyboard(id); + try + http.Upload; + except + // Silently swallow network errors + on E:Exception do Exit(''); + end; + + if (http.Response.StatusCode < 200) or (http.Response.StatusCode > 299) then + begin + // Keyboard not found or invalid response + Exit(''); + end; + + Result := UTF8ToString(PAnsiChar(http.Response.MessageBodyAsString)); + finally + FreeAndNil(http); + end; + end; + + function GetSourcePathFromBody(const body: string): string; + var + val: TJSONValue; + obj: TJSONObject; + begin + try + val := TJSONObject.ParseJSONValue(body); + except + // Not a valid response + Exit(''); + end; + + if not (val is TJSONObject) then + begin + // Not a valid response + Exit(''); + end; + + obj := val as TJSONObject; + val := obj.Values['sourcePath']; + if not Assigned(val) or not (val is TJSONString) then + begin + // no sourcePath property + Exit(''); + end; + + Result := (val as TJSONString).Value; + end; + +var + body, sourcePath: string; +begin + if id = '' then + begin + Exit(False); + end; + + body := GetKeyboardDataFromApiServer(id); + if body = '' then + begin + Exit(False); + end; + + sourcePath := GetSourcePathFromBody(body); + if sourcePath = '' then + begin + Exit(False); + end; + + // Keyboards in legacy/ do not have source available. Keyboards in + // release/ and experimental/ have source, and other new categories will + // also have source in future. + Result := not sourcePath.startsWith('legacy'); +end; + procedure TfrmCloneKeymanCloudProjectParameters.cmdBrowseClick(Sender: TObject); begin dlgBrowse.InitialDir := editPath.Text; @@ -221,22 +316,26 @@ end; procedure TfrmCloneKeymanCloudProjectParameters.editKeyboardIDChange(Sender: TObject); begin UpdateProjectFilename; + UpdateMessage; EnableControls; end; procedure TfrmCloneKeymanCloudProjectParameters.editSourceProjectFilenameChange(Sender: TObject); begin + UpdateMessage; EnableControls; end; procedure TfrmCloneKeymanCloudProjectParameters.editPathChange(Sender: TObject); begin UpdateProjectFilename; + UpdateMessage; EnableControls; end; procedure TfrmCloneKeymanCloudProjectParameters.editVersionChange(Sender: TObject); begin + UpdateMessage; EnableControls; end; @@ -248,7 +347,8 @@ begin (FKeymanID <> '') and (Trim(editPath.Text) <> '') and (Trim(editKeyboardID.Text) <> '') and - TKeyboardUtils.IsValidKeyboardID(Trim(editKeyboardID.Text), True); + TKeyboardUtils.IsValidKeyboardID(Trim(editKeyboardID.Text), True) and + FSourceAvailable; cmdOK.Enabled := e; end; @@ -292,9 +392,41 @@ end; procedure TfrmCloneKeymanCloudProjectParameters.SetKeyboardID(const Value: string); begin editKeyboardID.Text := Value; + UpdateMessage; EnableControls; end; +procedure TfrmCloneKeymanCloudProjectParameters.UpdateMessage; +var + msg: string; +begin + if FKeymanID = '' then + begin + msg := 'Please choose a keyboard from the search form above.'; + end + else if not FSourceAvailable then + begin + msg := Format('The keyboard %0:s has no source available. It cannot be cloned.', [FKeymanID]); + end + else if Trim(editPath.Text) = '' then + begin + msg := 'A valid destination path must be selected.'; + end + else if Trim(editKeyboardID.Text) = '' then + begin + msg := 'Please enter a valid new project identifier.'; + end + else if not TKeyboardUtils.IsValidKeyboardID(Trim(editKeyboardID.Text), True) then + begin + msg := 'Please enter a valid new project identifier.'; + end + else + begin + msg := ''; + end; + lblMessage.Caption := msg; +end; + procedure TfrmCloneKeymanCloudProjectParameters.UpdateProjectFilename; begin editProjectFilename.Text := From acf64f1897b5d0c07b415d0e77ec5725fdbecc3c Mon Sep 17 00:00:00 2001 From: rc-swag <58423624+rc-swag@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:07:38 +1000 Subject: [PATCH 04/36] fix(windows): add update property to remote check This just adds the update property to the HTTP request in the RemoteUpdateCheck call. Fixes: #15433 --- .../src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas | 1 + 1 file changed, 1 insertion(+) diff --git a/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas b/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas index dc41ae59bc..0a28e1b828 100644 --- a/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas +++ b/windows/src/desktop/kmshell/main/Keyman.System.RemoteUpdateCheck.pas @@ -158,6 +158,7 @@ begin try http.Fields.Add('version', ansistring(CKeymanVersionInfo.Version)); http.Fields.Add('tier', ansistring(CKeymanVersionInfo.Tier)); + http.Fields.Add('update', '1'); // This is checking for an update if FForce then http.Fields.Add('manual', '1') else From 3e345274af26c48da4c617afca0cb2f09f3d1909 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 23 Jun 2026 11:57:15 +0200 Subject: [PATCH 05/36] fix(developer): fixup references to layer after deleting in Touch Layout Editor Also address fixup after renaming a layer so that it does not touch the key `layer` property, because that refers actually to "modifier", not "layer" (these terms were conflated in the original design). Fixes: #15340 Test-bot: skip --- .../tike/xml/layoutbuilder/layer-controls.js | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/developer/src/tike/xml/layoutbuilder/layer-controls.js b/developer/src/tike/xml/layoutbuilder/layer-controls.js index 8fa04d369e..0f1797d413 100644 --- a/developer/src/tike/xml/layoutbuilder/layer-controls.js +++ b/developer/src/tike/xml/layoutbuilder/layer-controls.js @@ -31,7 +31,8 @@ $(function() { $('#btnDelLayer').click(function () { if ($('#selLayer option').length == 1) return; builder.saveUndo(); - KVKL[builder.lastPlatform].layer.splice(builder.lastLayerIndex, 1); + const deletedLayer = KVKL[builder.lastPlatform].layer.splice(builder.lastLayerIndex, 1)[0]; + updateNextLayerReferences(KVKL[builder.lastPlatform], deletedLayer.id, ''); builder.selectPlatform(); builder.generate(false,true); }); @@ -45,29 +46,8 @@ $(function() { // Layer dialogs // - function submitLayerProperties() { - var newLayerName = $('#layerName').val(); - if (!newLayerName.match(/^[a-zA-Z0-9_-]+$/)) { - alert('Layer name must contain only alphanumerics, underscore and hyphen.'); - return false; - } - for(var i = 0; i < KVKL[builder.lastPlatform].layer.length; i++) { - if(i != builder.lastLayerIndex && KVKL[builder.lastPlatform].layer[i].id == newLayerName) { - alert('Layer name must not already be in use for the current platform.'); - return false; - } - } - - builder.saveUndo(); - builder.generate(); - - var platform = KVKL[builder.lastPlatform]; - var oldLayerName = platform.layer[builder.lastLayerIndex].id; - - let fixup = function(key) { - if (key.layer == oldLayerName) { - key.layer = newLayerName; - } + function updateNextLayerReferences(platform, oldLayerName, newLayerName) { + const fixup = function(key) { if (key.nextlayer == oldLayerName) { key.nextlayer = newLayerName; } @@ -94,6 +74,28 @@ $(function() { }); } }); + } + + function submitLayerProperties() { + const newLayerName = $('#layerName').val(); + if (!newLayerName.match(/^[a-zA-Z0-9_-]+$/)) { + alert('Layer name must contain only alphanumerics, underscore and hyphen.'); + return false; + } + for(let i = 0; i < KVKL[builder.lastPlatform].layer.length; i++) { + if(i != builder.lastLayerIndex && KVKL[builder.lastPlatform].layer[i].id == newLayerName) { + alert('Layer name must not already be in use for the current platform.'); + return false; + } + } + + builder.saveUndo(); + builder.generate(); + + const platform = KVKL[builder.lastPlatform]; + const oldLayerName = platform.layer[builder.lastLayerIndex].id; + + updateNextLayerReferences(platform, oldLayerName, newLayerName); platform.layer[builder.lastLayerIndex].id = newLayerName; builder.prepareLayers(); From cfe2e8ef06099e21742b0245eabca8d633d57689 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Wed, 24 Jun 2026 06:27:10 +0200 Subject: [PATCH 06/36] fix(developer): consolidate user options in TypeScript code * Remove Server's 'config.json' redundant user options handling * Refactor options.ts in Server and kmc to share common cross-platform code * Move option defaults into common code and sync with .pas defaults * Rename config.ts to standardPaths.ts to better represent the remaining purpose of the module. Fixes: #13458 --- developer/src/common/web/utils/src/index.ts | 2 + .../web/utils/src/keyman-developer-options.ts | 128 ++++++++++++++++++ .../src/commands/buildClasses/BuildProject.ts | 2 +- developer/src/kmc/src/util/KeymanSentry.ts | 2 +- developer/src/kmc/src/util/options.ts | 85 ++++-------- developer/src/server/src/KeymanSentry.ts | 2 +- developer/src/server/src/config.ts | 48 ------- developer/src/server/src/data.ts | 6 +- .../src/handlers/api/debugobject/register.ts | 4 +- developer/src/server/src/index.ts | 37 ++--- developer/src/server/src/options.ts | 86 ++++-------- developer/src/server/src/routes.ts | 5 +- developer/src/server/src/standardPaths.ts | 33 +++++ ....Developer.System.KeymanDeveloperPaths.pas | 1 - .../src/tike/main/KeymanDeveloperOptions.pas | 20 --- 15 files changed, 241 insertions(+), 220 deletions(-) create mode 100644 developer/src/common/web/utils/src/keyman-developer-options.ts delete mode 100644 developer/src/server/src/config.ts create mode 100644 developer/src/server/src/standardPaths.ts diff --git a/developer/src/common/web/utils/src/index.ts b/developer/src/common/web/utils/src/index.ts index a414139584..5371620d67 100644 --- a/developer/src/common/web/utils/src/index.ts +++ b/developer/src/common/web/utils/src/index.ts @@ -78,3 +78,5 @@ export { getFontFamily, getFontFamilySync } from './font-family.js'; export * as ValidIds from './valid-ids.js'; export * as ProjectLoader from './project-loader.js'; + +export { optionsManager, KeymanDeveloperOption, KeymanDeveloperOptions, KeymanDeveloperOptionsPath } from './keyman-developer-options.js'; diff --git a/developer/src/common/web/utils/src/keyman-developer-options.ts b/developer/src/common/web/utils/src/keyman-developer-options.ts new file mode 100644 index 0000000000..f1e831e684 --- /dev/null +++ b/developer/src/common/web/utils/src/keyman-developer-options.ts @@ -0,0 +1,128 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * User options for Keyman Developer. These are stored in options.json in the + * user profile; the location varies by operating system or may be stored in + * browser storage on web sites. + * + * The node-based loader is implemented in both kmc and Keyman Developer Server, + * in order to keep node dependencies out of the developer-utils module. + */ + +/** + * The standard path under the user profile where options.json is stored; use + * `path.join(os.homedir(), ...KeymanDeveloperOptionsPath)` or similar + */ +export const KeymanDeveloperOptionsPath = [/* '~', */ '.keymandeveloper', 'options.json']; + +/** + * The set of standard user options for Keyman Developer. Corresponds to + * TKeymanDeveloperOptions in the Keyman Developer TIKE source. + */ +export interface KeymanDeveloperOptions { + "use tab char": boolean; + "link font sizes": boolean; + "indent size": number; + "use old debugger": boolean; + "editor theme": string; + "debugger break when exiting line": boolean; + "debugger single step after break": boolean; + "debugger show store offset": boolean; + "debugger recompile with debug info": boolean; + "debugger auto reset before compilng": boolean; + "auto save before compiling": boolean; + "osk auto save before importing": boolean; + "web host port": number; + "server keep alive": boolean; + "server use local addresses": boolean; + "server ngrok token": string; + "server ngrok region": string; + "server use ngrok": boolean; + "server show console window": boolean; + "char map disable database lookups": boolean; + "char map auto lookup": boolean; + "open keyboard files in source view": boolean; + "display theme": string; + "external editor path": string; + "smtp server": string; + "test email addresses": string; + "web ladder length": number; + "default project path": string; + "automatically report errors": boolean; + "automatically report usage": boolean; + "toolbar visible": boolean; + "active project": string; + "prompt to upgrade projects": boolean; +}; + +/** + * A single Keyman Developer user option. + */ +export type KeymanDeveloperOption = keyof KeymanDeveloperOptions; + +const DEFAULT_OPTIONS: KeymanDeveloperOptions = { + // Corresponds to KeymanDeveloperOptions.pas, TKeymanDeveloperOptions.Read + "use tab char": false, + "link font sizes": true, + "indent size": 4, + "use old debugger": false, + "editor theme": '', + "debugger break when exiting line": true, + "debugger single step after break": false, + "debugger show store offset": false, + "debugger recompile with debug info": false, + "debugger auto reset before compilng": false, + "auto save before compiling": false, + "osk auto save before importing": false, + "web host port": 8008, + "server keep alive": false, + "server use local addresses": true, + "server ngrok token": '', + "server ngrok region": '', + "server use ngrok": false, + "server show console window": false, + "char map disable database lookups": false, + "char map auto lookup": true, + "open keyboard files in source view": false, + "display theme": 'Windows10', + "external editor path": '', + "smtp server": '', + "test email addresses": '', + "web ladder length": 100, + "default project path": '', // Note: this diverges from Delphi code, which uses CSIDL_PERSONAL on Windows, but it is not used in Server + "automatically report errors": true, + "automatically report usage": true, + "toolbar visible": true, + "active project": '', + "prompt to upgrade projects": true, +} + + +class KeymanDeveloperOptionsManager { + private options: KeymanDeveloperOptions = {...DEFAULT_OPTIONS}; + constructor() {} + + public load(blob: Uint8Array | null) { + this.options = {...DEFAULT_OPTIONS}; + if(blob !== null && blob !== undefined) { + const data = JSON.parse(new TextDecoder('utf-8').decode(blob)); + if(typeof data == 'object') { + // TODO: verify fields in options + this.options = {...DEFAULT_OPTIONS, ...data}; + return true; + } + } + return false; + } + + public get(valueName: T): KeymanDeveloperOptions[T] { + return this.options[valueName]; + } + + public clear() { + this.options = {...DEFAULT_OPTIONS}; + } +} + +export const optionsManager = new KeymanDeveloperOptionsManager(); + diff --git a/developer/src/kmc/src/commands/buildClasses/BuildProject.ts b/developer/src/kmc/src/commands/buildClasses/BuildProject.ts index 76fdfe1d04..6d51a3914a 100644 --- a/developer/src/kmc/src/commands/buildClasses/BuildProject.ts +++ b/developer/src/kmc/src/commands/buildClasses/BuildProject.ts @@ -46,7 +46,7 @@ class ProjectBuilder { // Give a hint if the project is v1.0 if(this.project.options.version != '2.0') { - if(getOption("prompt to upgrade projects", true)) { + if(getOption("prompt to upgrade projects")) { this.callbacks.reportMessage(InfrastructureMessages.Hint_ProjectIsVersion10()); } } diff --git a/developer/src/kmc/src/util/KeymanSentry.ts b/developer/src/kmc/src/util/KeymanSentry.ts index 9ee05ecb7f..658f750d37 100644 --- a/developer/src/kmc/src/util/KeymanSentry.ts +++ b/developer/src/kmc/src/util/KeymanSentry.ts @@ -23,7 +23,7 @@ export class KeymanSentry { return true; } - return getOption('automatically report errors', true); + return getOption('automatically report errors'); } static init(options?: SentryNodeOptions) { diff --git a/developer/src/kmc/src/util/options.ts b/developer/src/kmc/src/util/options.ts index 9d1a93895e..51217628cf 100644 --- a/developer/src/kmc/src/util/options.ts +++ b/developer/src/kmc/src/util/options.ts @@ -1,69 +1,32 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Load Keyman Developer's options from the standard Options location. This + * small loader is duplicated in Keyman Developer Server, because we do not have + * a shared node-aware module at this time. + */ + import * as os from 'node:os'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { KeymanDeveloperOption, KeymanDeveloperOptions, KeymanDeveloperOptionsPath, optionsManager } from '@keymanapp/developer-utils'; -export interface KeymanDeveloperOptions { - "use tab char"?: boolean; - "link font sizes"?: boolean; - "indent size"?: number; - "use old debugger"?: boolean; - "editor theme"?: string; - "debugger break when exiting line"?: boolean; - "debugger single step after break"?: boolean; - "debugger show store offset"?: boolean; - "debugger recompile with debug info"?: boolean; - "debugger auto reset before compilng"?: boolean; - "auto save before compiling"?: boolean; - "osk auto save before importing"?: boolean; - "web host port"?: number; - "server keep alive"?: boolean; - "server use local addresses"?: boolean; - "server ngrok token"?: string; - "server ngrok region"?: string; - "server use ngrok"?: boolean; - "server show console window"?: boolean; - "char map disable database lookups"?: boolean; - "char map auto lookup"?: boolean; - "open keyboard files in source view"?: boolean; - "display theme"?: string; - "external editor path"?: string; - "smtp server"?: string; - "test email addresses"?: string; - "web ladder length"?: number; - "default project path"?: string; - "automatically report errors"?: boolean; - "automatically report usage"?: boolean; - "toolbar visible"?: boolean; - "active project"?: string; - "prompt to upgrade projects"?: boolean; -}; +let optionsLoaded: boolean = false; -type KeymanDeveloperOption = keyof KeymanDeveloperOptions; - -// Default has no options set, and unit tests will use the defaults (won't call -// `loadOptions()`) -let options: KeymanDeveloperOptions = {}; - -// We only load the options from disk once on first use -let optionsLoaded = false; - -export async function loadOptions(): Promise { +export async function loadOptions(): Promise { if(optionsLoaded) { - return options; + return true; } + optionsLoaded = true; - options = {}; try { - const optionsFile = path.join(os.homedir(), '.keymandeveloper', 'options.json'); + const optionsFile = path.join(os.homedir(), ...KeymanDeveloperOptionsPath); if(fs.existsSync(optionsFile)) { for(let i = 0; i < 5; i++) { try { - const data = JSON.parse(fs.readFileSync(optionsFile, 'utf-8')); - if(typeof data == 'object') { - options = data; - } - break; - } catch(e) { + const data = fs.readFileSync(optionsFile) as Uint8Array; + return optionsManager.load(data); + } catch(e: any) { if(e?.code == 'EBUSY') { await new Promise(resolve => setTimeout(resolve, 500)); } else { @@ -76,20 +39,20 @@ export async function loadOptions(): Promise { } catch(e) { // Nothing to report here, sadly -- because we cannot rely on Sentry at this // low level. - options = {}; } - optionsLoaded = true; - return options; + + optionsManager.clear(); + return false; } -export function getOption(valueName: T, defaultValue: KeymanDeveloperOptions[T]): KeymanDeveloperOptions[T] { - return options[valueName] ?? defaultValue; +export function getOption(valueName: T): KeymanDeveloperOptions[T] { + return optionsManager.get(valueName); } /** * unit tests will clear options before running, for consistency */ export function clearOptions() { - options = {}; - optionsLoaded = true; + optionsLoaded = false; + return optionsManager.clear(); } \ No newline at end of file diff --git a/developer/src/server/src/KeymanSentry.ts b/developer/src/server/src/KeymanSentry.ts index 9dba30a0d4..b531e80235 100644 --- a/developer/src/server/src/KeymanSentry.ts +++ b/developer/src/server/src/KeymanSentry.ts @@ -23,7 +23,7 @@ export class KeymanSentry { return true; } - return getOption('automatically report errors', true); + return getOption('automatically report errors'); } static init(options?: SentryNodeOptions) { diff --git a/developer/src/server/src/config.ts b/developer/src/server/src/config.ts deleted file mode 100644 index e60ec6d0a0..0000000000 --- a/developer/src/server/src/config.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { mkdirSync } from 'fs'; -import { loadJsonFile } from './load-json-file.js'; - -export class Configuration { - public readonly appDataPath: string; - public readonly cachePath: string; - public readonly cacheStateFilename: string; - public readonly lockFilename: string; - public readonly pidFilename: string; - public readonly configFilename: string; - - /* Configuration values - set in config.json by TIKE */ - - public readonly port: number; - - /* ngrok Configuration */ - - public readonly useNgrok: boolean; - public readonly ngrokToken: string; - public readonly ngrokVisible: boolean; - - public ngrokEndpoint: string = ''; - - constructor() { - - this.appDataPath = (process.env.APPDATA || - (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share")) + - '/Keyman/Keyman Developer/Server/'; - this.cachePath = this.appDataPath + 'cache/'; - this.cacheStateFilename = this.appDataPath + 'cache.json'; - this.lockFilename = this.appDataPath + 'lock.json'; - this.pidFilename = this.appDataPath + 'pid.json'; - this.configFilename = this.appDataPath + 'config.json'; - - mkdirSync(this.cachePath, { recursive: true}); - - const cfg = loadJsonFile(this.configFilename); - - this.port = cfg?.port ?? 8008; - - // ngrok configuration - this.useNgrok = cfg?.useNgrok ?? false; - this.ngrokToken = cfg?.ngrokToken ?? ''; - this.ngrokVisible = cfg?.ngrokVisible ?? false; - } -}; - -export const configuration = new Configuration(); \ No newline at end of file diff --git a/developer/src/server/src/data.ts b/developer/src/server/src/data.ts index 6600d57b4d..8d2753ea21 100644 --- a/developer/src/server/src/data.ts +++ b/developer/src/server/src/data.ts @@ -1,5 +1,5 @@ import { writeFileSync } from 'fs'; -import { configuration } from './config.js'; +import { standardPaths } from './standardPaths.js'; import { loadJsonFile } from './load-json-file.js'; export interface DebugObject { @@ -97,7 +97,7 @@ export class SiteData { } private loadState() { - const state = loadJsonFile(configuration.cacheStateFilename); + const state = loadJsonFile(standardPaths.cacheStateFilename); this.loadDebugObject(DebugKeyboard, state?.keyboards, this.keyboards); this.loadDebugObject(DebugModel, state?.models, this.models); this.loadDebugObject(DebugFont, state?.fonts, this.fonts); @@ -106,7 +106,7 @@ export class SiteData { } public saveState() { - writeFileSync(configuration.cacheStateFilename, JSON.stringify(this, null, 2), 'utf-8'); + writeFileSync(standardPaths.cacheStateFilename, JSON.stringify(this, null, 2), 'utf-8'); } }; diff --git a/developer/src/server/src/handlers/api/debugobject/register.ts b/developer/src/server/src/handlers/api/debugobject/register.ts index a1f7a72cd4..bb28c87a58 100644 --- a/developer/src/server/src/handlers/api/debugobject/register.ts +++ b/developer/src/server/src/handlers/api/debugobject/register.ts @@ -2,7 +2,7 @@ import * as express from 'express'; import { DebugObject, isValidId, simplifyId } from "../../../data.js"; import * as fs from 'fs'; import * as crypto from 'crypto'; -import { configuration } from '../../../config.js'; +import { standardPaths } from '../../../standardPaths.js'; import chalk from 'chalk'; // We allow only 12 objects of each type in the cache @@ -41,7 +41,7 @@ export function apiRegisterFile (intf: new () => O, root: o.lastUse = new Date(); o.id = id; - o.filename = configuration.cachePath + o.filenameFromId(id); + o.filename = standardPaths.cachePath + o.filenameFromId(id); fs.writeFileSync(o.filename, file); o.sha256 = crypto.createHash('sha256').update(file).digest('hex'); diff --git a/developer/src/server/src/index.ts b/developer/src/server/src/index.ts index 36fd8431d8..e248b19625 100644 --- a/developer/src/server/src/index.ts +++ b/developer/src/server/src/index.ts @@ -5,12 +5,12 @@ import express from 'express'; import multer from 'multer'; import * as ws from 'ws'; import { KeymanSentry } from './KeymanSentry.js'; -import { configuration } from './config.js'; +import { standardPaths } from './standardPaths.js'; import { environment } from './environment.js'; import setupRoutes from './routes.js'; import { shutdown } from './shutdown.js'; import { initTray } from './tray.js'; -import { loadOptions } from './options.js'; +import { getOption, loadOptions } from './options.js'; const options = { ngrokLog: false, // Set this to true if you need to see ngrok logs in the console @@ -18,13 +18,13 @@ const options = { /* Lock file - report on PID and prevent multiple instances cleanly */ -console.log(`Starting Keyman Developer Server ${environment.versionWithTag}, listening on port ${configuration.port}.`); - // We need to load the Keyman Developer options before attempting to initialize // Sentry. `loadOptions` silently suppresses exceptions and returns a default // set of options if an error occurs. await loadOptions(); +console.log(`Starting Keyman Developer Server ${environment.versionWithTag}, listening on port ${getOption('web host port')}.`); + KeymanSentry.init(); try { await run(); @@ -80,10 +80,11 @@ export async function run() { let server = null; try { - server = app.listen(configuration.port); + server = app.listen(getOption("web host port")); } catch(err) { console.error(err); // TODO handle and cleanup EADDRINUSE, throw anything else + return; } /* Attach the web socket server */ @@ -96,14 +97,14 @@ export async function run() { /* Launch ngrok if enabled */ - configuration.ngrokEndpoint = ''; - if(configuration.useNgrok) { + standardPaths.ngrokEndpoint = ''; + if(getOption("server use ngrok")) { await startNGrok(); } /* Load the tray icon */ - tray.start(configuration.port, configuration.ngrokEndpoint); + tray.start(getOption("web host port"), standardPaths.ngrokEndpoint); } async function loadNGrok() { @@ -129,8 +130,8 @@ async function startNGrok() { let started = false; const listener = await ngrok.forward({ proto: 'http', - addr: configuration.port, - authtoken: configuration.ngrokToken, + addr: getOption("web host port"), + authtoken: getOption("server ngrok token"), onLogEvent: (msg: string) => { if(options.ngrokLog) { console.log(chalk.cyan(('\n'+msg).split('\n').join('\n[ngrok] ').trim())); @@ -139,19 +140,19 @@ async function startNGrok() { onStatusChange: (state: string) => { if(state == 'connected' && started) { // We only announce reconnection after initial start - configuration.ngrokEndpoint = listener.url() ?? ''; - console.log(chalk.blueBright('ngrok tunnel reconnected at %s'), configuration.ngrokEndpoint); + standardPaths.ngrokEndpoint = listener.url() ?? ''; + console.log(chalk.blueBright('ngrok tunnel reconnected at %s'), standardPaths.ngrokEndpoint); } else if(state == 'closed') { - configuration.ngrokEndpoint = ''; + standardPaths.ngrokEndpoint = ''; console.log(chalk.blueBright('ngrok tunnel closed')); } } }); started = true; - configuration.ngrokEndpoint = listener.url(); - console.log(chalk.blueBright('ngrok tunnel established at %s'), configuration.ngrokEndpoint); + standardPaths.ngrokEndpoint = listener.url() ?? ""; + console.log(chalk.blueBright('ngrok tunnel established at %s'), standardPaths.ngrokEndpoint); } catch(e) { - configuration.ngrokEndpoint = ''; + standardPaths.ngrokEndpoint = ''; console.error(chalk.red('ngrok tunnel failed to connect with an error: %s'), e); return false; } @@ -168,8 +169,8 @@ function getRunningInstancePid(pidFilename: string) { } function writeLockFile() { - const lockFilename = configuration.lockFilename.replaceAll(/[\\\/]/g, path.sep); - const pidFilename = configuration.pidFilename.replaceAll(/[\\\/]/g, path.sep); + const lockFilename = standardPaths.lockFilename.replaceAll(/[\\\/]/g, path.sep); + const pidFilename = standardPaths.pidFilename.replaceAll(/[\\\/]/g, path.sep); // console.debug(`Testing existence of ${lockFilename}`); if(fs.existsSync(lockFilename)) { diff --git a/developer/src/server/src/options.ts b/developer/src/server/src/options.ts index 45799ba5f2..a911283be3 100644 --- a/developer/src/server/src/options.ts +++ b/developer/src/server/src/options.ts @@ -1,70 +1,32 @@ -// TODO: this is duplicated in kmc +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Load Keyman Developer's options from the standard Options location. This + * small loader is duplicated in kmc, because we do not have a shared node-aware + * module at this time. + */ + import * as os from 'node:os'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { KeymanDeveloperOption, KeymanDeveloperOptions, KeymanDeveloperOptionsPath, optionsManager } from '@keymanapp/developer-utils'; -export interface KeymanDeveloperOptions { - "use tab char"?: boolean; - "link font sizes"?: boolean; - "indent size"?: number; - "use old debugger"?: boolean; - "editor theme"?: string; - "debugger break when exiting line"?: boolean; - "debugger single step after break"?: boolean; - "debugger show store offset"?: boolean; - "debugger recompile with debug info"?: boolean; - "debugger auto reset before compilng"?: boolean; - "auto save before compiling"?: boolean; - "osk auto save before importing"?: boolean; - "web host port"?: number; - "server keep alive"?: boolean; - "server use local addresses"?: boolean; - "server ngrok token"?: string; - "server ngrok region"?: string; - "server use ngrok"?: boolean; - "server show console window"?: boolean; - "char map disable database lookups"?: boolean; - "char map auto lookup"?: boolean; - "open keyboard files in source view"?: boolean; - "display theme"?: string; - "external editor path"?: string; - "smtp server"?: string; - "test email addresses"?: string; - "web ladder length"?: number; - "default project path"?: string; - "automatically report errors"?: boolean; - "automatically report usage"?: boolean; - "toolbar visible"?: boolean; - "active project"?: string; - "prompt to upgrade projects"?: boolean; -}; +let optionsLoaded: boolean = false; -type KeymanDeveloperOption = keyof KeymanDeveloperOptions; - -// Default has no options set, and unit tests will use the defaults (won't call -// `loadOptions()`) -let options: KeymanDeveloperOptions = {}; - -// We only load the options from disk once on first use -let optionsLoaded = false; - -export async function loadOptions(): Promise { +export async function loadOptions(): Promise { if(optionsLoaded) { - return options; + return true; } + optionsLoaded = true; - options = {}; try { - const optionsFile = path.join(os.homedir(), '.keymandeveloper', 'options.json'); + const optionsFile = path.join(os.homedir(), ...KeymanDeveloperOptionsPath); if(fs.existsSync(optionsFile)) { for(let i = 0; i < 5; i++) { try { - const data = JSON.parse(fs.readFileSync(optionsFile, 'utf-8')); - if(typeof data == 'object') { - options = data; - } - break; - } catch(e) { + const data = fs.readFileSync(optionsFile) as Uint8Array; + return optionsManager.load(data); + } catch(e: any) { if(e?.code == 'EBUSY') { await new Promise(resolve => setTimeout(resolve, 500)); } else { @@ -77,20 +39,20 @@ export async function loadOptions(): Promise { } catch(e) { // Nothing to report here, sadly -- because we cannot rely on Sentry at this // low level. - options = {}; } - optionsLoaded = true; - return options; + + optionsManager.clear(); + return false; } -export function getOption(valueName: T, defaultValue: KeymanDeveloperOptions[T]): KeymanDeveloperOptions[T] { - return options[valueName] ?? defaultValue; +export function getOption(valueName: T): KeymanDeveloperOptions[T] { + return optionsManager.get(valueName); } /** * unit tests will clear options before running, for consistency */ export function clearOptions() { - options = {}; - optionsLoaded = true; + optionsLoaded = false; + return optionsManager.clear(); } \ No newline at end of file diff --git a/developer/src/server/src/routes.ts b/developer/src/server/src/routes.ts index 3876bf0d7a..acccf23402 100644 --- a/developer/src/server/src/routes.ts +++ b/developer/src/server/src/routes.ts @@ -12,9 +12,10 @@ import handleIncPackagesJson from './handlers/inc/packages-json.js'; import apiPackageRegister from './handlers/api/package/register.js'; import handleIncKeyboardsCss from './handlers/inc/keyboards-css.js'; import { Environment } from './version-data.js'; -import { configuration } from './config.js'; +import { standardPaths } from './standardPaths.js'; import chalk from 'chalk'; import { shutdown } from './shutdown.js'; +import { getOption } from './options.js'; export default function setupRoutes(app: express.Express, upload: multer.Multer, wsServer: ws.WebSocketServer, environment: Environment ) { @@ -163,7 +164,7 @@ export default function setupRoutes(app: express.Express, upload: multer.Multer, /* ngrok data */ app.get('/api/status', (_req,res,next) => { - const response = { ngrokEnabled: configuration.useNgrok, ngrokEndpoint: configuration.ngrokEndpoint }; + const response = { ngrokEnabled: getOption("server use ngrok"), ngrokEndpoint: standardPaths.ngrokEndpoint }; res.send(response); next(); }); diff --git a/developer/src/server/src/standardPaths.ts b/developer/src/server/src/standardPaths.ts new file mode 100644 index 0000000000..bc84104d9e --- /dev/null +++ b/developer/src/server/src/standardPaths.ts @@ -0,0 +1,33 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Path and URL constants (in some cases calculated) + */ +import { mkdirSync } from 'node:fs'; + +class StandardPaths { + public readonly appDataPath: string; + public readonly cachePath: string; + public readonly cacheStateFilename: string; + public readonly lockFilename: string; + public readonly pidFilename: string; + + /* ngrok Configuration */ + + public ngrokEndpoint: string = ''; + + constructor() { + + this.appDataPath = (process.env.APPDATA || + (process.platform == 'darwin' ? process.env.HOME + '/Library/Preferences' : process.env.HOME + "/.local/share")) + + '/Keyman/Keyman Developer/Server/'; + this.cachePath = this.appDataPath + 'cache/'; + this.cacheStateFilename = this.appDataPath + 'cache.json'; + this.lockFilename = this.appDataPath + 'lock.json'; + this.pidFilename = this.appDataPath + 'pid.json'; + + mkdirSync(this.cachePath, {recursive: true}); + } +}; + +export const standardPaths = new StandardPaths(); diff --git a/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas b/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas index 6beeafe62e..1322010798 100644 --- a/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas +++ b/developer/src/tike/main/Keyman.Developer.System.KeymanDeveloperPaths.pas @@ -20,7 +20,6 @@ type const S_Kmc = 'kmc.cmd'; class function KmcPath: string; static; - const S_ServerConfigJson = 'config.json'; class function ServerDataPath: string; static; class function ServerPath: string; static; diff --git a/developer/src/tike/main/KeymanDeveloperOptions.pas b/developer/src/tike/main/KeymanDeveloperOptions.pas index 22109f58ff..09c6afcd9c 100644 --- a/developer/src/tike/main/KeymanDeveloperOptions.pas +++ b/developer/src/tike/main/KeymanDeveloperOptions.pas @@ -81,7 +81,6 @@ type procedure optWriteString(const nm, value: string); procedure optWriteBool(const nm: string; value: Boolean); procedure optWriteInt(const nm: string; value: Integer); - procedure WriteServerConfigurationJson; class function Get_Initial_DefaultProjectPath: string; static; function BackOffAndSaveJson(const Filename: string; const JSON: TJSONObject): Boolean; public @@ -476,8 +475,6 @@ begin finally FreeAndNil(json); end; - - WriteServerConfigurationJson; end; function TKeymanDeveloperOptions.BackOffAndSaveJson(const Filename: string; const JSON: TJSONObject): Boolean; @@ -513,23 +510,6 @@ begin Result := False; end; -procedure TKeymanDeveloperOptions.WriteServerConfigurationJson; -var - o: TJSONObject; -begin - o := TJSONObject.Create; - try - o.AddPair('port', TJSONNumber.Create(FServerDefaultPort)); - o.AddPair('ngrokToken', FServerNgrokToken); - o.AddPair('useNgrok', TJSONBool.Create(FServerUseNgrok)); - o.AddPair('ngrokVisible', TJSONBool.Create(FServerServerShowConsoleWindow)); - ForceDirectories(TKeymanDeveloperPaths.ServerDataPath); - BackOffAndSaveJSON(TKeymanDeveloperPaths.ServerDataPath + TKeymanDeveloperPaths.S_ServerConfigJson, o); - finally - o.Free; - end; -end; - procedure TKeymanDeveloperOptions.optWriteBool(const nm: string; value: Boolean); begin json.AddPair(nm, TJSONBool.Create(value)); From e387588503c628b9267c18ea4cb884e4777fa02d Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 25 Jun 2026 12:27:00 +0200 Subject: [PATCH 07/36] fix(developer): warn only on race when destroying TAppSourceHttpResponder I uncovered one race, documented in the source. Not trying to resolve that race at this time (it is not consequential). There is a second condition which is unclear -- and may have other consequences. So report a warning to Sentry when this arises, but do not crash out on the user. Fixes: #11916 Test-bot: skip --- ....Developer.System.HttpServer.AppSource.pas | 19 +++++++++++++++++-- developer/src/tike/main/UframeTextEditor.pas | 7 ++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas b/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas index 904d389a08..5c125aa4cd 100644 --- a/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas +++ b/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas @@ -79,8 +79,23 @@ begin // Note: Unlike regular functions, Assert has short-circuit evaluation // intrinsics on the first param which makes it safe to dereference T[0] in // the second parameter. - Assert(T.Count = 0, 'TAppSourceHttpResponder.Sources should be empty at destruction '+ - '(T.Count='+IntToStr(T.Count)+', T[0].Filename='+T[0].Filename+')'); + + // There is a race where RegisterSource is called on the server side + // where a request is started in the form but the server does not respond + // before the form is destroyed: + // 1. http request starts on form + // 2. Form destroyed, calls UnregisterSource + // 3. http request received in TAppSourceHttpResponder, + // RespondTouchEditorState calls RegisterSource + // 4. Ooops + + // There is another race somewhere with unsaved text editors, or else a + // resource leak. For now, we'll report this as a message rather than crash. + if T.Count > 0 then + begin + TKeymanSentryClient.Instance.ReportMessage('TAppSourceHttpResponder.Sources should be empty at destruction '+ + '(T.Count='+IntToStr(T.Count)+', T[0].Filename='+T[0].Filename+')', True); + end; finally FSources.UnlockList; end; diff --git a/developer/src/tike/main/UframeTextEditor.pas b/developer/src/tike/main/UframeTextEditor.pas index de6e293774..b9f5f8ce22 100644 --- a/developer/src/tike/main/UframeTextEditor.pas +++ b/developer/src/tike/main/UframeTextEditor.pas @@ -486,7 +486,12 @@ procedure TframeTextEditor.LoadFileInBrowser(const AData: string); function GenerateNewFilename: string; begin Inc(FInitialFilenameIndex); - Result := '*texteditor*'+IntToStr(FInitialFilenameIndex); + Result := '*texteditor'; + if Owner <> nil then + Result := Result + '*' + Owner.ClassName; + if Parent <> nil then + Result := Result + '*' + Parent.Name; + Result := Result + '*'+IntToStr(FInitialFilenameIndex); end; function EncodeFont(const prefix: string; f: TFont): string; begin From 64abd5b690be92eefb257992685189f6a34b6512 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Thu, 25 Jun 2026 17:36:39 +0200 Subject: [PATCH 08/36] fix(android): fix loading of keyboards on Android This change moves to using `WebViewAssetLoader` for loading files from the device instead of using file:// URLs. This fixes the blank keyboard problem reported in #16096 for Android. Also make `KMKeyboard.getKeyboardRoot()` private, and rename public `Keyboard.getKeyboardPath()` to private `Keyboard.getKeyboardUrl()`. Part-of: #16096 --- android/KMAPro/kMAPro/build.gradle | 3 +- android/KMEA/app/build.gradle | 1 + .../KMEA/app/src/main/assets/keyboard.html | 2 +- .../java/com/keyman/engine/KMKeyboard.java | 40 +++++----- .../engine/KMKeyboardWebViewClient.java | 74 +++++++++++-------- .../java/com/keyman/engine/KMManager.java | 19 +++-- .../java/com/keyman/engine/data/Keyboard.java | 32 ++++---- .../com/keyman/engine/util/WebViewUtils.java | 8 ++ 8 files changed, 106 insertions(+), 73 deletions(-) diff --git a/android/KMAPro/kMAPro/build.gradle b/android/KMAPro/kMAPro/build.gradle index 77ab2fa736..ebdbe2a2f3 100644 --- a/android/KMAPro/kMAPro/build.gradle +++ b/android/KMAPro/kMAPro/build.gradle @@ -173,10 +173,11 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'com.google.android.material:material:1.12.0' implementation 'com.stepstone.stepper:material-stepper:4.3.1' + implementation 'androidx.webkit:webkit:1.14.0' implementation files('libs/keyman-engine.aar') implementation 'io.sentry:sentry-android:8.19.1' implementation 'androidx.preference:preference:1.2.1' - implementation "com.android.installreferrer:installreferrer:2.2" + implementation 'com.android.installreferrer:installreferrer:2.2' // Add dependency for generating QR Codes // (Even though it's embedded in KMEA, because we're manually copying keyman-engine.aar, diff --git a/android/KMEA/app/build.gradle b/android/KMEA/app/build.gradle index c9ae92c1c2..4bb05106e3 100644 --- a/android/KMEA/app/build.gradle +++ b/android/KMEA/app/build.gradle @@ -72,6 +72,7 @@ dependencies { implementation 'commons-io:commons-io:2.16.1' implementation 'io.sentry:sentry-android:8.19.1' implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' // Robolectric testImplementation 'androidx.test.ext:junit:1.2.1' diff --git a/android/KMEA/app/src/main/assets/keyboard.html b/android/KMEA/app/src/main/assets/keyboard.html index 38f5ad617e..a9b34fd486 100644 --- a/android/KMEA/app/src/main/assets/keyboard.html +++ b/android/KMEA/app/src/main/assets/keyboard.html @@ -1,7 +1,7 @@ diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index 9152f84360..807014697d 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2017-2018 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine; @@ -23,6 +23,7 @@ import com.keyman.engine.util.DependencyUtil.LibraryType; import com.keyman.engine.util.FileUtils; import com.keyman.engine.util.KMLog; import com.keyman.engine.util.KMString; +import com.keyman.engine.util.WebViewUtils; import android.annotation.SuppressLint; import android.content.Context; @@ -85,7 +86,7 @@ final class KMKeyboard extends WebView { private static String txtFont = ""; private static String oskFont = null; - private static String keyboardRoot = ""; + private String keyboardRoot = ""; private final String fontUndefined = "undefined"; private GestureDetector gestureDetector; private static ArrayList kbEventListeners = null; @@ -367,7 +368,9 @@ final class KMKeyboard extends WebView { KMManager.SystemKeyboardWebViewClient.setKeyboardLoaded(false); } - String htmlPath = "file://" + getContext().getDir("data", Context.MODE_PRIVATE) + "/" + KMManager.KMFilename_KeyboardHtml; + // Use the reserved magic domain for loading the keyboard from the local device. + // See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader + String htmlPath = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMFilename_KeyboardHtml; loadUrl(htmlPath); setBackgroundColor(0); } @@ -561,6 +564,7 @@ final class KMKeyboard extends WebView { return oskFont; } + // REVIEW: this method seems to be unused /** * Return the full path to the special OSK font, * which is with all the keyboard assets at the root app_data folder @@ -715,7 +719,7 @@ final class KMKeyboard extends WebView { String kbKey = KMString.format("%s_%s", languageID, keyboardID); - String keyboardPath = makeKeyboardPath(packageID, keyboardID, keyboardVersion); + String keyboardUrl = makeKeyboardUrl(packageID, keyboardID, keyboardVersion); JSONObject reg = new JSONObject(); try { @@ -723,7 +727,7 @@ final class KMKeyboard extends WebView { reg.put("KI", "Keyboard_" + keyboardID); reg.put("KLC", languageID); reg.put("KL", languageName); - reg.put("KF", keyboardPath); + reg.put("KF", keyboardUrl); reg.put("KP", packageID); if (jDisplayFont != null) reg.put("KFont", jDisplayFont); @@ -811,26 +815,24 @@ final class KMKeyboard extends WebView { // Set the base path of the keyboard depending on the package ID private void setKeyboardRoot(String packageID) { if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - this.keyboardRoot = (context.getDir("data", Context.MODE_PRIVATE).toString() + - File.separator + KMManager.KMDefault_UndefinedPackageID + File.separator); + this.keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMDefault_UndefinedPackageID + "/"; } else { - this.keyboardRoot = (context.getDir("data", Context.MODE_PRIVATE).toString() + - File.separator + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator); + this.keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; } } - public String getKeyboardRoot() { + private String getKeyboardRoot() { return this.keyboardRoot; } - private String makeKeyboardPath(String packageID, String keyboardID, String keyboardVersion) { - String keyboardPath; + private String makeKeyboardUrl(String packageID, String keyboardID, String keyboardVersion) { + String keyboardUrl = getKeyboardRoot(); if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - keyboardPath = getKeyboardRoot() + keyboardID + "-" + keyboardVersion + ".js"; + keyboardUrl += keyboardID + "-" + keyboardVersion + ".js"; } else { - keyboardPath = getKeyboardRoot() + keyboardID + ".js"; + keyboardUrl += keyboardID + ".js"; } - return keyboardPath; + return keyboardUrl; } private void sendKMWError(int lineNumber, String sourceId, String message) { @@ -1059,7 +1061,7 @@ final class KMKeyboard extends WebView { JSONObject jfont = new JSONObject(); jfont.put(KMManager.KMKey_FontFamily, font.substring(0, font.length()-4)); JSONArray jfiles = new JSONArray(); - jfiles.put(keyboardRoot + font); + jfiles.put(getKeyboardRoot() + font); jfont.put(KMManager.KMKey_FontFiles, jfiles); return jfont; } @@ -1077,7 +1079,7 @@ final class KMKeyboard extends WebView { Object obj = fontObj.get(KMManager.KMKey_FontFiles); if (obj instanceof String) { fontFile = fontObj.getString(KMManager.KMKey_FontFiles); - fontObj.put(KMManager.KMKey_FontFiles, keyboardRoot + obj); + fontObj.put(KMManager.KMKey_FontFiles, getKeyboardRoot() + obj); return fontObj; } else if (obj instanceof JSONArray) { sourceArray = fontObj.optJSONArray(KMManager.KMKey_FontFiles); @@ -1085,7 +1087,7 @@ final class KMKeyboard extends WebView { for (int i = 0; i < sourceArray.length(); i++) { fontFile = sourceArray.getString(i); if (FileUtils.hasFontExtension(fontFile)) { - fontObj.put(KMManager.KMKey_FontFiles, keyboardRoot + fontFile); + fontObj.put(KMManager.KMKey_FontFiles, getKeyboardRoot() + fontFile); fontObj.remove(KMManager.KMKey_FontSource); return fontObj; } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java index 853d997d7e..c4a2818e6c 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2023 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine; @@ -9,9 +9,13 @@ import android.graphics.Bitmap; import android.graphics.RectF; import android.net.Uri; import android.util.Log; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.RelativeLayout; +import androidx.webkit.WebViewAssetLoader; +import androidx.webkit.WebViewAssetLoader.InternalStoragePathHandler; import com.keyman.engine.KeyboardEventHandler.EventType; import com.keyman.engine.KMManager; @@ -31,11 +35,16 @@ public final class KMKeyboardWebViewClient extends WebViewClient { public Context context; private KeyboardType keyboardType; private boolean keyboardLoaded; + private WebViewAssetLoader assetLoader; KMKeyboardWebViewClient(Context context, KeyboardType keyboardType) { this.context = context; this.keyboardType = keyboardType; this.keyboardLoaded = false; + this.assetLoader = new WebViewAssetLoader.Builder() + .addPathHandler("/data/", new InternalStoragePathHandler(context, + context.getDir("data", Context.MODE_PRIVATE))) + .build(); if (keyboardType != KeyboardType.KEYBOARD_TYPE_INAPP && keyboardType != KeyboardType.KEYBOARD_TYPE_SYSTEM) { KMLog.LogError(TAG, String.format("Cannot initialize: Invalid keyboard type: %s", keyboardType.toString())); @@ -58,6 +67,11 @@ public final class KMKeyboardWebViewClient extends WebViewClient { public void onPageStarted(WebView view, String url, Bitmap favicon) { } + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + return this.assetLoader.shouldInterceptRequest(request.getUrl()); + } + @Override public void onPageFinished(WebView view, String url) { Log.d("KMEA", String.format("onPageFinished: [%s] %s", keyboardType.toString(), url)); @@ -74,46 +88,44 @@ public final class KMKeyboardWebViewClient extends WebViewClient { kmKeyboard.keyboardSet = false; KMManager.currentLexicalModel = null; - if (url.startsWith("file")) { // TODO: is this test necessary? - this.keyboardLoaded = true; + this.keyboardLoaded = true; - SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); - int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); - if (index < 0) { - index = 0; + SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); + int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); + if (index < 0) { + index = 0; + } + Keyboard keyboardInfo = KMManager.getKeyboardInfo(context, index); + String langId = null; + if (keyboardInfo != null) { + langId = keyboardInfo.getLanguageID(); + kmKeyboard.setKeyboard(keyboardInfo); + } else { + // Revert to default (index 0) or fallback keyboard + keyboardInfo = KMManager.getKeyboardInfo(context, 0); + if (keyboardInfo == null) { + // Don't log to Sentry because some keyboard apps like FV don't install keyboards until the user chooses + keyboardInfo = KMManager.getDefaultKeyboard(context); } - Keyboard keyboardInfo = KMManager.getKeyboardInfo(context, index); - String langId = null; if (keyboardInfo != null) { langId = keyboardInfo.getLanguageID(); kmKeyboard.setKeyboard(keyboardInfo); - } else { - // Revert to default (index 0) or fallback keyboard - keyboardInfo = KMManager.getKeyboardInfo(context, 0); - if (keyboardInfo == null) { - // Don't log to Sentry because some keyboard apps like FV don't install keyboards until the user chooses - keyboardInfo = KMManager.getDefaultKeyboard(context); - } - if (keyboardInfo != null) { - langId = keyboardInfo.getLanguageID(); - kmKeyboard.setKeyboard(keyboardInfo); - } } + } - KMManager.registerAssociatedLexicalModel(langId); + KMManager.registerAssociatedLexicalModel(langId); - kmKeyboard.showHelpBubbleAfterDelay(2000, true); // check if it should be shown at that time! + kmKeyboard.showHelpBubbleAfterDelay(2000, true); // check if it should be shown at that time! - kmKeyboard.callJavascriptAfterLoad(); - kmKeyboard.setSpacebarText(KMManager.getSpacebarText()); + kmKeyboard.callJavascriptAfterLoad(); + kmKeyboard.setSpacebarText(KMManager.getSpacebarText()); - KeyboardEventHandler.notifyListeners(KMTextView.kbEventListeners, keyboardType, EventType.KEYBOARD_LOADED, null); + KeyboardEventHandler.notifyListeners(KMTextView.kbEventListeners, keyboardType, EventType.KEYBOARD_LOADED, null); - // Special handling for in-app TextView context keymanapp/keyman#3809 - if (keyboardType == KeyboardType.KEYBOARD_TYPE_INAPP && - KMTextView.activeView != null && KMTextView.activeView.getClass() == KMTextView.class) { - KMTextView.updateTextContext(); - } + // Special handling for in-app TextView context keymanapp/keyman#3809 + if (keyboardType == KeyboardType.KEYBOARD_TYPE_INAPP && + KMTextView.activeView != null && KMTextView.activeView.getClass() == KMTextView.class) { + KMTextView.updateTextContext(); } } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index f87219f512..a4e054eb75 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2017 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine; @@ -398,6 +398,10 @@ public final class KMManager { return appContext.getDir("data", Context.MODE_PRIVATE).toString() + File.separator; } + public static String getResourceUrl() { + return WebViewUtils.MAGIC_DEFAULT_DOMAIN +"/data/"; + } + public static String getPackagesDir() { return getResourceRoot() + KMDefault_AssetPackages + File.separator; } @@ -406,6 +410,10 @@ public final class KMManager { return getResourceRoot() + KMDefault_LexicalModelPackages + File.separator; } + public static String getLexicalModelsUrl() { + return getResourceUrl() + KMDefault_LexicalModelPackages + "/"; + } + public static String getCloudDir() { return getResourceRoot() + KMDefault_UndefinedPackageID + File.separator; } @@ -1650,8 +1658,9 @@ public final class KMManager { String modelID = lexicalModelInfo.get(KMKey_LexicalModelID); String languageID = lexicalModelInfo.get(KMKey_LanguageID); boolean modelFileExists = true; - File modelFile = new File(getLexicalModelsDir(), pkgID + File.separator + modelID + ".model.js"); - String path = "file://" + modelFile.getAbsolutePath(); + String modelFilename = pkgID + File.separator + modelID + ".model.js"; + File modelFile = new File(getLexicalModelsDir(), modelFilename); + String url = getLexicalModelsUrl() + modelFilename; // Disable sugestions if lexical-model file doesn't exist if (!modelFile.exists()) { @@ -1666,7 +1675,7 @@ public final class KMManager { modelObj.put("id", modelID); languageJSONArray.put(languageID); modelObj.put("languages", languageJSONArray); - modelObj.put("path", path); + modelObj.put("path", url); modelObj.put("CustomHelpLink", lexicalModelInfo.get(KMKey_CustomHelpLink)); } catch (JSONException e) { KMLog.LogException(TAG, "Invalid lexical model to register", e); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java index 0dbfe5d023..a160d96d03 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2020 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine.data; @@ -15,6 +15,7 @@ import com.keyman.engine.util.BCP47; import com.keyman.engine.util.FileUtils; import com.keyman.engine.util.KMLog; import com.keyman.engine.util.KMString; +import com.keyman.engine.util.WebViewUtils; import org.json.JSONArray; import org.json.JSONException; @@ -173,24 +174,23 @@ public class Keyboard extends LanguageResource implements Serializable { return o; } - private String getKeyboardRoot(Context context) { - String keyboardRoot = context.getDir("data", Context.MODE_PRIVATE).toString() + - File.separator; - + private String getKeyboardRoot() { + String keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/"; if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - return keyboardRoot + KMManager.KMDefault_UndefinedPackageID + File.separator; + keyboardRoot += KMManager.KMDefault_UndefinedPackageID + "/"; } else { - return keyboardRoot + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator; + keyboardRoot += KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; } + return keyboardRoot; } - public String getKeyboardPath(Context context) { + private String getKeyboardUrl() { String keyboardID = this.getKeyboardID(); String keyboardVersion = this.getVersion(); if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - return getKeyboardRoot(context) + keyboardID + "-" + keyboardVersion + ".js"; + return getKeyboardRoot() + keyboardID + "-" + keyboardVersion + ".js"; } else { - return getKeyboardRoot(context) + keyboardID + ".js"; + return getKeyboardRoot() + keyboardID + ".js"; } } @@ -202,17 +202,17 @@ public class Keyboard extends LanguageResource implements Serializable { stubObj.put("KI", "Keyboard_" + this.getKeyboardID()); stubObj.put("KLC", this.getLanguageID()); stubObj.put("KL", this.getLanguageName()); - stubObj.put("KF", this.getKeyboardPath(context)); + stubObj.put("KF", this.getKeyboardUrl()); stubObj.put("KP", this.getPackageID()); String displayFont = this.getFont(); if(displayFont != null) { - stubObj.put("KFont", this.buildDisplayFontObject(displayFont, context)); + stubObj.put("KFont", this.buildDisplayFontObject(displayFont)); } String oskFont = this.getOSKFont(); if(oskFont != null) { - stubObj.put("KOskFont", this.buildDisplayFontObject(oskFont, context)); + stubObj.put("KOskFont", this.buildDisplayFontObject(oskFont)); } String displayName = this.getDisplayName(); @@ -234,12 +234,12 @@ public class Keyboard extends LanguageResource implements Serializable { * @param font String font JSON object as a string * @return JSONObject of modified font information with full paths. If font is invalid, return `null` */ - private JSONObject buildDisplayFontObject(String font, Context context) { + private JSONObject buildDisplayFontObject(String font) { if(font == null || font.equals("")) { return null; } - String keyboardRoot = this.getKeyboardRoot(context); + String keyboardRoot = this.getKeyboardRoot(); try { if (FileUtils.hasFontExtension(font)) { diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java index 0586ecbcc8..8a82556f02 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java @@ -42,6 +42,14 @@ public final class WebViewUtils { private static final String CHROME_INSTALL_PATTERN_FORMATSTR = "^.*Chrome/([\\d.]+).*$"; private static final Pattern installPattern = Pattern.compile(CHROME_INSTALL_PATTERN_FORMATSTR); + /** + * Reserved magic domain for loading files from the local device. At runtime + * the WebViewAssetLoader will replace the protocol and domain with the + * internal storage path. + * See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader + */ + public static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; + /** * Get the Keyman Engine mode based on the Chrome version. * @param context - The context From 13e154f3a49c8b2bb282a510de3798b832cf02be Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 25 Jun 2026 13:19:23 -0500 Subject: [PATCH 09/36] fix(ios): bundle in KMW's globe-hint.css Pretty much just what the title says; it's been silently missing this whole time. This doesn't fix iOS keyboard's display by itself, but it is a prerequisite for the full solution offered by #16136. Build-bot: skip build:ios Test-bot: skip --- ios/.gitignore | 1 + .../Classes/Keyboard/KeymanWebViewController.swift | 2 +- .../KeymanEngine/Classes/Resource Management/Storage.swift | 4 ++++ ios/engine/build.sh | 3 ++- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ios/.gitignore b/ios/.gitignore index 31d4700aec..556562d4b6 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -13,6 +13,7 @@ samples/KMSample2/KeymanEngine.xcframework samples/KMSample2/build engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keymanios.js engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keymanweb-osk.ttf +engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/globe-hint.css engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/kmwosk.css engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keyman.js.map engine/KMEI/KeymanEngine/resources/Keyman.bundle/Contents/Resources/keymanweb-webview.js diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 4bd5375bc9..10b530d6f8 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -182,7 +182,7 @@ extension KeymanWebViewController { } view = nil } - + func languageMenuPosition(_ completion: @escaping (CGRect) -> Void) { webView!.evaluateJavaScript("langMenuPos();") { result, _ in guard let result = result as? String, !result.isEmpty else { diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift index 6775edb660..fd8b98e8a8 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/Storage.swift @@ -245,6 +245,10 @@ extension Storage { resourceName: "kmwosk.css", dstDir: baseDir, excludeFromBackup: true) + try Storage.copy(from: bundle, + resourceName: "globe-hint.css", + dstDir: baseDir, + excludeFromBackup: true) try Storage.copy(from: bundle, resourceName: "keymanweb-osk.ttf", dstDir: baseDir, diff --git a/ios/engine/build.sh b/ios/engine/build.sh index 9b98249b9b..50aafcac9f 100755 --- a/ios/engine/build.sh +++ b/ios/engine/build.sh @@ -102,7 +102,8 @@ function update_bundle ( ) { KMW_PRODUCT="$KEYMAN_ROOT/web/build/app/webview/$CONFIG" KMW_RESOURCES="$KEYMAN_ROOT/web/build/app/resources" - #Copy over the relevant resources! It's easiest to do if we navigate to the resulting folder. + # Copy over the relevant resources! It's easiest to do if we navigate to the resulting folder. + cp "$KMW_RESOURCES/osk/globe-hint.css" "$BUNDLE_PATH/globe-hint.css" cp "$KMW_RESOURCES/osk/kmwosk.css" "$BUNDLE_PATH/kmwosk.css" cp "$KMW_RESOURCES/osk/keymanweb-osk.ttf" "$BUNDLE_PATH/keymanweb-osk.ttf" cp "$KMW_PRODUCT/keymanweb-webview.js" "$BUNDLE_PATH/keymanweb-webview.js" From b3ee271aeedf98ec19f8f352fe3c533d25970471 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 24 Jun 2026 15:58:02 -0500 Subject: [PATCH 10/36] fix(ios): load host page, keyboards through a consistent WKURLSchemeHandler This bypasses any need to modify KMW keyboard loading (say, via .fetch), though it does require a number of collateral changes to be made in order for CORS, etc to be satisfied. Build-bot: skip build:ios --- .../KeymanEngine.xcodeproj/project.pbxproj | 4 + .../Keyboard/KeymanWebViewController.swift | 27 ++++-- .../Keyboard/WebViewSchemeHandler.swift | 95 +++++++++++++++++++ .../src/interfaces/pathConfiguration.ts | 30 +++--- web/src/engine/src/osk/views/oskView.ts | 5 +- web/src/engine/src/osk/visualKeyboard.ts | 2 +- 6 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index 94dea1d5c7..2365c073a5 100644 --- a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj +++ b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj @@ -145,6 +145,7 @@ CE8B0BBD248734240045EB2E /* KeymanPackageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8B0BBC248734240045EB2E /* KeymanPackageTests.swift */; }; CE8B0BBF248764ED0045EB2E /* KMPResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8B0BBE248764ED0045EB2E /* KMPResource.swift */; }; CE8B5BB22491DA540075CCB0 /* 13.0 Cloud to Package Migration.bundle in Resources */ = {isa = PBXBuildFile; fileRef = CE8B5BB12491DA530075CCB0 /* 13.0 Cloud to Package Migration.bundle */; }; + CE8E6B1E2FEC17B100F5E731 /* WebViewSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */; }; CE8EDEB123F53D1A009E1FF6 /* FileManagementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A079DD1223194B100581263 /* FileManagementTests.swift */; }; CE8EDEB323F53F96009E1FF6 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */; }; CE969BE8251AD8B500376D6A /* PackageWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE969BE7251AD8B500376D6A /* PackageWebViewController.swift */; }; @@ -459,6 +460,7 @@ CE8B0BBC248734240045EB2E /* KeymanPackageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeymanPackageTests.swift; sourceTree = ""; }; CE8B0BBE248764ED0045EB2E /* KMPResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPResource.swift; sourceTree = ""; }; CE8B5BB12491DA530075CCB0 /* 13.0 Cloud to Package Migration.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = "13.0 Cloud to Package Migration.bundle"; sourceTree = ""; }; + CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSchemeHandler.swift; sourceTree = ""; }; CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = ""; }; CE969BE7251AD8B500376D6A /* PackageWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageWebViewController.swift; sourceTree = ""; }; CE96E42C24D1229A005B8E5A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -804,6 +806,7 @@ CE79B24823C711FF007E72AE /* KeyboardScaleMap.swift */, C0EF3E7A1F95B65300CE9BD4 /* KeymanWebDelegate.swift */, C0C16A881FA8146300F090BA /* KeymanWebViewController.swift */, + CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */, C0A5FF361F6682EB00BE740C /* PopoverView.swift */, ); path = Keyboard; @@ -1522,6 +1525,7 @@ 9A079DCA222E050E00581263 /* LexicalModelKeymanPackage.swift in Sources */, 9A60764422893A4E003BCFBA /* SettingsViewController.swift in Sources */, C06085B41F9485E40057E5B9 /* UIButton+Helpers.swift in Sources */, + CE8E6B1E2FEC17B100F5E731 /* WebViewSchemeHandler.swift in Sources */, C0959CD41F99C44E00B616BC /* Constants.swift in Sources */, C0452BAD1F9F21270064431A /* Keyboard.swift in Sources */, 29B30C232B564F9900C342A4 /* KeymanEngineLogger.swift in Sources */, diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift index 10b530d6f8..02560e6929 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/KeymanWebViewController.swift @@ -38,7 +38,8 @@ class KeymanWebViewController: UIViewController { let storage: Storage weak var delegate: KeymanWebDelegate? private var useSpecialFont = false - private var userContentController = WKUserContentController() + private let userContentController = WKUserContentController() + private let schemeHandler: WebViewSchemeHandler private let keymanWebViewName: String = "keyman" // Views @@ -69,8 +70,9 @@ class KeymanWebViewController: UIViewController { init(storage: Storage) { self.storage = storage + self.schemeHandler = WebViewSchemeHandler(storage: storage) + super.init(nibName: nil, bundle: nil) - _ = view } @@ -122,6 +124,7 @@ class KeymanWebViewController: UIViewController { config.preferences = prefs config.suppressesIncrementalRendering = false config.userContentController = self.userContentController + config.setURLSchemeHandler(schemeHandler, forURLScheme: schemeHandler.scheme) webView = KeymanWebView(frame: CGRect(origin: .zero, size: keyboardSize), configuration: config) webView!.isOpaque = false @@ -279,20 +282,28 @@ extension KeymanWebViewController { // family does not have to match the name in the font file. It only has to be unique. return [ "family": "\(keyboard.id)__\(isOsk ? "osk" : "display")", - "files": font.source.map { storage.fontURL(forResource: keyboard, filename: $0)!.absoluteString } + "files": font.source.map { + schemeHandler.buildUrlForFile( + fileURL: storage.fontURL(forResource: keyboard, filename: $0)! + ).absoluteString + } ] } func setKeyboard(_ keyboard: InstallableKeyboard) throws { let fileURL = storage.keyboardURL(for: keyboard) + let loadingURL = schemeHandler.buildUrlForFile( + fileURL: storage.keyboardURL(for: keyboard) + ) + var stub: [String: Any] = [ "KI": "Keyboard_\(keyboard.id)", "KN": keyboard.name, "KLC": keyboard.languageID, "KL": keyboard.languageName, - "KF": fileURL.absoluteString + "KF": loadingURL.absoluteString ] - + if let packageID = keyboard.packageID { stub["KP"] = packageID } @@ -367,7 +378,7 @@ extension KeymanWebViewController { let stub: [String: Any] = [ "id": lexicalModel.id, "languages": [lexicalModel.languageID], // Change when InstallableLexicalModel is updated to store an array - "path": fileURL.absoluteString + "path": schemeHandler.buildUrlForFile(fileURL: fileURL).absoluteString ] guard FileManager.default.fileExists(atPath: fileURL.path) else { @@ -861,7 +872,9 @@ extension KeymanWebViewController { // MARK: - Show/hide views func reloadKeyboard() { - webView!.loadFileURL(Storage.active.kmwURL, allowingReadAccessTo: Storage.active.baseDir) + let hostPageFileUrl = URL(fileURLWithPath: Resources.kmwFilename, relativeTo: storage.baseDir) + let hostPageUrl = schemeHandler.buildUrlForFile(fileURL: hostPageFileUrl) + webView!.load(URLRequest(url: hostPageUrl)) isLoading = true updateSpacebarText() diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift new file mode 100644 index 0000000000..28c41adaeb --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift @@ -0,0 +1,95 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Joshua Horton on 6/24/26. + * + * WebViewKeyboardLoader implements a URLSchemeHandler that allows + * the hosted Keyman Engine for Web to access all files, consistently, + * via a http-like protocol, preventing CORS access issues for files + * loaded dynamically. + */ + +import WebKit +import UniformTypeIdentifiers +import os.log + +func getMimeType(forExtension ext: String) -> String { + // Find the UTType associated with the file extension + if let utType = UTType(filenameExtension: ext) { + // Return the preferred MIME type if it exists + return utType.preferredMIMEType ?? "application/octet-stream" + } + return "application/octet-stream" +} + +class WebViewSchemeHandler: NSObject, WKURLSchemeHandler { + let storage: Storage + let scheme = "keyman-engine" + + init(storage: Storage) { + self.storage = storage + } + + func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + return + } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! + components.scheme = "file" + + let fileUrl = components.url + + let doError = { () -> Void in + let message = "Could not load url via WKURLSchemeHandler: \(url)" + let errorInfo = [ + NSLocalizedDescriptionKey: message + ] + let error = NSError(domain: "WebViewKeyboardLoader", code: 500, userInfo: errorInfo) + + os_log("%{public}s", log:KeymanEngineLogger.settings, type: .error, message) + SentryManager.capture(error, message: message) + + urlSchemeTask.didFailWithError(error) + } + + guard fileUrl != nil else { + doError() + return + } + + do { + let fileContents = try Data(contentsOf: fileUrl!) + let fileExtension = fileUrl!.pathExtension + + let mimeType: String = getMimeType(forExtension: fileExtension) + let charset: String = mimeType.hasPrefix("text/") ? "; charset=utf-8" : "" + + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "\(mimeType)\(charset)", + ] + )! + + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(fileContents) + urlSchemeTask.didFinish() + } catch { + doError() + return + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { + } + + func buildUrlForFile(fileURL: URL) -> URL { + var loadingURLBuilder = URLComponents() + loadingURLBuilder.scheme = scheme + loadingURLBuilder.path = fileURL.path + return loadingURLBuilder.url! + } +} diff --git a/web/src/engine/src/interfaces/pathConfiguration.ts b/web/src/engine/src/interfaces/pathConfiguration.ts index 090dc7d4c4..4ab8432bcd 100644 --- a/web/src/engine/src/interfaces/pathConfiguration.ts +++ b/web/src/engine/src/interfaces/pathConfiguration.ts @@ -21,24 +21,12 @@ export class PathConfiguration implements OSKResourcePathConfiguration { private _fonts: string; readonly protocol: string; - /* - * Pre-modularization code corresponding to `sourcePath`: - ``` - // Determine path and protocol of executing script, setting them as - // construction defaults. - // - // This can only be done during load when the active script will be the - // last script loaded. Otherwise the script must be identified by name. - - var scripts = document.getElementsByTagName('script'); - var ss = scripts[scripts.length-1].src; - var sPath = ss.substr(0,ss.lastIndexOf('/')+1); - ``` - */ constructor(pathSpec: Required, sourcePath: string) { + const sourceURL = new URL(sourcePath); + sourcePath = addDelimiter(sourcePath); this.sourcePath = sourcePath; - this.protocol = sourcePath.replace(/(.{3,5}:)(.*)/,'$1'); + this.protocol = sourceURL.protocol; this.updateFromOptions(pathSpec); } @@ -75,8 +63,16 @@ export class PathConfiguration implements OSKResourcePathConfiguration { p = addDelimiter(p); - // Absolute - if((p.replace(/^(http)s?:.*/,'$1') == 'http') || (p.replace(/^(file):.*/,'$1') == 'file')) { + // Absolute - with protocol specified + const protocolList = [ + 'http:', + 'https:', + 'file:', + // If using a custom origin (say, hosted in an iOS WebView via WKURLSchemeHandler) + this.protocol + ]; + + if(protocolList.find((protocol) => p.startsWith(protocol))) { return p; } diff --git a/web/src/engine/src/osk/views/oskView.ts b/web/src/engine/src/osk/views/oskView.ts index 962d943557..ab8ab9e7b3 100644 --- a/web/src/engine/src/osk/views/oskView.ts +++ b/web/src/engine/src/osk/views/oskView.ts @@ -130,7 +130,7 @@ export function getResourcePath(config: ViewConfiguration) { if(config.isEmbedded) { resourcePathExt = ''; } - return `${config.pathConfig.resources}/${resourcePathExt}` + return `${config.pathConfig.resources}${resourcePathExt}` } export abstract class OSKView @@ -267,6 +267,7 @@ export abstract class OSKView for(const sheetFile of OSKView.STYLESHEET_FILES) { const sheetHref = `${resourcePath}${sheetFile}`; + this.uiStyleSheetManager.linkExternalSheet(sheetHref); } @@ -856,7 +857,7 @@ export abstract class OSKView isEmbedded: this.config.isEmbedded, specialFont: { family: 'SpecialOSK', - files: [`${resourcePath}/keymanweb-osk.ttf`], + files: [`${resourcePath}keymanweb-osk.ttf`], path: '' // Not actually used. }, gestureParams: this.config.gestureParams diff --git a/web/src/engine/src/osk/visualKeyboard.ts b/web/src/engine/src/osk/visualKeyboard.ts index 3faa214a4b..f5e2c9b59f 100644 --- a/web/src/engine/src/osk/visualKeyboard.ts +++ b/web/src/engine/src/osk/visualKeyboard.ts @@ -1398,7 +1398,7 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi styleSheetManager: null, specialFont: { family: 'SpecialOSK', - files: [`${pathConfig.resources}/osk/keymanweb-osk.ttf`], + files: [`${pathConfig.resources}keymanweb-osk.ttf`], path: '' // Not actually used. } }); From 83e9501b6519394e9a1b133bdf925a7f7b060365 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 29 Jun 2026 12:46:27 +0200 Subject: [PATCH 11/36] maint(linux): show output of API check also in log file Previously the output of running the API check was only shown on the summary page, but not in the log file of the step. With this change it is now also displayed in the log file. Build-bot: skip Test-bot: skip --- .github/workflows/api-verification.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/api-verification.yml b/.github/workflows/api-verification.yml index 56726817dc..95c9b959a1 100644 --- a/.github/workflows/api-verification.yml +++ b/.github/workflows/api-verification.yml @@ -71,7 +71,7 @@ jobs: - name: "Verify API for libkeymancore*.so (${{ steps.environment_step.outputs.GIT_BRANCH }}, branch ${{ steps.environment_step.outputs.GIT_BASE_BRANCH }}, by ${{ steps.environment_step.outputs.GIT_USER }})" if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' run: | - echo "Verify API for libkeymancore*.so (${{ steps.environment_step.outputs.GIT_BRANCH }}, branch ${{ steps.environment_step.outputs.GIT_BASE_BRANCH }}, by ${{ steps.environment_step.outputs.GIT_USER }}):" >> $GITHUB_STEP_SUMMARY + echo "Verify API for libkeymancore*.so (${{ steps.environment_step.outputs.GIT_BRANCH }}, branch ${{ steps.environment_step.outputs.GIT_BASE_BRANCH }}, by ${{ steps.environment_step.outputs.GIT_USER }}):" | tee -a ${GITHUB_STEP_SUMMARY} BIN_PACKAGE=$(ls "${GITHUB_WORKSPACE}/artifacts/" | grep "${PKG_NAME}[0-9]*_${{ steps.environment_step.outputs.KEYMAN_VERSION }}-1${{ steps.environment_step.outputs.PRERELEASE_TAG }}+$(lsb_release -c -s)1_amd64.deb") cd ${{ github.workspace }}/keyman/linux @@ -80,7 +80,7 @@ jobs: --bin-pkg "${GITHUB_WORKSPACE}/artifacts/${BIN_PACKAGE}" \ --git-sha "${{ steps.environment_step.outputs.GIT_SHA }}" \ --git-base "${{ steps.environment_step.outputs.GIT_BASE }}" \ - verify 2>> $GITHUB_STEP_SUMMARY + verify 2> >(tee -a ${GITHUB_STEP_SUMMARY} >&2) - name: Archive .symbols file if: steps.environment_step.outputs.SKIP_API_CHECK != 'true' && always() From 9d1f197c39d4b840cb0b8e9b641b03b76cecf76e Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 15:09:45 +0200 Subject: [PATCH 12/36] chore: add missing line to history --- HISTORY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/HISTORY.md b/HISTORY.md index f2d3cab56b..f9d015bc76 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -39,6 +39,7 @@ ## 19.0.241 alpha 2026-06-02 +* chore: web-core preflight (#16015) * chore(web): web-core preflight - strip core references (#16040) * docs: add note on how to use composer on dockerized websites (#16029) * fix(web): fix race displaying active keyboard in menu (#16042) From c2d72b7a0331f8232447be7f81561acc3acd1022 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 15:40:05 +0200 Subject: [PATCH 13/36] fix(web): revert regression in setting `activeKeyboard` in `set osk` Fixes: #16155 Test-bot: skip --- web/src/engine/src/main/keymanEngineBase.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/engine/src/main/keymanEngineBase.ts b/web/src/engine/src/main/keymanEngineBase.ts index bdb43e05ea..268752ad48 100644 --- a/web/src/engine/src/main/keymanEngineBase.ts +++ b/web/src/engine/src/main/keymanEngineBase.ts @@ -3,7 +3,7 @@ import { ProcessorInitOptions } from 'keyman/engine/js-processor'; import { DOMKeyboardLoader } from "keyman/engine/keyboard"; import { WorkerFactory } from "@keymanapp/lexical-model-layer/web" import { InputProcessor } from './headless/inputProcessor.js'; -import { OSKView, KeyboardData } from "keyman/engine/osk"; +import { OSKView } from "keyman/engine/osk"; import { KeyboardRequisitioner, ModelCache, toUnprefixedKeyboardId, DOMCloudRequester } from "keyman/engine/keyboard-storage"; import { ModelSpec, PredictionContext } from "keyman/engine/interfaces"; @@ -386,7 +386,7 @@ export class KeymanEngineBase< this.core.keyboardProcessor.contextDevice = value?.targetDevice ?? this.config.softDevice; if(value) { // Don't build an OSK if no keyboard is available yet; avoid the extra flash. - if (this.contextManager.activeKeyboard && this.contextManager.activeKeyboard instanceof KeyboardData) { // TODO-embed-osk-in-kmx: add support for OSK for KMX keyboards + if (this.contextManager.activeKeyboard) { value.activeKeyboard = this.contextManager.activeKeyboard; } value.on('keyevent', this.keyEventListener); From 724249263ce4d91416866c6d9e1c90623c2d377c Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 15:56:31 +0200 Subject: [PATCH 14/36] maint(resources): DRY out `PRInformation` interface Test-bot: skip Build-bot: skip --- resources/build/version/src/fixupHistory.ts | 6 +----- resources/build/version/src/reportHistory.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/resources/build/version/src/fixupHistory.ts b/resources/build/version/src/fixupHistory.ts index 4f34c0c13d..ff98088571 100644 --- a/resources/build/version/src/fixupHistory.ts +++ b/resources/build/version/src/fixupHistory.ts @@ -7,13 +7,9 @@ type GitHub = ReturnType; import { readFileSync, writeFileSync } from 'node:fs'; import { gt } from 'semver'; -import { reportHistory } from './reportHistory.js'; +import { reportHistory, PRInformation } from './reportHistory.js'; import { spawnChild } from './util/spawnAwait.js'; -interface PRInformation { - title: string; - number: number; -} // ------------------------------------------------------------------------------------ // splitPullsIntoHistory diff --git a/resources/build/version/src/reportHistory.ts b/resources/build/version/src/reportHistory.ts index 91bfb53d33..8fbd8519b9 100644 --- a/resources/build/version/src/reportHistory.ts +++ b/resources/build/version/src/reportHistory.ts @@ -33,7 +33,7 @@ const getPullRequestInformation = async ( return commit_id; }; -interface PRInformation { +export interface PRInformation { title: string; number: number; version?: string; From e2f0954cd5ad4acbc45a4cf81196a0f8ace22912 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 15:59:26 +0200 Subject: [PATCH 15/36] fix(developer): Free val in GetSourcePathFromBody Co-authored-by: Eberhard Beilharz --- ....UfrmCloneKeymanCloudProjectParameters.pas | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas index 36014446b0..66f321befb 100644 --- a/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas +++ b/developer/src/tike/project/Keyman.Developer.UI.Project.UfrmCloneKeymanCloudProjectParameters.pas @@ -255,22 +255,25 @@ function TfrmCloneKeymanCloudProjectParameters.IsKeyboardSourceAvailable(const i // Not a valid response Exit(''); end; + try + if not (val is TJSONObject) then + begin + // Not a valid response + Exit(''); + end; - if not (val is TJSONObject) then - begin - // Not a valid response - Exit(''); + obj := val as TJSONObject; + val := obj.Values['sourcePath']; + if not Assigned(val) or not (val is TJSONString) then + begin + // no sourcePath property + Exit(''); + end; + + Result := (val as TJSONString).Value; + finally + val.Free; end; - - obj := val as TJSONObject; - val := obj.Values['sourcePath']; - if not Assigned(val) or not (val is TJSONString) then - begin - // no sourcePath property - Exit(''); - end; - - Result := (val as TJSONString).Value; end; var From b2ee534612b702fc2d99c08e4c6c992cbf737915 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 29 Jun 2026 16:11:39 +0200 Subject: [PATCH 16/36] docs: improve comments in keyman-developer-options.ts Build-bot: skip Co-authored-by: Eberhard Beilharz --- .../src/common/web/utils/src/keyman-developer-options.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/developer/src/common/web/utils/src/keyman-developer-options.ts b/developer/src/common/web/utils/src/keyman-developer-options.ts index f1e831e684..3117ab8633 100644 --- a/developer/src/common/web/utils/src/keyman-developer-options.ts +++ b/developer/src/common/web/utils/src/keyman-developer-options.ts @@ -17,7 +17,7 @@ export const KeymanDeveloperOptionsPath = [/* '~', */ '.keymandeveloper', 'optio /** * The set of standard user options for Keyman Developer. Corresponds to - * TKeymanDeveloperOptions in the Keyman Developer TIKE source. + * TKeymanDeveloperOptions in `developer/src/tike/main/KeymanDeveloperOptions.pas` */ export interface KeymanDeveloperOptions { "use tab char": boolean; @@ -61,7 +61,7 @@ export interface KeymanDeveloperOptions { export type KeymanDeveloperOption = keyof KeymanDeveloperOptions; const DEFAULT_OPTIONS: KeymanDeveloperOptions = { - // Corresponds to KeymanDeveloperOptions.pas, TKeymanDeveloperOptions.Read + // Corresponds to TKeymanDeveloperOptions.Read in KeymanDeveloperOptions.pas "use tab char": false, "link font sizes": true, "indent size": 4, From eea463b18bc7c37f7196d36421a597d8bd9c82a8 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 29 Jun 2026 09:38:03 -0500 Subject: [PATCH 17/36] fix(web): set proper font path for doc-keyboards --- web/src/engine/src/osk/visualKeyboard.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/web/src/engine/src/osk/visualKeyboard.ts b/web/src/engine/src/osk/visualKeyboard.ts index f5e2c9b59f..07bb794110 100644 --- a/web/src/engine/src/osk/visualKeyboard.ts +++ b/web/src/engine/src/osk/visualKeyboard.ts @@ -40,7 +40,7 @@ import { KeyTip } from './keytip.interface.js'; import { OSKKey } from './keyboard-layout/oskKey.js'; import { OSKLayer, LayerLayoutParams } from './keyboard-layout/oskLayer.js'; import { OSKLayerGroup } from './keyboard-layout/oskLayerGroup.js'; -import { OSKView } from './views/oskView.js'; +import { getResourcePath, OSKView } from './views/oskView.js'; import { ParsedLengthStyle } from './lengthStyle.js'; import { defaultFontSize } from './fontSizeUtils.js'; import { PhoneKeyTip } from './input/gestures/browser/phoneKeytip.js'; @@ -1372,7 +1372,8 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi device: { formFactor?: DeviceSpec.FormFactor, OS?: DeviceSpec.OperatingSystem, - touchable?: boolean + touchable?: boolean, + browser?: DeviceSpec.Browser } = {}; // Device emulation for target documentation. @@ -1380,9 +1381,11 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi if (formFactor != 'desktop') { device.OS = DeviceSpec.OperatingSystem.iOS; device.touchable = true; + device.browser = DeviceSpec.Browser.Safari; } else { device.OS = DeviceSpec.OperatingSystem.Windows; device.touchable = false; + device.browser = DeviceSpec.Browser.Chrome; } const layout = PKbd.layout(formFactor); @@ -1398,7 +1401,11 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi styleSheetManager: null, specialFont: { family: 'SpecialOSK', - files: [`${pathConfig.resources}keymanweb-osk.ttf`], + files: [`${getResourcePath({ + // Not actually leveraged. + hostDevice: device as DeviceSpec, + pathConfig + })}keymanweb-osk.ttf`], path: '' // Not actually used. } }); From 0847fb6ca22381e1f6e910ee198a03dda2035670 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 29 Jun 2026 16:15:47 +0200 Subject: [PATCH 18/36] chore(android): address code review comments Co-authored-by: Marc Durdin --- android/KMEA/app/src/main/assets/keyboard.html | 2 +- .../main/java/com/keyman/engine/KMKeyboard.java | 11 ++++------- .../com/keyman/engine/KMKeyboardWebViewClient.java | 11 ++++++----- .../src/main/java/com/keyman/engine/KMManager.java | 6 +----- .../main/java/com/keyman/engine/data/Keyboard.java | 7 ++----- .../java/com/keyman/engine/util/WebViewUtils.java | 14 +++++++++++++- 6 files changed, 27 insertions(+), 24 deletions(-) diff --git a/android/KMEA/app/src/main/assets/keyboard.html b/android/KMEA/app/src/main/assets/keyboard.html index a9b34fd486..38f5ad617e 100644 --- a/android/KMEA/app/src/main/assets/keyboard.html +++ b/android/KMEA/app/src/main/assets/keyboard.html @@ -1,7 +1,7 @@ diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index 807014697d..2987ed1795 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -86,7 +86,7 @@ final class KMKeyboard extends WebView { private static String txtFont = ""; private static String oskFont = null; - private String keyboardRoot = ""; + private static String keyboardRoot = ""; private final String fontUndefined = "undefined"; private GestureDetector gestureDetector; private static ArrayList kbEventListeners = null; @@ -367,10 +367,7 @@ final class KMKeyboard extends WebView { } else { KMManager.SystemKeyboardWebViewClient.setKeyboardLoaded(false); } - - // Use the reserved magic domain for loading the keyboard from the local device. - // See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader - String htmlPath = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMFilename_KeyboardHtml; + String htmlPath = WebViewUtils.buildAssetUrl(KMManager.KMFilename_KeyboardHtml); loadUrl(htmlPath); setBackgroundColor(0); } @@ -815,9 +812,9 @@ final class KMKeyboard extends WebView { // Set the base path of the keyboard depending on the package ID private void setKeyboardRoot(String packageID) { if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - this.keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMDefault_UndefinedPackageID + "/"; + this.keyboardRoot = WebViewUtils.buildAssetUrl(KMManager.KMDefault_UndefinedPackageID + "/"); } else { - this.keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/" + KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; + this.keyboardRoot = WebViewUtils.buildAssetUrl(KMManager.KMDefault_AssetPackages + "/" + packageID + "/"); } } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java index c4a2818e6c..f043a56681 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java @@ -23,6 +23,7 @@ import com.keyman.engine.KMManager.KeyboardType; import com.keyman.engine.KMManager.SuggestionType; import com.keyman.engine.util.KMLog; import com.keyman.engine.data.Keyboard; +import com.keyman.engine.util.WebViewUtils; import org.json.JSONObject; @@ -42,8 +43,8 @@ public final class KMKeyboardWebViewClient extends WebViewClient { this.keyboardType = keyboardType; this.keyboardLoaded = false; this.assetLoader = new WebViewAssetLoader.Builder() - .addPathHandler("/data/", new InternalStoragePathHandler(context, - context.getDir("data", Context.MODE_PRIVATE))) + .addPathHandler(WebViewUtils.ASSET_DATA_PATH, + new InternalStoragePathHandler(context, context.getDir("data", Context.MODE_PRIVATE))) .build(); if (keyboardType != KeyboardType.KEYBOARD_TYPE_INAPP && keyboardType != KeyboardType.KEYBOARD_TYPE_SYSTEM) { @@ -92,10 +93,10 @@ public final class KMKeyboardWebViewClient extends WebViewClient { SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); - if (index < 0) { - index = 0; + Keyboard keyboardInfo = null; + if (index >= 0) { + keyboardInfo = KMManager.getKeyboardInfo(context, index); } - Keyboard keyboardInfo = KMManager.getKeyboardInfo(context, index); String langId = null; if (keyboardInfo != null) { langId = keyboardInfo.getLanguageID(); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index a4e054eb75..57fc8aa919 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -398,10 +398,6 @@ public final class KMManager { return appContext.getDir("data", Context.MODE_PRIVATE).toString() + File.separator; } - public static String getResourceUrl() { - return WebViewUtils.MAGIC_DEFAULT_DOMAIN +"/data/"; - } - public static String getPackagesDir() { return getResourceRoot() + KMDefault_AssetPackages + File.separator; } @@ -411,7 +407,7 @@ public final class KMManager { } public static String getLexicalModelsUrl() { - return getResourceUrl() + KMDefault_LexicalModelPackages + "/"; + return WebViewUtils.buildAssetUrl(KMDefault_LexicalModelPackages + "/"); } public static String getCloudDir() { diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java index a160d96d03..267942b203 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java @@ -175,13 +175,10 @@ public class Keyboard extends LanguageResource implements Serializable { } private String getKeyboardRoot() { - String keyboardRoot = WebViewUtils.MAGIC_DEFAULT_DOMAIN + "/data/"; if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - keyboardRoot += KMManager.KMDefault_UndefinedPackageID + "/"; - } else { - keyboardRoot += KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; + return WebViewUtils.buildAssetUrl(KMManager.KMDefault_UndefinedPackageID + "/"); } - return keyboardRoot; + return WebViewUtils.buildAssetUrl(KMManager.KMDefault_AssetPackages + "/" + packageID + "/"); } private String getKeyboardUrl() { diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java index 8a82556f02..e0779e59bc 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java @@ -48,7 +48,19 @@ public final class WebViewUtils { * internal storage path. * See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader */ - public static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; + private static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; + + /** + * Path under the asset domain where all assets live + */ + public static final String ASSET_DATA_PATH = "/data/"; + + /** + * Build a full URL to the provided asset + */ + public static String buildAssetUrl(String assetPath) { + return WebViewUtils.MAGIC_DEFAULT_DOMAIN + WebViewUtils.ASSET_DATA_PATH + assetPath; + } /** * Get the Keyman Engine mode based on the Chrome version. From b805f66177108d6717ff43f522c8827b40eaa501 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 29 Jun 2026 17:08:24 +0200 Subject: [PATCH 19/36] chore(android): address code review comment --- web/build.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/build.sh b/web/build.sh index 4154c6203c..143a3b8aa9 100755 --- a/web/build.sh +++ b/web/build.sh @@ -125,6 +125,10 @@ build_tests_action() { # shellcheck disable=SC2310 if ! builder_is_ci_release_build; then for f in "${KEYMAN_ROOT}/web/build/docs/engine/guide/examples"/*.html; do + # Replace CDN URL (https://s.keyman.com/kwm/engine/18.0.123) with + # local local build path (/build/publish/debug). We write to a temp + # file and then replace the original instead of modifying in-place. + # This is safer and more portable. sed "s|https://s\.keyman\.com/kmw/engine/[0-9]*\.[0-9]*\.[0-9]*/|/build/publish/${config}/|g" \ "${f}" > "${f}.tmp" && mv "${f}.tmp" "${f}" done From a70861dad871520af293cf9720ac54bc1688dcb3 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 29 Jun 2026 22:14:40 +0700 Subject: [PATCH 20/36] change(web): Fix ios/engine/build.sh comment Co-authored-by: Marc Durdin --- ios/engine/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/engine/build.sh b/ios/engine/build.sh index 50aafcac9f..f341e13983 100755 --- a/ios/engine/build.sh +++ b/ios/engine/build.sh @@ -102,7 +102,7 @@ function update_bundle ( ) { KMW_PRODUCT="$KEYMAN_ROOT/web/build/app/webview/$CONFIG" KMW_RESOURCES="$KEYMAN_ROOT/web/build/app/resources" - # Copy over the relevant resources! It's easiest to do if we navigate to the resulting folder. + # Copy relevant KeymanWeb resources; this list is also in Storage.swift cp "$KMW_RESOURCES/osk/globe-hint.css" "$BUNDLE_PATH/globe-hint.css" cp "$KMW_RESOURCES/osk/kmwosk.css" "$BUNDLE_PATH/kmwosk.css" cp "$KMW_RESOURCES/osk/keymanweb-osk.ttf" "$BUNDLE_PATH/keymanweb-osk.ttf" From fa435cbeaeb1fe29f7d2ca0878a30fccf26898ca Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 29 Jun 2026 22:21:58 +0700 Subject: [PATCH 21/36] docs(ios): change file header date format Co-authored-by: Marc Durdin --- .../KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift index 28c41adaeb..f67f143503 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift @@ -1,7 +1,7 @@ /* * Keyman is copyright (C) SIL Global. MIT License. * - * Created by Joshua Horton on 6/24/26. + * Created by Joshua Horton on 2026-06-24. * * WebViewKeyboardLoader implements a URLSchemeHandler that allows * the hosted Keyman Engine for Web to access all files, consistently, From 700380ddc15a0a632691d7a4fba5214a61011c52 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 29 Jun 2026 10:23:44 -0500 Subject: [PATCH 22/36] change(ios): pre-emptively denote utf-8 encoding for JSON We don't actually pass JSON through our scheme handler yet, but just in case... we ensure we detect JSON's MIME type and annotate appropriately --- .../KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift index f67f143503..0b09f79732 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Keyboard/WebViewSchemeHandler.swift @@ -63,7 +63,9 @@ class WebViewSchemeHandler: NSObject, WKURLSchemeHandler { let fileExtension = fileUrl!.pathExtension let mimeType: String = getMimeType(forExtension: fileExtension) - let charset: String = mimeType.hasPrefix("text/") ? "; charset=utf-8" : "" + let charset: String = (mimeType.hasPrefix("text/") || mimeType == "application/json") + ? "; charset=utf-8" + : "" let response = HTTPURLResponse( url: url, From bec75197b1c01ab8b0e51b36f36b001351eb4bdd Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 29 Jun 2026 17:56:06 +0200 Subject: [PATCH 23/36] fix(android): add tests for `WebViewUtils.buildAssertUrl` Also fix `assertEquals` in that test file - the parameter ordering is `expected, actual` but we had it the other way round which gives a confusing message if the test fails. --- .../com/keyman/engine/util/WebViewUtils.java | 6 ++-- .../keyman/engine/util/WebViewUtilsTest.java | 31 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java index e0779e59bc..11c64434da 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java @@ -48,7 +48,7 @@ public final class WebViewUtils { * internal storage path. * See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader */ - private static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; + private static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; /** * Path under the asset domain where all assets live @@ -59,7 +59,9 @@ public final class WebViewUtils { * Build a full URL to the provided asset */ public static String buildAssetUrl(String assetPath) { - return WebViewUtils.MAGIC_DEFAULT_DOMAIN + WebViewUtils.ASSET_DATA_PATH + assetPath; + String appendAsset = assetPath == null ? "" : + (assetPath.startsWith("/") ? assetPath.substring(1) : assetPath); + return WebViewUtils.MAGIC_DEFAULT_DOMAIN + WebViewUtils.ASSET_DATA_PATH + appendAsset; } /** diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java index 342934200a..bfb867ecc9 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java @@ -26,37 +26,48 @@ public class WebViewUtilsTest { @Test public void test_ChromeEmpty_EngineWebViewVersionStatusDisabled() { String chromeVersion = ""; - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DISABLED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DISABLED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome36_EngineWebViewVersionStatusDisabled() { double chromeVersionFloat = Float.parseFloat(WebViewUtils.KEYMAN_MIN_TARGET_VERSION_DEGRADED_ANDROID_CHROME) - 1.0; String chromeVersion = String.valueOf(chromeVersionFloat); - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DISABLED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DISABLED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome37_EngineWebViewVersionStatusDegraded() { String chromeVersion = WebViewUtils.KEYMAN_MIN_TARGET_VERSION_DEGRADED_ANDROID_CHROME; - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DEGRADED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DEGRADED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome94_EngineWebViewVersionStatusDegraded() { double chromeVersionFloat = Float.parseFloat(WebViewUtils.KEYMAN_MIN_TARGET_VERSION_ANDROID_CHROME) - 1.0; String chromeVersion = String.valueOf(chromeVersionFloat); - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.DEGRADED); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.DEGRADED, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } @Test public void test_Chrome95_EngineWebViewVersionStatusFull() { String chromeVersion = WebViewUtils.KEYMAN_MIN_TARGET_VERSION_ANDROID_CHROME; - Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), - WebViewUtils.EngineWebViewVersionStatus.FULL); + Assert.assertEquals(WebViewUtils.EngineWebViewVersionStatus.FULL, + WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion)); } + + @Test + public void test_buildAssetUrl() { + Assert.assertEquals("https://appassets.androidplatform.net/data/", WebViewUtils.buildAssetUrl("")); + Assert.assertEquals("https://appassets.androidplatform.net/data/", WebViewUtils.buildAssetUrl(null)); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo", WebViewUtils.buildAssetUrl("foo")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/", WebViewUtils.buildAssetUrl("foo/")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/", WebViewUtils.buildAssetUrl("/foo/")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/bar.html", WebViewUtils.buildAssetUrl("foo/bar.html")); + } + } From 71b0586c66913bb7bbbbe76222482a607e630540 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Mon, 29 Jun 2026 18:22:02 +0200 Subject: [PATCH 24/36] fix(android): fix failing test on Windows On Windows the tests failed because the AppData directory doesn't yet exist under an allowed app internal storage path. This change creates the directory first when running tests. --- .../src/test/java/com/keyman/engine/KMManagerTest.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java index 91fc7dd370..71fa9c5ddb 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java @@ -1,5 +1,9 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ package com.keyman.engine; +import android.content.Context; import android.util.Log; import androidx.test.core.app.ApplicationProvider; @@ -32,7 +36,11 @@ public class KMManagerTest { // For some keyboard list tests, load an existing keyboard list. // Can't use @Before because context is null before running tests. public void loadOldKeyboardsList() { - KMManager.initialize(ApplicationProvider.getApplicationContext(), KMManager.KeyboardType.KEYBOARD_TYPE_INAPP); + Context context = ApplicationProvider.getApplicationContext(); + // Create appData directory accessed in KMKeyboardWebViewClient + File dataDir = context.getDir("data", Context.MODE_PRIVATE); + dataDir.mkdirs(); + KMManager.initialize(context, KMManager.KeyboardType.KEYBOARD_TYPE_INAPP); File keyboards_dat = new File(TEST_RESOURCE_ROOT, OLD_KEYBOARDS_LIST); if (keyboards_dat == null || !keyboards_dat.exists()) { From c1fbf2fdf169ea0228854d86d5e24d1ecfa74b37 Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Mon, 29 Jun 2026 13:02:46 -0500 Subject: [PATCH 25/36] auto: increment master version to 19.0.249 Test-bot: skip Build-bot: skip --- HISTORY.md | 11 +++++++++++ VERSION.md | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index f9d015bc76..e4696631e8 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,16 @@ # Keyman Version History +## 19.0.248 alpha 2026-06-29 + +* chore: add missing line to history (#16154) +* maint(resources): DRY out `PRInformation` interface (#16158) +* fix(developer): prevent clone of legacy keyboards with no source (#16111) +* fix(developer): consolidate user options in TypeScript code (#16134) +* fix(developer): fixup references to layer after deleting in Touch Layout Editor (#16129) +* fix(web): revert regression in setting `activeKeyboard` in `set osk` (#16156) +* test(web): add e2e tests for examples from guide (#16108) +* maint(linux): show output of API check also in log file (#16153) + ## 19.0.247 alpha 2026-06-16 * chore: update multi-labeler to 5.0.0 (#16100) diff --git a/VERSION.md b/VERSION.md index a0b5f4d5f2..a202b62bed 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.248 \ No newline at end of file +19.0.249 \ No newline at end of file From 23764b7e561d48f0e857ab0b9199d2ceacaade87 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 30 Jun 2026 11:49:54 +0200 Subject: [PATCH 26/36] chore(android): allow to build FV app in docker container Building the FirstVoices app is triggered in the top-level `build.sh` if the two environment variables are set. Previously we didn't pass these variables to Docker, so the FV app was never built even when the env variables were set. This change passes the two variables to the container, thus allowing to build the FW app in the docker container. Build-bot: skip Test-bot: skip --- resources/docker-images/run.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/docker-images/run.sh b/resources/docker-images/run.sh index 534e84eebf..a76d2bcb0b 100755 --- a/resources/docker-images/run.sh +++ b/resources/docker-images/run.sh @@ -44,6 +44,9 @@ else fi run_android() { + if [[ -n ${RELEASE_OEM:-} ]] && [[ -n ${RELEASE_OEM_FIRSTVOICES:-} ]]; then + DOCKER_RUN_ARGS+=(-e "RELEASE_OEM=${RELEASE_OEM:-}" -e "RELEASE_OEM_FIRSTVOICES=${RELEASE_OEM_FIRSTVOICES:-}") + fi docker_wrapper run "${DOCKER_RUN_ARGS[@]}" -i --rm -v "${KEYMAN_ROOT}":/home/build/build \ -v "${KEYMAN_ROOT}/core/build/docker-core/${build_dir}":/home/build/build/core/build \ "${registry_slash}keymanapp/keyman-android-ci:${image_version}" \ From c606733a5688f35899e3a0c8a009d7308b7e0402 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 30 Jun 2026 11:31:51 +0200 Subject: [PATCH 27/36] fix(android): add missing dependency for FirstVoices and Samples Fixes: FV-ANDROID-20E Build-bot: release:android --- android/Samples/KMSample1/app/build.gradle | 1 + android/Samples/KMSample2/app/build.gradle | 1 + oem/firstvoices/android/app/build.gradle | 1 + 3 files changed, 3 insertions(+) diff --git a/android/Samples/KMSample1/app/build.gradle b/android/Samples/KMSample1/app/build.gradle index ae4ff55d7e..395d6a69e5 100644 --- a/android/Samples/KMSample1/app/build.gradle +++ b/android/Samples/KMSample1/app/build.gradle @@ -50,4 +50,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation files('libs/keyman-engine.aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } diff --git a/android/Samples/KMSample2/app/build.gradle b/android/Samples/KMSample2/app/build.gradle index a148a7ab19..40e30c0025 100644 --- a/android/Samples/KMSample2/app/build.gradle +++ b/android/Samples/KMSample2/app/build.gradle @@ -49,4 +49,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation files('libs/keyman-engine.aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } diff --git a/oem/firstvoices/android/app/build.gradle b/oem/firstvoices/android/app/build.gradle index 9f42f94085..aa5e65941d 100644 --- a/oem/firstvoices/android/app/build.gradle +++ b/oem/firstvoices/android/app/build.gradle @@ -137,6 +137,7 @@ dependencies { implementation files('libs/keyman-engine.aar') implementation 'io.sentry:sentry-android:8.19.1' implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } apply plugin: 'com.android.application' From b490d20098cd0a2ed98f54df2dbbba0f106409b4 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Tue, 30 Jun 2026 12:56:50 +0200 Subject: [PATCH 28/36] fix(android): add missing dependency for KeyboardHarness app --- android/Tests/KeyboardHarness/app/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/android/Tests/KeyboardHarness/app/build.gradle b/android/Tests/KeyboardHarness/app/build.gradle index 25d5c8250a..d9502cfa64 100644 --- a/android/Tests/KeyboardHarness/app/build.gradle +++ b/android/Tests/KeyboardHarness/app/build.gradle @@ -60,4 +60,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation files('libs/keyman-engine.aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } From fd6e2bfc6f34d8aea67231577e13f3c20a273d14 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Tue, 30 Jun 2026 14:42:21 +0200 Subject: [PATCH 29/36] chore(developer): remove outdated comment --- .../http/Keyman.Developer.System.HttpServer.AppSource.pas | 4 ---- 1 file changed, 4 deletions(-) diff --git a/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas b/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas index 5c125aa4cd..37c4b483b4 100644 --- a/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas +++ b/developer/src/tike/http/Keyman.Developer.System.HttpServer.AppSource.pas @@ -76,10 +76,6 @@ var begin T := FSources.LockList; try - // Note: Unlike regular functions, Assert has short-circuit evaluation - // intrinsics on the first param which makes it safe to dereference T[0] in - // the second parameter. - // There is a race where RegisterSource is called on the server side // where a request is started in the form but the server does not respond // before the form is destroyed: From d549508f5bb36dcb7d151f2f72abc5ffd3276e6e Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Tue, 30 Jun 2026 13:02:05 -0500 Subject: [PATCH 30/36] auto: increment master version to 19.0.250 Test-bot: skip Build-bot: skip --- HISTORY.md | 7 +++++++ VERSION.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index e4696631e8..1de8faa65c 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,12 @@ # Keyman Version History +## 19.0.249 alpha 2026-06-30 + +* fix(windows): add update property to remote check (#16126) +* chore(android): allow to build FV app in docker container (#16163) +* fix(developer): warn only on race when destroying TAppSourceHttpResponder (#16140) +* fix(developer): map shift key nextlayer property when importing OSK (#16110) + ## 19.0.248 alpha 2026-06-29 * chore: add missing line to history (#16154) diff --git a/VERSION.md b/VERSION.md index a202b62bed..0115601331 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.249 \ No newline at end of file +19.0.250 \ No newline at end of file From a146345eae5f4ed38118fdd7d4eb858328a6a38b Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 1 Jul 2026 11:27:19 +0200 Subject: [PATCH 31/36] fix(android): fix problem with fonts The default fonts (DejaVueSans.ttf and keymanweb-osk.ttf) are located in the root directory, not the package directory. This fixes the KeyboardHarness test app. Also some improvements with variable and function names to better match what they do. --- .../java/com/keyman/engine/KMKeyboard.java | 71 ++++++++++++------- .../java/com/keyman/engine/KMManager.java | 13 ++++ .../java/com/keyman/engine/data/Keyboard.java | 33 +++++---- 3 files changed, 76 insertions(+), 41 deletions(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index 2987ed1795..541e20edcb 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -86,7 +86,8 @@ final class KMKeyboard extends WebView { private static String txtFont = ""; private static String oskFont = null; - private static String keyboardRoot = ""; + private static String dataRoot = ""; + private static String packageRoot = ""; private final String fontUndefined = "undefined"; private GestureDetector gestureDetector; private static ArrayList kbEventListeners = null; @@ -647,7 +648,7 @@ final class KMKeyboard extends WebView { } String kbKey = KMString.format("%s_%s", languageID, keyboardID); - setKeyboardRoot(packageID); + setPackageRoot(packageID); // Escape single-quoted names for javascript call keyboardName = keyboardName.replaceAll("\'", "\\\\'"); // Double-escaped-backslash b/c regex. @@ -703,13 +704,13 @@ final class KMKeyboard extends WebView { KMManager.getLatestKeyboardFileVersion(getContext(), packageID, keyboardID) : null; } - setKeyboardRoot(packageID); + setPackageRoot(packageID); if(kOskFont == null || kOskFont.isEmpty()) kOskFont = kFont; - JSONObject jDisplayFont = makeFontPaths(kFont); - JSONObject jOskFont = makeFontPaths(kOskFont); + JSONObject jDisplayFont = makeFontObject(kFont); + JSONObject jOskFont = makeFontObject(kOskFont); txtFont = getFontFilename(jDisplayFont); oskFont = getFontFilename(jOskFont); @@ -810,20 +811,25 @@ final class KMKeyboard extends WebView { } // Set the base path of the keyboard depending on the package ID - private void setKeyboardRoot(String packageID) { + private void setPackageRoot(String packageID) { + this.dataRoot = WebViewUtils.buildAssetUrl(""); if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - this.keyboardRoot = WebViewUtils.buildAssetUrl(KMManager.KMDefault_UndefinedPackageID + "/"); + this.packageRoot = this.dataRoot + KMManager.KMDefault_UndefinedPackageID + "/"; } else { - this.keyboardRoot = WebViewUtils.buildAssetUrl(KMManager.KMDefault_AssetPackages + "/" + packageID + "/"); + this.packageRoot = this.dataRoot + KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; } } - private String getKeyboardRoot() { - return this.keyboardRoot; + private String getDataRoot() { + return this.dataRoot; + } + + private String getPackageRoot() { + return this.packageRoot; } private String makeKeyboardUrl(String packageID, String keyboardID, String keyboardVersion) { - String keyboardUrl = getKeyboardRoot(); + String keyboardUrl = getPackageRoot(); if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { keyboardUrl += keyboardID + "-" + keyboardVersion + ".js"; } else { @@ -1041,13 +1047,26 @@ final class KMKeyboard extends WebView { } /** - * Take a font JSON object and adjust to pass to JS - * 1. Replace "source" keys for "files" keys - * 2. Create full font paths for .ttf or .svg - * @param font String font JSON object as a string - * @return JSONObject of modified font information with full paths. If font is invalid, return `null` + * Create a JSON object consisting of the font family and the URLs of the + * font files on the local device. + * + * The `font` parameter can either be the filename of the font (with an + * extension recognized as font), or a Font object or JSON string. + * In the former case a new JSON object is created with the font family + * derived from the filename, and the font filename prefixed with path + * to the fonts. + * In the latter case the legacy `sources` key is renamed to `files`. + * If `files` is a single string it will be prefixed with the path to the + * fonts. If `files` is an array, the array is iterated until finding + * the first file with a font extension which is then prefixed with the + * path to the fonts. + * + * @param font A string containing either the font filename or a font JSON + * object as a string + * @return JSONObject of modified font information with full paths. If font + * is invalid, return `null`. */ - private JSONObject makeFontPaths(String font) { + private JSONObject makeFontObject(String font) { if(font == null || font.equals("")) { return null; @@ -1058,14 +1077,13 @@ final class KMKeyboard extends WebView { JSONObject jfont = new JSONObject(); jfont.put(KMManager.KMKey_FontFamily, font.substring(0, font.length()-4)); JSONArray jfiles = new JSONArray(); - jfiles.put(getKeyboardRoot() + font); + String fontRoot = KMManager.isDefaultFont(font) ? getDataRoot() : getPackageRoot(); + jfiles.put(fontRoot + font); jfont.put(KMManager.KMKey_FontFiles, jfiles); return jfont; } JSONObject fontObj = new JSONObject(font); - JSONArray sourceArray; - String fontFile; // Replace "sources" key with "files" if (fontObj.has(KMManager.KMKey_FontSource)) { @@ -1075,16 +1093,18 @@ final class KMKeyboard extends WebView { Object obj = fontObj.get(KMManager.KMKey_FontFiles); if (obj instanceof String) { - fontFile = fontObj.getString(KMManager.KMKey_FontFiles); - fontObj.put(KMManager.KMKey_FontFiles, getKeyboardRoot() + obj); + String fontFile = fontObj.getString(KMManager.KMKey_FontFiles); + String fontRoot = KMManager.isDefaultFont(fontFile) ? getDataRoot() : getPackageRoot(); + fontObj.put(KMManager.KMKey_FontFiles, fontRoot + obj); return fontObj; } else if (obj instanceof JSONArray) { - sourceArray = fontObj.optJSONArray(KMManager.KMKey_FontFiles); + JSONArray sourceArray = fontObj.optJSONArray(KMManager.KMKey_FontFiles); if (sourceArray != null) { for (int i = 0; i < sourceArray.length(); i++) { - fontFile = sourceArray.getString(i); + String fontFile = sourceArray.getString(i); if (FileUtils.hasFontExtension(fontFile)) { - fontObj.put(KMManager.KMKey_FontFiles, getKeyboardRoot() + fontFile); + String fontRoot = KMManager.isDefaultFont(fontFile) ? getDataRoot() : getPackageRoot(); + fontObj.put(KMManager.KMKey_FontFiles, fontRoot + fontFile); fontObj.remove(KMManager.KMKey_FontSource); return fontObj; } @@ -1093,7 +1113,6 @@ final class KMKeyboard extends WebView { } } catch (JSONException e) { KMLog.LogException(TAG, "Failed to make font for '"+font+"'", e); - return null; } return null; diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index 57fc8aa919..afecbace88 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -500,6 +500,19 @@ public final class KMManager { return false; } + /** + * Check if the given font is the default font or the default OSK font. + * @param fontName String - The font name and extension + * @return boolean true if `fontName` is DejaVueSans.ttf or keymanweb-osk.ttf + * or null or empty string, false otherwise + */ + public static boolean isDefaultFont(String fontName) { + if (fontName == null || fontName.isEmpty()) { + return true; + } + return fontName.equals(KMDefault_KeyboardFont) || fontName.equals(KMFilename_Osk_Ttf_Font); + } + public static void initialize(final Context context, KeyboardType keyboardType) { appContext = context.getApplicationContext(); diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java index 267942b203..844959fb54 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java @@ -174,20 +174,24 @@ public class Keyboard extends LanguageResource implements Serializable { return o; } - private String getKeyboardRoot() { + private String getDataRoot() { + return WebViewUtils.buildAssetUrl(""); + } + + private String getPackageRoot() { if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - return WebViewUtils.buildAssetUrl(KMManager.KMDefault_UndefinedPackageID + "/"); + return getDataRoot() + KMManager.KMDefault_UndefinedPackageID + "/"; } - return WebViewUtils.buildAssetUrl(KMManager.KMDefault_AssetPackages + "/" + packageID + "/"); + return getDataRoot() + KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; } private String getKeyboardUrl() { String keyboardID = this.getKeyboardID(); String keyboardVersion = this.getVersion(); if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - return getKeyboardRoot() + keyboardID + "-" + keyboardVersion + ".js"; + return getPackageRoot() + keyboardID + "-" + keyboardVersion + ".js"; } else { - return getKeyboardRoot() + keyboardID + ".js"; + return getPackageRoot() + keyboardID + ".js"; } } @@ -225,34 +229,33 @@ public class Keyboard extends LanguageResource implements Serializable { } /** - * Take a font JSON object and adjust to pass to JS - * 1. Replace "source" keys for "files" keys - * 2. Create full font paths for .ttf or .svg - * @param font String font JSON object as a string - * @return JSONObject of modified font information with full paths. If font is invalid, return `null` + * Create a JSON object consisting of the font family and the font + * file URL. + * + * @param font A string containing the font filename + * @return JSONObject of modified font information with full URL. If font + * is invalid, return `null`. */ private JSONObject buildDisplayFontObject(String font) { if(font == null || font.equals("")) { return null; } - String keyboardRoot = this.getKeyboardRoot(); + String fontRoot = KMManager.isDefaultFont(font) ? this.getDataRoot() : this.getPackageRoot(); try { if (FileUtils.hasFontExtension(font)) { JSONObject jfont = new JSONObject(); jfont.put(KMManager.KMKey_FontFamily, font.substring(0, font.length() - 4)); JSONArray jfiles = new JSONArray(); - jfiles.put(keyboardRoot + font); + jfiles.put(fontRoot + font); jfont.put(KMManager.KMKey_FontFiles, jfiles); return jfont; - } else { - return null; } } catch (JSONException e) { KMLog.LogException(TAG, "Failed to make font for '"+font+"'", e); - return null; } + return null; } /** From 0aa41c1028d7c02e8133738b03d3d024f334f9a0 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 1 Jul 2026 11:52:02 +0200 Subject: [PATCH 32/36] fix(android): fix typo --- android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index afecbace88..4d7162b15e 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -503,7 +503,7 @@ public final class KMManager { /** * Check if the given font is the default font or the default OSK font. * @param fontName String - The font name and extension - * @return boolean true if `fontName` is DejaVueSans.ttf or keymanweb-osk.ttf + * @return boolean true if `fontName` is DejaVuSans.ttf or keymanweb-osk.ttf * or null or empty string, false otherwise */ public static boolean isDefaultFont(String fontName) { From 28652af8ab21e91cecee843d7633c9a4656aaf27 Mon Sep 17 00:00:00 2001 From: Eberhard Beilharz Date: Wed, 1 Jul 2026 12:27:38 +0200 Subject: [PATCH 33/36] fix(android): suppress error for fallback keyboard We use SIL EuroLatin as fallback keyboard. If that is not installed, previously we output an error which showed up as a toast for the user. This change now outputs a warning instead of the error for the default keyboard. Also changed the KeyboardHarness test app to set a different keyoard as default. --- .../app/src/main/java/com/keyman/engine/KMManager.java | 7 ++++++- .../keyman/android/tests/keyboardHarness/MainActivity.java | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index 4d7162b15e..3ca6f53de0 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -2243,7 +2243,12 @@ public final class KMManager { File kmpJSONFile = new File(path); if (!kmpJSONFile.exists()) { if (!KMManager.isTestMode()) { - KMLog.LogError(TAG, path + " not found. Returning version 1.0"); + if (packageID.equals(KMDefault_PackageID)) { + // Suppress error log for the default fallback keyboard if it's not installed + Log.d(TAG, path + " not found. Returning version 1.0"); + } else { + KMLog.LogError(TAG, path + " not found. Returning version 1.0"); + } } return "1.0"; } diff --git a/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java b/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java index becec2868a..a41d79dd89 100644 --- a/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java +++ b/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java @@ -58,6 +58,7 @@ public class MainActivity extends BaseActivity implements OnKeyboardEventListene KMManager.KMDefault_KeyboardFont, KMManager.KMDefault_KeyboardFont); KMManager.addKeyboard(this, chiralityKBInfo); + Keyboard.setDefaultKeyboard(chiralityKBInfo); // Longpress test keyboard Keyboard longpressKBbInfo = new Keyboard( From 8dfdd8251eec272ac891a21c419d0b88ec85c16b Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Wed, 1 Jul 2026 13:01:37 -0500 Subject: [PATCH 34/36] auto: increment master version to 19.0.251 Test-bot: skip Build-bot: skip --- HISTORY.md | 6 ++++++ VERSION.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 1de8faa65c..4b155ec311 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,11 @@ # Keyman Version History +## 19.0.250 alpha 2026-07-01 + +* fix(android): use `WebViewAssetLoader` for loading assets on Android (address regression in 19.0.242) (#16146) +* fix(ios): bundle in KMW's globe-hint.css (#16142) +* fix(ios): load host page, keyboards through a consistent WKURLSchemeHandler (#16136) + ## 19.0.249 alpha 2026-06-30 * fix(windows): add update property to remote check (#16126) diff --git a/VERSION.md b/VERSION.md index 0115601331..cae332d93f 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.250 \ No newline at end of file +19.0.251 \ No newline at end of file From fb761fc78f300766dfdcff5fe40bebf741777c60 Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Thu, 2 Jul 2026 09:16:17 +0200 Subject: [PATCH 35/36] fix(android): skip test on Windows that triggers androidx WebKit bug Fixes: #16183 Test-bot: skip --- .../test/java/com/keyman/engine/KMManagerTest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java index 71fa9c5ddb..afc206c9da 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java @@ -119,6 +119,11 @@ public class KMManagerTest { */ @Test public void create_newKeyboardsList() { + if(File.separatorChar != '/') { + Log.w(TAG, "create_newKeyboardsList skipped due to #16183"); + return; + } + loadOldKeyboardsList(); dat_list = new ArrayList>(); @@ -190,6 +195,12 @@ public class KMManagerTest { @Test public void test_updateOldKeyboardsList() { + // #16183 + if(File.separatorChar != '/') { + Log.w(TAG, "test_updateOldKeyboardsList skipped due to #16183"); + return; + } + loadOldKeyboardsList(); Assert.assertNotNull(dat_list); From 048be2f6a833ed75dad35368b5fae7a620bc917e Mon Sep 17 00:00:00 2001 From: Keyman Build Agent Date: Thu, 2 Jul 2026 13:01:27 -0500 Subject: [PATCH 36/36] auto: increment master version to 19.0.252 Test-bot: skip Build-bot: skip --- HISTORY.md | 4 ++++ VERSION.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 4b155ec311..e8a1a9b22a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,9 @@ # Keyman Version History +## 19.0.251 alpha 2026-07-02 + +* fix(android): skip test on Windows that triggers androidx WebKit bug (#16184) + ## 19.0.250 alpha 2026-07-01 * fix(android): use `WebViewAssetLoader` for loading assets on Android (address regression in 19.0.242) (#16146) diff --git a/VERSION.md b/VERSION.md index cae332d93f..45d34a1a82 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -19.0.251 \ No newline at end of file +19.0.252 \ No newline at end of file