Merge pull request #16194 from keymanapp/auto/A19S32-merge-base-into-autocorrect
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled

auto: A19S32 merge master into autocorrect 🚂
This commit is contained in:
Keyman Server 2026-07-04 16:02:00 +02:00 • committed by GitHub
commit a6747c8ac9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 1358 additions and 542 deletions

View file

@ -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()

View file

@ -1,5 +1,33 @@
# 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)
* 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)
* 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)
* 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)
@ -39,6 +67,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)

View file

@ -1 +1 @@
19.0.248
19.0.252

View file

@ -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,

View file

@ -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'

View file

@ -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,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<OnKeyboardEventListener> kbEventListeners = null;
@ -366,8 +368,7 @@ final class KMKeyboard extends WebView {
} else {
KMManager.SystemKeyboardWebViewClient.setKeyboardLoaded(false);
}
String htmlPath = "file://" + getContext().getDir("data", Context.MODE_PRIVATE) + "/" + KMManager.KMFilename_KeyboardHtml;
String htmlPath = WebViewUtils.buildAssetUrl(KMManager.KMFilename_KeyboardHtml);
loadUrl(htmlPath);
setBackgroundColor(0);
}
@ -561,6 +562,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
@ -646,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.
@ -702,20 +704,20 @@ 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);
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 +725,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);
@ -809,28 +811,31 @@ 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 = (context.getDir("data", Context.MODE_PRIVATE).toString() +
File.separator + KMManager.KMDefault_UndefinedPackageID + File.separator);
this.packageRoot = this.dataRoot + KMManager.KMDefault_UndefinedPackageID + "/";
} else {
this.keyboardRoot = (context.getDir("data", Context.MODE_PRIVATE).toString() +
File.separator + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator);
this.packageRoot = this.dataRoot + KMManager.KMDefault_AssetPackages + "/" + packageID + "/";
}
}
public String getKeyboardRoot() {
return this.keyboardRoot;
private String getDataRoot() {
return this.dataRoot;
}
private String makeKeyboardPath(String packageID, String keyboardID, String keyboardVersion) {
String keyboardPath;
private String getPackageRoot() {
return this.packageRoot;
}
private String makeKeyboardUrl(String packageID, String keyboardID, String keyboardVersion) {
String keyboardUrl = getPackageRoot();
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) {
@ -1042,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;
@ -1059,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(keyboardRoot + 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)) {
@ -1076,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, keyboardRoot + 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, keyboardRoot + fontFile);
String fontRoot = KMManager.isDefaultFont(fontFile) ? getDataRoot() : getPackageRoot();
fontObj.put(KMManager.KMKey_FontFiles, fontRoot + fontFile);
fontObj.remove(KMManager.KMKey_FontSource);
return fontObj;
}
@ -1094,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;

View file

@ -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;
@ -19,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;
@ -31,11 +36,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(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) {
KMLog.LogError(TAG, String.format("Cannot initialize: Invalid keyboard type: %s", keyboardType.toString()));
@ -58,6 +68,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 +89,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);
Keyboard keyboardInfo = null;
if (index >= 0) {
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();
}
}

View file

@ -1,5 +1,5 @@
/**
* Copyright (C) 2017 SIL International. All rights reserved.
/*
* Keyman is copyright (C) SIL Global. MIT License.
*/
package com.keyman.engine;
@ -406,6 +406,10 @@ public final class KMManager {
return getResourceRoot() + KMDefault_LexicalModelPackages + File.separator;
}
public static String getLexicalModelsUrl() {
return WebViewUtils.buildAssetUrl(KMDefault_LexicalModelPackages + "/");
}
public static String getCloudDir() {
return getResourceRoot() + KMDefault_UndefinedPackageID + File.separator;
}
@ -496,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 DejaVuSans.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();
@ -1650,8 +1667,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 +1684,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);
@ -2225,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";
}

View file

@ -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,24 @@ 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;
if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) {
return keyboardRoot + KMManager.KMDefault_UndefinedPackageID + File.separator;
} else {
return keyboardRoot + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator;
}
private String getDataRoot() {
return WebViewUtils.buildAssetUrl("");
}
public String getKeyboardPath(Context context) {
private String getPackageRoot() {
if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) {
return getDataRoot() + KMManager.KMDefault_UndefinedPackageID + "/";
}
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(context) + keyboardID + "-" + keyboardVersion + ".js";
return getPackageRoot() + keyboardID + "-" + keyboardVersion + ".js";
} else {
return getKeyboardRoot(context) + keyboardID + ".js";
return getPackageRoot() + keyboardID + ".js";
}
}
@ -202,17 +203,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();
@ -228,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, Context context) {
private JSONObject buildDisplayFontObject(String font) {
if(font == null || font.equals("")) {
return null;
}
String keyboardRoot = this.getKeyboardRoot(context);
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;
}
/**

View file

@ -42,6 +42,28 @@ 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
*/
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) {
String appendAsset = assetPath == null ? "" :
(assetPath.startsWith("/") ? assetPath.substring(1) : assetPath);
return WebViewUtils.MAGIC_DEFAULT_DOMAIN + WebViewUtils.ASSET_DATA_PATH + appendAsset;
}
/**
* Get the Keyman Engine mode based on the Chrome version.
* @param context - The context

View file

@ -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()) {
@ -111,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<HashMap<String, String>>();
@ -182,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);

View file

@ -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"));
}
}

View file

@ -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'
}

View file

@ -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'
}

View file

@ -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'
}

View file

@ -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(

View file

@ -86,6 +86,8 @@ function API_UserAgent: string; // = 'Keyman for Windows/<ver>...'
function API_UserAgent_Developer: string; // = 'Keyman Developer/<ver>...'
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.

View file

@ -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';

View file

@ -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 `developer/src/tike/main/KeymanDeveloperOptions.pas`
*/
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 TKeymanDeveloperOptions.Read in KeymanDeveloperOptions.pas
"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<T extends KeymanDeveloperOption>(valueName: T): KeymanDeveloperOptions[T] {
return this.options[valueName];
}
public clear() {
this.options = {...DEFAULT_OPTIONS};
}
}
export const optionsManager = new KeymanDeveloperOptionsManager();

View file

@ -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());
}
}

View file

@ -23,7 +23,7 @@ export class KeymanSentry {
return true;
}
return getOption('automatically report errors', true);
return getOption('automatically report errors');
}
static init(options?: SentryNodeOptions) {

View file

@ -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<KeymanDeveloperOptions> {
export async function loadOptions(): Promise<boolean> {
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<KeymanDeveloperOptions> {
} 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<T extends KeymanDeveloperOption>(valueName: T, defaultValue: KeymanDeveloperOptions[T]): KeymanDeveloperOptions[T] {
return options[valueName] ?? defaultValue;
export function getOption<T extends KeymanDeveloperOption>(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();
}

View file

@ -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

View file

@ -23,7 +23,7 @@ export class KeymanSentry {
return true;
}
return getOption('automatically report errors', true);
return getOption('automatically report errors');
}
static init(options?: SentryNodeOptions) {

View file

@ -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();

View file

@ -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');
}
};

View file

@ -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<O extends DebugObject> (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');

View file

@ -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)) {

View file

@ -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<KeymanDeveloperOptions> {
export async function loadOptions(): Promise<boolean> {
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<KeymanDeveloperOptions> {
} 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<T extends KeymanDeveloperOption>(valueName: T, defaultValue: KeymanDeveloperOptions[T]): KeymanDeveloperOptions[T] {
return options[valueName] ?? defaultValue;
export function getOption<T extends KeymanDeveloperOption>(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();
}

View file

@ -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();
});

View file

@ -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();

View file

@ -76,11 +76,22 @@ 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.
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;

View file

@ -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;

View file

@ -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));

View file

@ -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

View file

@ -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

View file

@ -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,100 @@ 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;
try
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;
finally
val.Free;
end;
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 +319,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 +350,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 +395,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 :=

View file

@ -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();

1
ios/.gitignore vendored
View file

@ -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

View file

@ -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 = "<group>"; };
CE8B0BBE248764ED0045EB2E /* KMPResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPResource.swift; sourceTree = "<group>"; };
CE8B5BB12491DA530075CCB0 /* 13.0 Cloud to Package Migration.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = "13.0 Cloud to Package Migration.bundle"; sourceTree = "<group>"; };
CE8E6B1D2FEC17A900F5E731 /* WebViewSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSchemeHandler.swift; sourceTree = "<group>"; };
CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = "<group>"; };
CE969BE7251AD8B500376D6A /* PackageWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageWebViewController.swift; sourceTree = "<group>"; };
CE96E42C24D1229A005B8E5A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@ -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 */,

View file

@ -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
@ -182,7 +185,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 {
@ -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()

View file

@ -0,0 +1,97 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* 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,
* 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/") || mimeType == "application/json")
? "; 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!
}
}

View file

@ -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,

View file

@ -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 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"
cp "$KMW_PRODUCT/keymanweb-webview.js" "$BUNDLE_PATH/keymanweb-webview.js"

View file

@ -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'

View file

@ -7,13 +7,9 @@ type GitHub = ReturnType<typeof getOctokit>;
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

View file

@ -33,7 +33,7 @@ const getPullRequestInformation = async (
return commit_id;
};
interface PRInformation {
export interface PRInformation {
title: string;
number: number;
version?: string;

View file

@ -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}" \

View file

@ -114,6 +114,25 @@ 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
# 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
fi
}
coverage_action() {

View file

@ -19,8 +19,8 @@
<body>
<h1>Automatic Mode Example</h1>
<form action='.' method='post'>
<p><input type='text' id='multilingual' name='multilingual' size="40" /></p>
<p><textarea cols='40' rows='5'></textarea></p>
<p><input type='text' id='multilingual' name='multilingual' size="40" data-testid='multilingual'/></p>
<p><textarea cols='40' rows='5' data-testid='textarea'></textarea></p>
</form>
<a href="automatic-control">Back to Document</a>

View file

@ -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() {
<h1>Manual Control - Custom Interface</h1>
<form name='f' action='.' method='post'>
<p>Keyboard: <select id='KWControl' onchange='KWControlChange()'><option value=''>English</option></select></p>
<p><label for='KWControl'>Keyboard:</label> <select id='KWControl' onchange='KWControlChange()'><option value=''>English</option></select></p>
<p><input type='text' id='multilingual' name='multilingual' size="40" placeholder="id='multilingual'"/></p>
</form>

View file

@ -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 @@
<p><img style="border: solid 1px black; padding: 2px 2px 3px 2px" src='js/kmicon.png' alt='KeymanWeb' onclick='KWControlClick()' id='KWControl' /></p>
<p><input type='text' id='multilingual' name='multilingual' size="40" /></p>
<p><textarea cols='40' rows='5'></textarea></p>
<p><input type='text' id='multilingual' name='multilingual' size="40" data-testid='multilingual'/></p>
<p><textarea cols='40' rows='5' data-testid='textarea'></textarea></p>
<a href="manual-control">Back to Document</a>
</form>

View file

@ -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();
}

View file

@ -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();
});

View file

@ -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<PathOptionSpec>, 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;
}

View file

@ -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";
@ -388,7 +388,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);

View file

@ -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

View file

@ -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<EventMap> 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<EventMap> 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<EventMap> implements KeyboardVi
styleSheetManager: null,
specialFont: {
family: 'SpecialOSK',
files: [`${pathConfig.resources}/osk/keymanweb-osk.ttf`],
files: [`${getResourcePath({
// Not actually leveraged.
hostDevice: device as DeviceSpec,
pathConfig
})}keymanweb-osk.ttf`],
path: '' // Not actually used.
}
});

View file

@ -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<string | undefined> {
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<Page> {
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<Locator> {
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');
}

View file

@ -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<void> {
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();
});
});

View file

@ -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